Files
pdfcraft/crates/organize/src/import.rs
T
Brandon ThomasandClaude Opus 5.5 c74fb89a0d Never crash: organize has no expect/unreachable
The outline import, bookmark lookup and page-label code use `first`/`last`, a final `Err` and a
default range instead of `expect`/`unreachable!`. The crate now denies the panic lints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:12:20 -04:00

719 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Copying pages between documents: the basis of Combine Files, Insert Pages from File,
//! Extract Pages and Split.
//!
//! A page is deep-copied with everything it references (content streams, resources, fonts,
//! images, annotations), renumbered into the destination. Objects shared between the copied
//! pages (a font used on every page) are copied once. The copy never follows `/Parent` from a
//! page, so the source page tree does not come along. It rewires what points *back* into the
//! document:
//! - an annotation's `/P` points at its new page;
//! - link and GoTo destinations that target a copied page point at the copy. Links to pages that
//! were not copied are dropped, so they don't dangle;
//! - form-field widgets keep their field hierarchy, and the top-level fields are registered in the
//! destination `/AcroForm`;
//! - structure-tree links (`/StructParent(s)`) and article beads (`/B`) are removed, because the
//! source structure tree is not copied;
//! - named destinations are resolved against the source's name tree and become explicit ones;
//! - layers (optional content groups) used by the pages are registered in the destination's
//! `/OCProperties`, keeping their default on/off state.
use std::collections::HashMap;
use printcraft_cos::{Dict, Document, ObjRef, Object};
use crate::{INHERITABLE, OrganizeError, pages_root, rebuild, walk};
/// Keys that must not be followed when copying: they point back up or across the source
/// document, and following them would drag in unrelated objects.
const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"];
const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"];
struct Copier<'a> {
src: &'a Document,
map: HashMap<ObjRef, ObjRef>,
/// Source page → destination page, for rewriting `/P` and destinations.
pages: HashMap<ObjRef, ObjRef>,
/// Destination objects that are annotation dictionaries (fixed up after copying).
annots: Vec<ObjRef>,
/// Top-level form fields reached through widgets.
fields: Vec<ObjRef>,
/// Optional content groups copied: (source, destination).
ocgs: Vec<(ObjRef, ObjRef)>,
}
impl Copier<'_> {
/// Copy an indirect object (once), returning its destination reference.
fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> ObjRef {
if let Some(d) = self.map.get(&r) {
return *d;
}
// Reserve the number first so cycles (field ↔ widget) terminate.
let new = dst.add(Object::Null);
self.map.insert(r, new);
let obj = self.src.get(r);
if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) {
self.ocgs.push((r, new));
}
let copied = self.copy_value(dst, &obj, Some(new));
dst.set(new, copied);
new
}
fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option<ObjRef>) -> Object {
match o {
Object::Ref(r) => Object::Ref(self.copy_ref(dst, *r)),
Object::Array(a) => Object::Array(a.iter().map(|x| self.copy_value(dst, x, None)).collect()),
Object::Dict(d) => Object::Dict(self.copy_dict(dst, d, this)),
Object::Stream(s) => {
let mut s = s.clone();
s.dict = self.copy_dict(dst, &s.dict, None);
Object::Stream(s)
}
other => other.clone(),
}
}
fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option<ObjRef>) -> Dict {
let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type"));
let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids"));
let mut out = Dict::new();
for (k, v) in d.iter() {
if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) {
continue;
}
// Destinations are rewritten afterwards (they may target pages copied later).
if is_annot && (k.as_slice() == b"Dest" || k.as_slice() == b"A") {
out.set(k.clone(), v.clone());
continue;
}
out.set(k.clone(), self.copy_value(dst, v, None));
}
if let Some(this) = this {
if is_annot {
self.annots.push(this);
}
// A field (or widget) without a parent is a top-level field.
if is_field && !d.contains(b"Parent") {
self.fields.push(this);
}
}
out
}
/// Map a source destination (explicit array or reference to one) to the destination
/// document; `None` if it targets a page that was not copied.
fn map_dest(&self, dest: &Object) -> Option<Object> {
self.map_dest_depth(dest, 0)
}
fn map_dest_depth(&self, dest: &Object, depth: u8) -> Option<Object> {
if depth > 4 {
return None;
}
match dest {
Object::Name(n) => self.map_dest_depth(&named_dest(self.src, n)?, depth + 1),
Object::String(s) => self.map_dest_depth(&named_dest(self.src, &s.bytes)?, depth + 1),
Object::Ref(_) => self.map_dest_depth(&self.src.resolve(dest), depth + 1),
Object::Dict(d) => self.map_dest_depth(d.get(b"D")?, depth + 1),
Object::Array(a) => {
let page = a.first()?.as_ref()?;
let new_page = *self.pages.get(&page)?;
let mut a = a.clone();
a[0] = Object::Ref(new_page);
Some(Object::Array(a))
}
_ => None,
}
}
/// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations.
fn fix_annotations(&self, dst: &mut Document, annot_pages: &HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
for &a in &self.annots {
let src_dict = dst.get(a).as_dict().cloned();
let Some(d) = src_dict else { continue };
let mut d = d;
if let Some(p) = annot_pages.get(&a) {
d.set(b"P".to_vec(), Object::Ref(*p));
}
if let Some(dest) = d.get(b"Dest").cloned() {
let resolved = self.src.resolve(&dest);
match self.map_dest(&resolved) {
Some(nd) => d.set(b"Dest".to_vec(), nd),
None => {
d.remove(b"Dest");
}
}
}
if let Some(action) = d.get(b"A").cloned() {
let action = self.src.resolve(&action);
if let Some(ad) = action.as_dict() {
if ad.name(b"S") == Some(b"GoTo") {
let target = ad.get(b"D").map(|x| self.src.resolve(x));
match target.and_then(|t| self.map_dest(&t)) {
Some(nd) => {
let mut na = Dict::new();
na.set(b"S".to_vec(), Object::name("GoTo"));
na.set(b"D".to_vec(), nd);
d.set(b"A".to_vec(), Object::Dict(na));
}
None => {
d.remove(b"A");
}
}
} else {
// Other actions (URI, Launch, JavaScript…) carry no page references.
let mut c = Copier {
src: self.src,
map: self.map.clone(),
pages: HashMap::new(),
annots: Vec::new(),
fields: Vec::new(),
ocgs: Vec::new(),
};
let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None);
d.set(b"A".to_vec(), copied);
}
}
}
dst.set(a, Object::Dict(d));
}
Ok(())
}
}
/// Copy pages `src_pages` (0-based, in the given order) of `src` into `dst`, inserting them at
/// position `at`. Returns the new page references in order.
///
/// Resources identical to ones already in `dst` (the same fonts, images, colour profiles) are
/// shared rather than stored twice (see `dedupe`).
pub fn import_pages(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result<Vec<ObjRef>, OrganizeError> {
let first_new = dst.object_numbers().last().map_or(1, |n| n + 1);
let pages = import_pages_mapped(dst, src, src_pages, at)?.0;
let created: Vec<ObjRef> = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect();
crate::dedupe::dedupe_resources(dst, &created, true);
Ok(pages)
}
/// `import_pages`, also returning the source-page → destination-page map.
fn import_pages_mapped(
dst: &mut Document,
src: &Document,
src_pages: &[usize],
at: usize,
) -> Result<(Vec<ObjRef>, HashMap<ObjRef, ObjRef>), OrganizeError> {
let source = walk(src)?;
if let Some(bad) = src_pages.iter().find(|i| **i >= source.len()) {
return Err(OrganizeError::NoSuchPage(*bad));
}
let mut existing = walk(dst)?;
let root = pages_root(dst)?;
let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
// Allocate every destination page first, so destinations between copied pages resolve.
let targets: Vec<(ObjRef, ObjRef, Dict)> = src_pages
.iter()
.map(|&i| {
let (page, inherited) = &source[i];
let new = match copier.pages.get(page) {
Some(n) => *n, // the same page requested twice gets a second copy below
None => dst.add(Object::Null),
};
copier.pages.insert(*page, new);
(*page, new, inherited.clone())
})
.collect();
let mut annot_pages = HashMap::new();
let mut new_pages = Vec::new();
let mut done: HashMap<ObjRef, ObjRef> = HashMap::new();
for (page, new, inherited) in targets {
// A page listed twice: make a second, independent page object sharing resources.
let new = if done.contains_key(&page) { dst.add(Object::Null) } else { new };
let src_dict = src.get(page).as_dict().cloned().unwrap_or_default();
let mut d = Dict::new();
for (k, v) in src_dict.iter() {
if SKIP_ON_PAGE.contains(&k.as_slice()) {
continue;
}
if k.as_slice() == b"Annots" {
let list = src.resolve(v).as_array().cloned().unwrap_or_default();
let mut out = Vec::new();
for a in list {
// Direct (inline) annotation dictionaries become indirect objects, so every
// copied annotation can be fixed up the same way.
let r = match a {
Object::Ref(r) => copier.copy_ref(dst, r),
Object::Dict(_) => {
let r = dst.add(Object::Null);
let c = copier.copy_value(dst, &a, Some(r));
dst.set(r, c);
r
}
_ => continue,
};
annot_pages.insert(r, new);
out.push(Object::Ref(r));
}
d.set(b"Annots".to_vec(), Object::Array(out));
continue;
}
d.set(k.clone(), copier.copy_value(dst, v, None));
}
for k in INHERITABLE {
if !d.contains(k)
&& let Some(v) = inherited.get(k)
{
let v = copier.copy_value(dst, v, None);
d.set(k.to_vec(), v);
}
}
d.set(b"Type".to_vec(), Object::name("Page"));
d.set(b"Parent".to_vec(), Object::Ref(root));
dst.set(new, Object::Dict(d));
done.insert(page, new);
new_pages.push(new);
}
copier.fix_annotations(dst, &annot_pages)?;
register_fields(dst, &copier.fields)?;
register_layers(dst, src, &copier.ocgs)?;
let at = at.min(existing.len());
let inserted: Vec<(ObjRef, Dict)> = new_pages.iter().map(|r| (*r, Dict::new())).collect();
existing.splice(at..at, inserted);
rebuild(dst, &existing)?;
Ok((new_pages, copier.pages))
}
/// Look up a named destination in the source (`/Dests` dictionary or `/Names /Dests` tree).
fn named_dest(src: &Document, name: &[u8]) -> Option<Object> {
let catalog = src.get(src.root()?).as_dict().cloned()?;
if let Some(dests) = catalog.get(b"Dests").map(|d| src.resolve(d))
&& let Some(v) = dests.as_dict().and_then(|d| d.get(name))
{
return Some(src.resolve(v).as_ref().clone());
}
let names = src.resolve(catalog.get(b"Names")?);
let tree = names.as_dict()?.get(b"Dests")?.clone();
name_tree_lookup(src, &tree, name, 0)
}
fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Option<Object> {
if depth > 32 {
return None;
}
let node = src.resolve(node);
let d = node.as_dict()?;
if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) {
for pair in names.chunks(2) {
if let [k, v] = pair
&& src.resolve(k).as_string().is_some_and(|s| s.bytes == key)
{
return Some(src.resolve(v).as_ref().clone());
}
}
}
let kids = d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
kids.iter().find_map(|k| name_tree_lookup(src, k, key, depth + 1))
}
/// All (key, value) pairs of a name tree, in order.
fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec<u8>, Object)>) {
if depth > 32 {
return;
}
let node = src.resolve(node);
let Some(d) = node.as_dict() else { return };
if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) {
for pair in names.chunks(2) {
if let [k, v] = pair
&& let Some(k) = src.resolve(k).as_string()
{
out.push((k.bytes.clone(), v.clone()));
}
}
}
for k in d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default() {
name_tree_entries(src, &k, depth + 1, out);
}
}
/// Register copied optional content groups in the destination's `/OCProperties`, keeping each
/// layer's default visibility from the source.
fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> {
if ocgs.is_empty() {
return Ok(());
}
let src_off: Vec<Object> = src
.root()
.and_then(|r| src.get(r).as_dict().cloned())
.and_then(|c| c.get(b"OCProperties").map(|o| src.resolve(o)))
.and_then(|o| o.as_dict().and_then(|d| d.get(b"D")).map(|d| src.resolve(d)))
.and_then(|d| d.as_dict().and_then(|d| d.get(b"OFF")).map(|o| src.resolve(o)))
.and_then(|o| o.as_array().cloned())
.unwrap_or_default();
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?;
let (props_ref, mut props) = match catalog.get(b"OCProperties") {
Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()),
Some(Object::Dict(d)) => (None, d.clone()),
_ => (None, Dict::new()),
};
let list = |d: &Dict, k: &[u8], doc: &Document| d.get(k).map(|v| doc.resolve(v)).and_then(|v| v.as_array().cloned()).unwrap_or_default();
let mut all = list(&props, b"OCGs", dst);
let mut config = props.get(b"D").map(|d| dst.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default();
let (mut on, mut off, mut order) = (list(&config, b"ON", dst), list(&config, b"OFF", dst), list(&config, b"Order", dst));
for (from, to) in ocgs {
let r = Object::Ref(*to);
if all.contains(&r) {
continue;
}
all.push(r.clone());
order.push(r.clone());
if src_off.contains(&Object::Ref(*from)) { off.push(r) } else { on.push(r) }
}
props.set(b"OCGs".to_vec(), Object::Array(all));
config.set(b"ON".to_vec(), Object::Array(on));
config.set(b"OFF".to_vec(), Object::Array(off));
config.set(b"Order".to_vec(), Object::Array(order));
props.set(b"D".to_vec(), Object::Dict(config));
match props_ref {
Some(r) => dst.set(r, Object::Dict(props)),
None => dst.update_dict(root, |c| c.set(b"OCProperties".to_vec(), Object::Dict(props)))?,
}
Ok(())
}
/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed).
fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> {
if fields.is_empty() {
return Ok(());
}
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?;
let (form_ref, mut form) = match catalog.get(b"AcroForm") {
Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()),
Some(Object::Dict(d)) => (None, d.clone()),
_ => (None, Dict::new()),
};
let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
for f in fields {
if !list.contains(&Object::Ref(*f)) {
list.push(Object::Ref(*f));
}
}
form.set(b"Fields".to_vec(), Object::Array(list));
match form_ref {
Some(r) => dst.set(r, Object::Dict(form)),
None => {
let r = dst.add(form);
dst.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?;
}
}
Ok(())
}
/// A new document containing copies of `pages` from `src` (Extract Pages / Split).
/// Document information (title, author…) is carried over.
pub fn extract_pages(src: &Document, pages: &[usize]) -> Result<Document, OrganizeError> {
let mut out = Document::new_empty();
// One source: nothing to deduplicate.
import_pages_mapped(&mut out, src, pages, 0)?;
for key in crate::INFO_KEYS {
if let Some(v) = crate::info(src, key) {
crate::set_info(&mut out, key, &v)?;
}
}
Ok(out)
}
/// How to split a document.
#[derive(Clone, Debug, PartialEq)]
pub enum SplitBy {
/// Every `n` pages.
PageCount(usize),
/// Before each of these 0-based page indices (e.g. `[3, 7]` → 0–2, 3–6, 7–end).
Before(Vec<usize>),
}
/// The page ranges (0-based, end-exclusive) a split produces.
pub fn split_ranges(page_count: usize, by: &SplitBy) -> Vec<std::ops::Range<usize>> {
let mut cuts: Vec<usize> = match by {
SplitBy::PageCount(n) => (1..).map(|i| i * (*n).max(1)).take_while(|c| *c < page_count).collect(),
SplitBy::Before(v) => v.iter().copied().filter(|c| *c > 0 && *c < page_count).collect(),
};
cuts.sort_unstable();
cuts.dedup();
let mut ranges = Vec::new();
let mut start = 0;
for c in cuts.into_iter().chain(std::iter::once(page_count)) {
if c > start {
ranges.push(start..c);
start = c;
}
}
ranges
}
/// Split a document into several new documents.
pub fn split(src: &Document, by: &SplitBy) -> Result<Vec<Document>, OrganizeError> {
let n = crate::page_count(src)?;
split_ranges(n, by).into_iter().map(|r| extract_pages(src, &r.collect::<Vec<_>>())).collect()
}
/// Combine whole documents, in order, into a new document. Each source gets a top-level
/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files.
/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level
/// attachments are carried over.
pub fn combine(sources: &[(&str, &Document)]) -> Result<Document, OrganizeError> {
let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect();
combine_selected(&all)
}
/// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order;
/// `None` for all of them). Bookmarks that point at pages left out lose their destination.
pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result<Document, OrganizeError> {
let mut out = Document::new_empty();
let mut marks = Vec::new();
let mut attachments = Vec::new();
for (title, src, chosen) in sources {
let n = crate::page_count(src)?;
let pages: Vec<usize> = match chosen {
Some(p) => {
if let Some(bad) = p.iter().find(|i| **i >= n) {
return Err(OrganizeError::NoSuchPage(*bad));
}
p.to_vec()
}
None => (0..n).collect(),
};
let at = crate::page_count(&out)?;
let (new, page_map) = import_pages_mapped(&mut out, src, &pages, at)?;
if let Some(first) = new.first() {
marks.push((title.to_string(), *first, *src, page_map));
}
collect_attachments(&mut out, src, &mut attachments);
}
// Sources often share fonts, images and profiles (or are the same file): store them once.
let all: Vec<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();
crate::dedupe::dedupe_resources(&mut out, &all, false);
add_outline(&mut out, &marks)?;
set_attachments(&mut out, attachments)?;
Ok(out)
}
/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`.
fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec<u8>, Object)>) {
let Some(tree) = src
.root()
.and_then(|r| src.get(r).as_dict().cloned())
.and_then(|c| c.get(b"Names").map(|n| src.resolve(n)))
.and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned()))
else {
return;
};
let mut entries = Vec::new();
name_tree_entries(src, &tree, 0, &mut entries);
let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
for (k, v) in entries {
let v = copier.copy_value(dst, &v, None);
out.push((k, v));
}
}
/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"…
fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec<u8>, Object)>) -> Result<(), OrganizeError> {
if entries.is_empty() {
return Ok(());
}
let mut seen = std::collections::HashSet::new();
for (k, _) in entries.iter_mut() {
let base = k.clone();
let mut n = 2;
while !seen.insert(k.clone()) {
*k = [base.as_slice(), format!(" ({n})").as_bytes()].concat();
n += 1;
}
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut names = Vec::new();
for (k, v) in entries {
names.push(Object::String(printcraft_cos::PdfString::literal(k)));
names.push(v);
}
let mut tree = Dict::new();
tree.set(b"Names".to_vec(), Object::Array(names));
let tree = dst.add(tree);
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
dst.update_dict(root, |c| {
let mut n = match c.get(b"Names") {
Some(Object::Dict(d)) => d.clone(),
_ => Dict::new(),
};
n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree));
c.set(b"Names".to_vec(), Object::Dict(n));
})?;
Ok(())
}
type Mark<'a> = (String, ObjRef, &'a Document, HashMap<ObjRef, ObjRef>);
/// Replace the outline with one bookmark per source (title → its first page), each holding a
/// collapsed copy of that source's own bookmarks with destinations mapped to the copied pages.
fn add_outline(doc: &mut Document, marks: &[Mark<'_>]) -> Result<(), OrganizeError> {
if marks.is_empty() {
return Ok(());
}
let outlines = doc.add(Object::Null);
let items: Vec<ObjRef> = marks.iter().map(|_| doc.add(Object::Null)).collect();
let (Some(&first_item), Some(&last_item)) = (items.first(), items.last()) else { return Ok(()) };
for (i, ((title, page, src, page_map), r)) in marks.iter().zip(&items).enumerate() {
let mut d = Dict::new();
d.set(b"Title".to_vec(), Object::String(printcraft_cos::PdfString::text(title)));
d.set(b"Parent".to_vec(), Object::Ref(outlines));
d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(*page), Object::name("Fit")]));
if i > 0 {
d.set(b"Prev".to_vec(), Object::Ref(items[i - 1]));
}
if let Some(next) = items.get(i + 1) {
d.set(b"Next".to_vec(), Object::Ref(*next));
}
let first = src
.root()
.and_then(|r| src.get(r).as_dict().cloned())
.and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o)))
.and_then(|o| o.as_dict().and_then(|d| d.reference(b"First")));
let mapper = Copier { src, map: HashMap::new(), pages: page_map.clone(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
let mut budget = 10_000usize;
if let Some((f, l, n)) = copy_outline_level(doc, &mapper, first, *r, 0, &mut budget) {
d.set(b"First".to_vec(), Object::Ref(f));
d.set(b"Last".to_vec(), Object::Ref(l));
d.set(b"Count".to_vec(), Object::Int(-(n as i64))); // collapsed
}
doc.set(*r, Object::Dict(d));
}
let mut o = Dict::new();
o.set(b"Type".to_vec(), Object::name("Outlines"));
o.set(b"First".to_vec(), Object::Ref(first_item));
o.set(b"Last".to_vec(), Object::Ref(last_item));
o.set(b"Count".to_vec(), Object::Int(items.len() as i64));
doc.set(outlines, Object::Dict(o));
let root = doc.root().ok_or(OrganizeError::NoPageTree)?;
doc.update_dict(root, |c| {
c.set(b"Outlines".to_vec(), Object::Ref(outlines));
c.set(b"PageMode".to_vec(), Object::name("UseOutlines"));
})?;
Ok(())
}
/// Copy one level of a source outline (siblings from `first`) under `parent`, recursing into
/// children. Returns (first, last, visible count). Cycle- and size-safe.
fn copy_outline_level(
dst: &mut Document,
m: &Copier<'_>,
first: Option<ObjRef>,
parent: ObjRef,
depth: u8,
budget: &mut usize,
) -> Option<(ObjRef, ObjRef, usize)> {
if depth > 32 {
return None;
}
let mut seen = std::collections::HashSet::new();
let mut made: Vec<ObjRef> = Vec::new();
let mut total = 0;
let mut cur = first;
while let Some(r) = cur {
if !seen.insert(r) || *budget == 0 {
break;
}
*budget -= 1;
let Some(item) = m.src.get(r).as_dict().cloned() else { break };
cur = item.reference(b"Next");
let new = dst.add(Object::Null);
let mut d = Dict::new();
if let Some(t) = item.get(b"Title").map(|t| m.src.resolve(t)) {
d.set(b"Title".to_vec(), t.as_ref().clone());
}
for k in [&b"C"[..], b"F"] {
if let Some(v) = item.get(k) {
d.set(k.to_vec(), m.src.resolve(v).as_ref().clone());
}
}
let target = item.get(b"Dest").cloned().or_else(|| {
let a = m.src.resolve(item.get(b"A")?);
let a = a.as_dict()?;
(a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten()
});
if let Some(dest) = target.and_then(|t| m.map_dest(&t)) {
d.set(b"Dest".to_vec(), dest);
}
d.set(b"Parent".to_vec(), Object::Ref(parent));
if let Some(prev) = made.last() {
d.set(b"Prev".to_vec(), Object::Ref(*prev));
dst.update_dict(*prev, |p| p.set(b"Next".to_vec(), Object::Ref(new))).ok()?;
}
if let Some((f, l, n)) = copy_outline_level(dst, m, item.reference(b"First"), new, depth + 1, budget) {
d.set(b"First".to_vec(), Object::Ref(f));
d.set(b"Last".to_vec(), Object::Ref(l));
// Keep the source's open/closed state.
let open = item.int(b"Count").is_some_and(|c| c > 0);
d.set(b"Count".to_vec(), Object::Int(if open { n as i64 } else { -(n as i64) }));
}
dst.set(new, Object::Dict(d));
made.push(new);
total += 1;
}
Some((*made.first()?, *made.last()?, total))
}
/// Page `page` of `src` as a form XObject in `dst` (its content and resources, no annotations),
/// for backgrounds and watermarks taken from a PDF. The form's `/BBox` is the page's crop box
/// and its `/Matrix` undoes the page rotation, so it draws upright as displayed. Returns the
/// form and its displayed size in points.
pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<(ObjRef, (f64, f64)), OrganizeError> {
use printcraft_cos::Stream;
let all = walk(src)?;
// `walk` gives the inheritable attributes (resources, boxes, rotation); the content is
// the page's own.
let (pr, d) = all.get(page).cloned().ok_or(OrganizeError::NoSuchPage(page))?;
let own = src.get(pr).as_dict().cloned().unwrap_or_default();
let rect = |k: &[u8]| -> Option<[f64; 4]> {
let a = src.resolve(d.get(k)?);
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| src.resolve(x).as_f64()).collect();
(v.len() == 4).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])])
};
let bbox = rect(b"CropBox").or_else(|| rect(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]);
let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]);
let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360);
// Content: the page's streams, decoded and joined.
let mut content = Vec::new();
if let Some(c) = own.get(b"Contents") {
let list = match &*src.resolve(c) {
Object::Array(a) => a.clone(),
_ => vec![c.clone()],
};
for o in list {
if let Object::Stream(s) = &*src.resolve(&o) {
content.extend(s.decoded().map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?);
content.push(b'\n');
}
}
}
let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
let resources = d.get(b"Resources").map(|r| copier.copy_value(dst, r, None)).unwrap_or(Object::Dict(Dict::new()));
register_layers(dst, src, &copier.ocgs)?;
let mut fd = Dict::new();
fd.set(b"Type".to_vec(), Object::name("XObject"));
fd.set(b"Subtype".to_vec(), Object::name("Form"));
fd.set(b"BBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect()));
// Map the crop box to (0, 0)–(w, h) as displayed (turning a rotated page upright).
let m: [f64; 6] = match rotate {
90 => [0.0, -1.0, 1.0, 0.0, -bbox[1], bbox[2]],
180 => [-1.0, 0.0, 0.0, -1.0, bbox[2], bbox[3]],
270 => [0.0, 1.0, -1.0, 0.0, bbox[3], -bbox[0]],
_ => [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]],
};
fd.set(b"Matrix".to_vec(), Object::Array(m.iter().map(|v| Object::Real(*v)).collect()));
fd.set(b"Resources".to_vec(), resources);
let r = dst.add(Object::Stream(Stream::flate(fd, &content)));
let size = if rotate == 90 || rotate == 270 { (h, w) } else { (w, h) };
Ok((r, size))
}