Skip to main content

rightkit_pdf/
organize.rs

1//! Page organisation beyond merge: delete, reorder, rotate and insert pages, keeping the outline
2//! and the page labels (feature `merge`).
3//!
4//! Every operation takes document bytes and returns new document bytes. Nothing is changed in
5//! place, so a caller keeps the input as its undo snapshot (copy on write at the byte level).
6//! Each result is reloaded and checked before it is returned ([`OrganizeError::Verification`]):
7//! the page count is the expected one, every page is a `/Page` whose `/Parent` is the one flat
8//! `/Pages` node, outline destinations that name a page name one of the result's pages, and the
9//! page-label tree reads back.
10//!
11//! Strategy (reference: pdfcraft `crates/organize`, MIT OR Apache-2.0; no code is copied): the
12//! attributes a page inherits (`Resources`, `MediaBox`, `CropBox`, `Rotate`) are written onto each
13//! page by [`crate::merge`]'s materialisation, then the old page tree is replaced by one flat
14//! `/Pages` node. Page objects keep their ids, so outline items, link destinations and named
15//! destinations that point at a surviving page stay valid.
16//!
17//! # What is kept, what is dropped
18//!
19//! - **Outline.** Kept. An item whose destination was a deleted page loses its `/Dest` (or its
20//!   `GoTo` action) and stays as a plain heading with its children. Named destinations are not
21//!   rewritten: a name that pointed at a deleted page now resolves to nothing.
22//! - **Page labels.** Rebuilt for the new order: a label range is split wherever the new order
23//!   breaks the old numbering (`/St` is adjusted so each page keeps its label). Inserted pages
24//!   carry the labels of the document they came from, or their number in that document when it
25//!   has none. A document without labels gets none.
26//! - **Deleted pages** and everything only they reached (content, fonts, images) are pruned.
27//!   References to a deleted page elsewhere (structure tree, destinations) become `null` first, so
28//!   nothing keeps the page alive.
29//! - **Inserted pages** bring their own objects; the source's outline, labels and forms are not
30//!   imported.
31//!
32//! Encrypted inputs are refused. Signatures do not survive: the file is rewritten whole.
33
34use std::collections::HashSet;
35
36use lopdf::{Dictionary, Document, Object, ObjectId};
37
38use crate::inspect::{deref_obj, load_pdf, resolve_rotation};
39use crate::merge::{load, materialize_inherited, MergeError, SharedValues};
40
41#[derive(Debug, thiserror::Error)]
42pub enum OrganizeError {
43    #[error("document has no pages")]
44    Empty,
45    #[error("input could not be parsed: {0}")]
46    Parse(String),
47    #[error("input is encrypted")]
48    Encrypted,
49    #[error("page {0} is out of range")]
50    PageOutOfRange(u32),
51    #[error("page order is not a permutation of 1..={0}")]
52    NotAPermutation(u32),
53    #[error("deleting these pages would leave no page")]
54    DeletesEveryPage,
55    #[error("rotation {0} is not a multiple of 90 degrees")]
56    InvalidRotation(i32),
57    #[error("insert position {at} is past the end of a {pages}-page document")]
58    InvalidPosition { at: u32, pages: u32 },
59    #[error("no pages selected")]
60    NoPagesSelected,
61    #[error("result failed verification: {0}")]
62    Verification(String),
63    #[error("serialize failed: {0}")]
64    Write(String),
65}
66
67impl OrganizeError {
68    /// Stable snake_case code.
69    pub fn code(&self) -> &'static str {
70        match self {
71            OrganizeError::Empty => "empty",
72            OrganizeError::Parse(_) => "parse",
73            OrganizeError::Encrypted => "encrypted",
74            OrganizeError::PageOutOfRange(_) => "page_out_of_range",
75            OrganizeError::NotAPermutation(_) => "not_a_permutation",
76            OrganizeError::DeletesEveryPage => "deletes_every_page",
77            OrganizeError::InvalidRotation(_) => "invalid_rotation",
78            OrganizeError::InvalidPosition { .. } => "invalid_position",
79            OrganizeError::NoPagesSelected => "no_pages_selected",
80            OrganizeError::Verification(_) => "verification_failed",
81            OrganizeError::Write(_) => "write",
82        }
83    }
84}
85
86fn from_merge(error: MergeError) -> OrganizeError {
87    match error {
88        MergeError::Encrypted(_) => OrganizeError::Encrypted,
89        MergeError::Empty => OrganizeError::Empty,
90        other => OrganizeError::Parse(other.to_string()),
91    }
92}
93
94/// A loaded document whose pages carry their inherited attributes, in page order.
95struct Open {
96    doc: Document,
97    pages: Vec<ObjectId>,
98}
99
100fn open(index: usize, bytes: &[u8]) -> Result<Open, OrganizeError> {
101    let mut doc = load(index, bytes).map_err(from_merge)?;
102    let pages: Vec<ObjectId> = doc.get_pages().into_values().collect();
103    if pages.is_empty() {
104        return Err(OrganizeError::Empty);
105    }
106    let mut shared = SharedValues::new();
107    for id in &pages {
108        materialize_inherited(&mut doc, *id, &mut shared);
109    }
110    Ok(Open { doc, pages })
111}
112
113// ---------------------------------------------------------------------------------------------
114// Page labels (ISO 32000-1 12.4.2)
115// ---------------------------------------------------------------------------------------------
116
117/// One page-label range: first page index (0-based) and its label dictionary (`/S`, `/P`, `/St`).
118#[derive(Clone, Debug)]
119struct LabelRange {
120    start: usize,
121    dict: Dictionary,
122}
123
124const MAX_TREE_DEPTH: usize = 32;
125
126fn collect_nums(
127    doc: &Document,
128    node: &Dictionary,
129    out: &mut Vec<LabelRange>,
130    depth: usize,
131    visited: &mut HashSet<ObjectId>,
132) {
133    if depth > MAX_TREE_DEPTH {
134        return;
135    }
136    if let Some(Object::Array(nums)) = node.get(b"Nums").ok().and_then(|o| deref_obj(doc, o)) {
137        for pair in nums.chunks(2) {
138            let [key, value] = pair else { continue };
139            let start = deref_obj(doc, key).and_then(|o| o.as_i64().ok());
140            let dict = deref_obj(doc, value).and_then(|o| o.as_dict().ok());
141            if let (Some(start), Some(dict)) = (start, dict) {
142                if start >= 0 {
143                    out.push(LabelRange {
144                        start: start as usize,
145                        dict: dict.clone(),
146                    });
147                }
148            }
149        }
150    }
151    if let Some(Object::Array(kids)) = node.get(b"Kids").ok().and_then(|o| deref_obj(doc, o)) {
152        for kid in kids {
153            let Object::Reference(id) = kid else { continue };
154            if !visited.insert(*id) {
155                continue;
156            }
157            if let Ok(Object::Dictionary(child)) = doc.get_object(*id) {
158                collect_nums(doc, child, out, depth + 1, visited);
159            }
160        }
161    }
162}
163
164fn read_labels(doc: &Document) -> Vec<LabelRange> {
165    let mut out = Vec::new();
166    let tree = doc
167        .catalog()
168        .ok()
169        .and_then(|c| c.get(b"PageLabels").ok())
170        .and_then(|o| deref_obj(doc, o))
171        .and_then(|o| o.as_dict().ok());
172    if let Some(tree) = tree {
173        collect_nums(doc, tree, &mut out, 0, &mut HashSet::new());
174    }
175    out.sort_by_key(|range| range.start);
176    out.dedup_by_key(|range| range.start);
177    out
178}
179
180/// The range that covers page `index`: its start and dictionary. A page before the first range (or
181/// in a document with none) is labelled by a default decimal range starting at page 0.
182fn range_for(ranges: &[LabelRange], index: usize) -> (usize, Dictionary) {
183    match ranges.iter().rev().find(|range| range.start <= index) {
184        Some(range) => (range.start, range.dict.clone()),
185        None => {
186            let mut dict = Dictionary::new();
187            dict.set("S", Object::Name(b"D".to_vec()));
188            (0, dict)
189        }
190    }
191}
192
193/// Where a page of the result came from.
194#[derive(Clone, Copy, Debug, PartialEq, Eq)]
195enum Origin {
196    /// 0-based page of the document being changed.
197    Dest(usize),
198    /// 0-based page of a document pages were inserted from.
199    Source(usize),
200}
201
202/// The label ranges for `plan` (the result's pages in order), or `None` when neither document has
203/// labels and the catalog must be left alone.
204fn build_labels(
205    plan: &[Origin],
206    dest: &[LabelRange],
207    source: &[LabelRange],
208) -> Option<Vec<LabelRange>> {
209    if dest.is_empty() && source.is_empty() {
210        return None;
211    }
212    let mut out: Vec<LabelRange> = Vec::new();
213    let mut previous: Option<(Origin, usize)> = None;
214    for (new_index, origin) in plan.iter().enumerate() {
215        let (ranges, old_index) = match origin {
216            Origin::Dest(i) => (dest, *i),
217            Origin::Source(i) => (source, *i),
218        };
219        let (range_start, dict) = range_for(ranges, old_index);
220        let continues = match (previous, origin) {
221            (Some((Origin::Dest(p), start)), Origin::Dest(i)) => {
222                p + 1 == *i && start == range_start
223            }
224            (Some((Origin::Source(p), start)), Origin::Source(i)) => {
225                p + 1 == *i && start == range_start
226            }
227            _ => false,
228        };
229        if !continues {
230            // The page keeps the label it had: its number within its old range moves into `/St`.
231            let mut dict = dict;
232            let base = dict
233                .get(b"St")
234                .ok()
235                .and_then(|o| o.as_i64().ok())
236                .unwrap_or(1);
237            dict.set(
238                "St",
239                Object::Integer(base + old_index.saturating_sub(range_start) as i64),
240            );
241            out.push(LabelRange {
242                start: new_index,
243                dict,
244            });
245        }
246        previous = Some((*origin, range_start));
247    }
248    Some(out)
249}
250
251fn labels_object(ranges: &[LabelRange]) -> Object {
252    let mut nums = Vec::with_capacity(ranges.len() * 2);
253    for range in ranges {
254        nums.push(Object::Integer(range.start as i64));
255        nums.push(Object::Dictionary(range.dict.clone()));
256    }
257    let mut tree = Dictionary::new();
258    tree.set("Nums", Object::Array(nums));
259    Object::Dictionary(tree)
260}
261
262// ---------------------------------------------------------------------------------------------
263// Outline
264// ---------------------------------------------------------------------------------------------
265
266/// Every outline item id, bounded and cycle-safe.
267fn outline_items(doc: &Document) -> Vec<ObjectId> {
268    let root = doc
269        .catalog()
270        .ok()
271        .and_then(|c| c.get(b"Outlines").ok())
272        .and_then(|o| o.as_reference().ok());
273    let mut items = Vec::new();
274    let mut seen: HashSet<ObjectId> = HashSet::new();
275    let mut stack: Vec<ObjectId> = Vec::new();
276    if let Some(root) = root {
277        if let Ok(dict) = doc.get_dictionary(root) {
278            if let Ok(Object::Reference(first)) = dict.get(b"First") {
279                stack.push(*first);
280            }
281        }
282    }
283    while let Some(id) = stack.pop() {
284        if !seen.insert(id) || items.len() >= 1_000_000 {
285            continue;
286        }
287        let Ok(dict) = doc.get_dictionary(id) else {
288            continue;
289        };
290        items.push(id);
291        for key in [&b"First"[..], b"Next"] {
292            if let Ok(Object::Reference(next)) = dict.get(key) {
293                stack.push(*next);
294            }
295        }
296    }
297    items
298}
299
300/// True for a destination array whose page was nulled.
301fn destination_is_dead(destination: Option<&Object>) -> bool {
302    matches!(destination, Some(Object::Array(items)) if matches!(items.first(), Some(Object::Null)))
303}
304
305fn clean_dead_destinations(doc: &mut Document) {
306    for id in outline_items(doc) {
307        let Ok(Object::Dictionary(item)) = doc.get_object_mut(id) else {
308            continue;
309        };
310        if destination_is_dead(item.get(b"Dest").ok()) {
311            item.remove(b"Dest");
312        }
313        let dead_action = match item.get(b"A") {
314            Ok(Object::Dictionary(action)) => {
315                action
316                    .get(b"S")
317                    .ok()
318                    .and_then(|s| s.as_name().ok())
319                    .is_some_and(|name| name == &b"GoTo"[..])
320                    && destination_is_dead(action.get(b"D").ok())
321            }
322            _ => false,
323        };
324        if dead_action {
325            item.remove(b"A");
326        }
327    }
328}
329
330// ---------------------------------------------------------------------------------------------
331// Assembly and verification
332// ---------------------------------------------------------------------------------------------
333
334fn root_pages_id(doc: &Document) -> Option<ObjectId> {
335    doc.catalog()
336        .ok()
337        .and_then(|c| c.get(b"Pages").ok())
338        .and_then(|o| o.as_reference().ok())
339}
340
341/// Replaces the page tree by one flat node over `new_pages`, applies `labels`, drops what only
342/// `removed` pages reached, and serialises.
343fn finish(
344    mut doc: Document,
345    new_pages: &[ObjectId],
346    removed: &HashSet<ObjectId>,
347    labels: Option<Vec<LabelRange>>,
348) -> Result<Vec<u8>, OrganizeError> {
349    let root_id = doc
350        .trailer
351        .get(b"Root")
352        .ok()
353        .and_then(|o| o.as_reference().ok())
354        .ok_or_else(|| OrganizeError::Parse("no catalog".to_string()))?;
355    let pages_id = match root_pages_id(&doc) {
356        Some(id) => id,
357        None => {
358            let id = doc.new_object_id();
359            if let Ok(Object::Dictionary(catalog)) = doc.get_object_mut(root_id) {
360                catalog.set("Pages", Object::Reference(id));
361            }
362            id
363        }
364    };
365    let kids: Vec<Object> = new_pages.iter().map(|id| Object::Reference(*id)).collect();
366    let mut node = Dictionary::new();
367    node.set("Type", Object::Name(b"Pages".to_vec()));
368    node.set("Count", Object::Integer(kids.len() as i64));
369    node.set("Kids", Object::Array(kids));
370    doc.objects.insert(pages_id, Object::Dictionary(node));
371    for id in new_pages {
372        if let Ok(Object::Dictionary(page)) = doc.get_object_mut(*id) {
373            page.set("Parent", Object::Reference(pages_id));
374        }
375    }
376    if let Some(ranges) = labels {
377        if let Ok(Object::Dictionary(catalog)) = doc.get_object_mut(root_id) {
378            if ranges.is_empty() {
379                catalog.remove(b"PageLabels");
380            } else {
381                catalog.set("PageLabels", labels_object(&ranges));
382            }
383        }
384    }
385    if !removed.is_empty() {
386        let _ = doc.traverse_objects(|object| {
387            if let Object::Reference(id) = object {
388                if removed.contains(&*id) {
389                    *object = Object::Null;
390                }
391            }
392        });
393        clean_dead_destinations(&mut doc);
394    }
395    doc.prune_objects();
396    let mut bytes = Vec::new();
397    doc.save_to(&mut bytes)
398        .map_err(|e| OrganizeError::Write(e.to_string()))?;
399    verify_structure(&bytes, new_pages.len())?;
400    Ok(bytes)
401}
402
403/// The structural check every result passes (see the module documentation). Public so a host can
404/// run it on a result it is about to stage.
405pub fn verify_structure(bytes: &[u8], expected_pages: usize) -> Result<(), OrganizeError> {
406    let fail = |message: String| OrganizeError::Verification(message);
407    let doc = load_pdf(bytes).map_err(|e| fail(format!("reload failed: {e}")))?;
408    let pages = doc.get_pages();
409    if pages.len() != expected_pages {
410        return Err(fail(format!(
411            "expected {expected_pages} pages, found {}",
412            pages.len()
413        )));
414    }
415    let root = root_pages_id(&doc).ok_or_else(|| fail("catalog has no /Pages".to_string()))?;
416    let mut seen: HashSet<ObjectId> = HashSet::new();
417    for (number, id) in &pages {
418        if !seen.insert(*id) {
419            return Err(fail(format!("page {number} repeats a page object")));
420        }
421        let dict = doc
422            .get_dictionary(*id)
423            .map_err(|_| fail(format!("page {number} is not a dictionary")))?;
424        if !dict.has_type(b"Page") {
425            return Err(fail(format!("page {number} is not a /Page")));
426        }
427        let parent = dict.get(b"Parent").ok().and_then(|p| p.as_reference().ok());
428        if parent != Some(root) {
429            return Err(fail(format!("page {number} is not under the root /Pages")));
430        }
431    }
432    let count = doc
433        .get_dictionary(root)
434        .ok()
435        .and_then(|d| d.get(b"Count").ok())
436        .and_then(|c| c.as_i64().ok());
437    if count != Some(expected_pages as i64) {
438        return Err(fail("root /Pages /Count is wrong".to_string()));
439    }
440    for id in outline_items(&doc) {
441        let Ok(item) = doc.get_dictionary(id) else {
442            continue;
443        };
444        let mut destinations: Vec<&Object> = Vec::new();
445        if let Ok(destination) = item.get(b"Dest") {
446            destinations.push(destination);
447        }
448        if let Ok(Object::Dictionary(action)) = item.get(b"A") {
449            if let Ok(destination) = action.get(b"D") {
450                destinations.push(destination);
451            }
452        }
453        for destination in destinations {
454            if let Object::Array(array) = destination {
455                if let Some(Object::Reference(page)) = array.first() {
456                    if !seen.contains(page) {
457                        return Err(fail("an outline item points at a missing page".to_string()));
458                    }
459                }
460            }
461        }
462    }
463    let labels = read_labels(&doc);
464    if labels.iter().any(|range| range.start >= expected_pages) {
465        return Err(fail(
466            "a page-label range starts past the last page".to_string(),
467        ));
468    }
469    Ok(())
470}
471
472// ---------------------------------------------------------------------------------------------
473// Operations
474// ---------------------------------------------------------------------------------------------
475
476/// Deletes `pages` (1-based, repeats ignored). Deleting every page is refused.
477pub fn delete_pages(source: &[u8], pages: &[u32]) -> Result<Vec<u8>, OrganizeError> {
478    let Open { doc, pages: all } = open(0, source)?;
479    let count = all.len();
480    let mut drop: HashSet<usize> = HashSet::new();
481    for &page in pages {
482        if page == 0 || page as usize > count {
483            return Err(OrganizeError::PageOutOfRange(page));
484        }
485        drop.insert(page as usize - 1);
486    }
487    if drop.is_empty() {
488        return Err(OrganizeError::NoPagesSelected);
489    }
490    if drop.len() == count {
491        return Err(OrganizeError::DeletesEveryPage);
492    }
493    let dest_labels = read_labels(&doc);
494    let mut kept = Vec::new();
495    let mut plan = Vec::new();
496    let mut removed = HashSet::new();
497    for (index, id) in all.iter().enumerate() {
498        if drop.contains(&index) {
499            removed.insert(*id);
500        } else {
501            kept.push(*id);
502            plan.push(Origin::Dest(index));
503        }
504    }
505    let labels = build_labels(&plan, &dest_labels, &[]);
506    finish(doc, &kept, &removed, labels)
507}
508
509/// Puts the pages in `order`: a permutation of `1..=page_count` where entry `i` is the old number
510/// of the page that becomes page `i + 1`.
511pub fn reorder_pages(source: &[u8], order: &[u32]) -> Result<Vec<u8>, OrganizeError> {
512    let Open { doc, pages: all } = open(0, source)?;
513    let count = all.len();
514    let mut seen = vec![false; count];
515    if order.len() != count {
516        return Err(OrganizeError::NotAPermutation(count as u32));
517    }
518    for &page in order {
519        let index = (page as usize)
520            .checked_sub(1)
521            .filter(|index| *index < count)
522            .ok_or(OrganizeError::NotAPermutation(count as u32))?;
523        if std::mem::replace(&mut seen[index], true) {
524            return Err(OrganizeError::NotAPermutation(count as u32));
525        }
526    }
527    let dest_labels = read_labels(&doc);
528    let plan: Vec<Origin> = order
529        .iter()
530        .map(|page| Origin::Dest(*page as usize - 1))
531        .collect();
532    let new_pages: Vec<ObjectId> = order.iter().map(|page| all[*page as usize - 1]).collect();
533    let labels = build_labels(&plan, &dest_labels, &[]);
534    finish(doc, &new_pages, &HashSet::new(), labels)
535}
536
537/// Turns `pages` (1-based) clockwise by `degrees`, a multiple of 90 (negative turns the other
538/// way), added to each page's effective `/Rotate`.
539pub fn rotate_pages(source: &[u8], pages: &[u32], degrees: i32) -> Result<Vec<u8>, OrganizeError> {
540    if degrees % 90 != 0 {
541        return Err(OrganizeError::InvalidRotation(degrees));
542    }
543    let Open {
544        mut doc,
545        pages: all,
546    } = open(0, source)?;
547    if pages.is_empty() {
548        return Err(OrganizeError::NoPagesSelected);
549    }
550    let mut targets: Vec<usize> = Vec::new();
551    for &page in pages {
552        if page == 0 || page as usize > all.len() {
553            return Err(OrganizeError::PageOutOfRange(page));
554        }
555        if !targets.contains(&(page as usize - 1)) {
556            targets.push(page as usize - 1);
557        }
558    }
559    for index in targets {
560        let id = all[index];
561        let current = doc
562            .get_object(id)
563            .ok()
564            .and_then(|o| o.as_dict().ok())
565            .map(|page| resolve_rotation(&doc, page))
566            .unwrap_or(0);
567        let next = (current + degrees).rem_euclid(360);
568        if let Ok(Object::Dictionary(page)) = doc.get_object_mut(id) {
569            page.set("Rotate", Object::Integer(i64::from(next)));
570        }
571    }
572    finish(doc, &all, &HashSet::new(), None)
573}
574
575/// Inserts pages of `source` (all of them, or the 1-based `source_pages` in that order, repeats
576/// allowed) into `dest` so that `at` pages of `dest` come before them (`0` inserts at the start,
577/// the page count appends).
578pub fn insert_pages(
579    dest: &[u8],
580    at: u32,
581    source: &[u8],
582    source_pages: Option<&[u32]>,
583) -> Result<Vec<u8>, OrganizeError> {
584    let Open {
585        mut doc,
586        pages: dest_pages,
587    } = open(0, dest)?;
588    if at as usize > dest_pages.len() {
589        return Err(OrganizeError::InvalidPosition {
590            at,
591            pages: dest_pages.len() as u32,
592        });
593    }
594    let Open {
595        doc: mut from,
596        pages: source_all,
597    } = open(1, source)?;
598    let selection: Vec<usize> = match source_pages {
599        None => (0..source_all.len()).collect(),
600        Some([]) => return Err(OrganizeError::NoPagesSelected),
601        Some(list) => {
602            let mut picked = Vec::with_capacity(list.len());
603            for &page in list {
604                if page == 0 || page as usize > source_all.len() {
605                    return Err(OrganizeError::PageOutOfRange(page));
606                }
607                picked.push(page as usize - 1);
608            }
609            picked
610        }
611    };
612    let dest_labels = read_labels(&doc);
613    let source_labels = read_labels(&from);
614    from.renumber_objects_with(doc.max_id + 1);
615    let source_ids: Vec<ObjectId> = from.get_pages().into_values().collect();
616    doc.max_id = doc.max_id.max(from.max_id);
617    doc.objects.extend(from.objects);
618
619    let mut inserted: Vec<ObjectId> = Vec::with_capacity(selection.len());
620    let mut used: HashSet<ObjectId> = HashSet::new();
621    for index in &selection {
622        let id = *source_ids
623            .get(*index)
624            .ok_or(OrganizeError::PageOutOfRange(*index as u32 + 1))?;
625        if used.insert(id) {
626            inserted.push(id);
627        } else {
628            // A repeated page: a copy of the page dictionary under a new id (content is shared).
629            let copy = doc
630                .get_object(id)
631                .cloned()
632                .map_err(|_| OrganizeError::PageOutOfRange(*index as u32 + 1))?;
633            let new_id = doc.new_object_id();
634            doc.objects.insert(new_id, copy);
635            inserted.push(new_id);
636        }
637    }
638
639    let at = at as usize;
640    let mut new_pages: Vec<ObjectId> = Vec::new();
641    let mut plan: Vec<Origin> = Vec::new();
642    for (index, id) in dest_pages.iter().enumerate().take(at) {
643        new_pages.push(*id);
644        plan.push(Origin::Dest(index));
645    }
646    for (id, index) in inserted.iter().zip(&selection) {
647        new_pages.push(*id);
648        plan.push(Origin::Source(*index));
649    }
650    for (index, id) in dest_pages.iter().enumerate().skip(at) {
651        new_pages.push(*id);
652        plan.push(Origin::Dest(index));
653    }
654    let labels = build_labels(&plan, &dest_labels, &source_labels);
655    finish(doc, &new_pages, &HashSet::new(), labels)
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::inspect::page_graph_from_bytes;
662    use lopdf::{dictionary, Stream, StringFormat};
663
664    /// `n` pages, page `k` showing "P{k}"; optional label ranges (roman for the first two pages,
665    /// decimal after) and one outline item per page.
666    fn numbered(n: usize, labels: bool, outline: bool) -> Vec<u8> {
667        let mut doc = Document::with_version("1.7");
668        let pages_id = doc.new_object_id();
669        let font = doc.add_object(dictionary! {
670            "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica",
671            "Encoding" => "WinAnsiEncoding",
672        });
673        let resources = doc.add_object(dictionary! { "Font" => dictionary! { "F1" => font } });
674        let mut page_ids = Vec::new();
675        for k in 1..=n {
676            let content = doc.add_object(Stream::new(
677                Dictionary::new(),
678                format!("BT /F1 12 Tf 72 700 Td (P{k}) Tj ET").into_bytes(),
679            ));
680            page_ids.push(doc.add_object(dictionary! {
681                "Type" => "Page", "Parent" => pages_id, "Contents" => content,
682                "Resources" => resources,
683                "MediaBox" => Object::Array(vec![
684                    Object::Integer(0), Object::Integer(0), Object::Integer(612), Object::Integer(792),
685                ]),
686            }));
687        }
688        doc.objects.insert(
689            pages_id,
690            Object::Dictionary(dictionary! {
691                "Type" => "Pages",
692                "Kids" => Object::Array(page_ids.iter().map(|id| Object::Reference(*id)).collect()),
693                "Count" => n as i64,
694            }),
695        );
696        let mut catalog = dictionary! { "Type" => "Catalog", "Pages" => pages_id };
697        if labels {
698            catalog.set(
699                "PageLabels",
700                dictionary! { "Nums" => Object::Array(vec![
701                    Object::Integer(0), Object::Dictionary(dictionary! { "S" => "r" }),
702                    Object::Integer(2), Object::Dictionary(dictionary! { "S" => "D" }),
703                ]) },
704            );
705        }
706        if outline {
707            let outlines = doc.new_object_id();
708            let items: Vec<ObjectId> = (0..n).map(|_| doc.new_object_id()).collect();
709            for (k, id) in items.iter().enumerate() {
710                let mut item = dictionary! {
711                    "Title" => Object::String(format!("Item {}", k + 1).into_bytes(), StringFormat::Literal),
712                    "Parent" => outlines,
713                    "Dest" => Object::Array(vec![
714                        Object::Reference(page_ids[k]), Object::Name(b"Fit".to_vec()),
715                    ]),
716                };
717                if k + 1 < n {
718                    item.set("Next", Object::Reference(items[k + 1]));
719                }
720                if k > 0 {
721                    item.set("Prev", Object::Reference(items[k - 1]));
722                }
723                doc.objects.insert(*id, Object::Dictionary(item));
724            }
725            doc.objects.insert(
726                outlines,
727                Object::Dictionary(dictionary! {
728                    "Type" => "Outlines", "First" => items[0], "Last" => items[n - 1],
729                    "Count" => n as i64,
730                }),
731            );
732            catalog.set("Outlines", Object::Reference(outlines));
733        }
734        let catalog_id = doc.add_object(catalog);
735        doc.trailer.set("Root", Object::Reference(catalog_id));
736        let mut out = Vec::new();
737        doc.save_to(&mut out).expect("fixture saves");
738        out
739    }
740
741    fn texts(bytes: &[u8]) -> Vec<String> {
742        let count = load_pdf(bytes).unwrap().get_pages().len();
743        (1..=count as u32)
744            .map(|page| {
745                page_graph_from_bytes(bytes, page)
746                    .unwrap()
747                    .islands
748                    .iter()
749                    .map(|i| i.text.clone())
750                    .collect::<Vec<_>>()
751                    .join("")
752            })
753            .collect()
754    }
755
756    #[test]
757    fn delete_keeps_the_others_and_drops_the_dead_outline_destination() {
758        let out = delete_pages(&numbered(4, false, true), &[2]).unwrap();
759        assert_eq!(texts(&out), ["P1", "P3", "P4"]);
760        let doc = load_pdf(&out).unwrap();
761        let items = outline_items(&doc);
762        assert_eq!(items.len(), 4, "the heading stays");
763        let with_dest = items
764            .iter()
765            .filter(|id| doc.get_dictionary(**id).unwrap().has(b"Dest"))
766            .count();
767        assert_eq!(with_dest, 3);
768    }
769
770    #[test]
771    fn deleted_page_content_does_not_survive() {
772        let out = delete_pages(&numbered(3, false, true), &[2]).unwrap();
773        let doc = load_pdf(&out).unwrap();
774        let has_p2 = doc.objects.values().any(|o| {
775            o.as_stream()
776                .ok()
777                .and_then(|s| s.decompressed_content().ok())
778                .is_some_and(|c| c.windows(4).any(|w| w == b"(P2)"))
779        });
780        assert!(!has_p2);
781    }
782
783    #[test]
784    fn reorder_rebuilds_the_label_ranges() {
785        let out = reorder_pages(&numbered(4, true, false), &[3, 4, 1, 2]).unwrap();
786        assert_eq!(texts(&out), ["P3", "P4", "P1", "P2"]);
787        let labels = read_labels(&load_pdf(&out).unwrap());
788        assert_eq!(labels.iter().map(|r| r.start).collect::<Vec<_>>(), [0, 2]);
789        assert_eq!(labels[0].dict.get(b"S").unwrap().as_name().unwrap(), b"D");
790        assert_eq!(labels[1].dict.get(b"S").unwrap().as_name().unwrap(), b"r");
791    }
792
793    #[test]
794    fn delete_shifts_st_so_pages_keep_their_labels() {
795        // Pages 3 and 4 are decimal 1 and 2; deleting page 3 leaves page 4 as decimal 2.
796        let out = delete_pages(&numbered(4, true, false), &[3]).unwrap();
797        let labels = read_labels(&load_pdf(&out).unwrap());
798        let last = labels.last().unwrap();
799        assert_eq!(last.start, 2);
800        assert_eq!(last.dict.get(b"St").unwrap().as_i64().unwrap(), 2);
801    }
802
803    #[test]
804    fn rotate_adds_to_the_effective_rotation() {
805        let source = numbered(2, false, false);
806        let once = rotate_pages(&source, &[1], 90).unwrap();
807        let twice = rotate_pages(&once, &[1], 270).unwrap();
808        let rotation = |bytes: &[u8], page: u32| {
809            let doc = load_pdf(bytes).unwrap();
810            let id = doc.get_pages()[&page];
811            resolve_rotation(&doc, doc.get_dictionary(id).unwrap())
812        };
813        assert_eq!(rotation(&once, 1), 90);
814        assert_eq!(rotation(&once, 2), 0);
815        assert_eq!(rotation(&twice, 1), 0);
816    }
817
818    #[test]
819    fn insert_places_pages_and_keeps_both_documents_text() {
820        let dest = numbered(2, false, true);
821        let source = numbered(3, false, false);
822        let out = insert_pages(&dest, 1, &source, Some(&[3, 3])).unwrap();
823        assert_eq!(texts(&out), ["P1", "P3", "P3", "P2"]);
824        let appended = insert_pages(&dest, 2, &source, None).unwrap();
825        assert_eq!(texts(&appended), ["P1", "P2", "P1", "P2", "P3"]);
826    }
827
828    #[test]
829    fn bad_requests_are_typed() {
830        let source = numbered(3, false, false);
831        assert!(matches!(
832            reorder_pages(&source, &[1, 1, 2]),
833            Err(OrganizeError::NotAPermutation(3))
834        ));
835        assert!(matches!(
836            delete_pages(&source, &[1, 2, 3]),
837            Err(OrganizeError::DeletesEveryPage)
838        ));
839        assert!(matches!(
840            delete_pages(&source, &[4]),
841            Err(OrganizeError::PageOutOfRange(4))
842        ));
843        assert!(matches!(
844            rotate_pages(&source, &[1], 45),
845            Err(OrganizeError::InvalidRotation(45))
846        ));
847        assert!(matches!(
848            insert_pages(&source, 9, &source, None),
849            Err(OrganizeError::InvalidPosition { at: 9, pages: 3 })
850        ));
851    }
852}