Skip to main content

oxidize_pdf/operations/
semantic_preservation.rs

1//! Lossless split, extraction, and merge operations for existing PDFs.
2//!
3//! These APIs use the incremental page-tree mutation engine. The first input
4//! remains an exact byte prefix of the result, and unsupported document-level
5//! combinations are rejected during planning rather than silently discarded.
6
7use super::reorder::{
8    materialize_pdf_page_mutations_from_bytes, plan_pdf_page_mutations_from_bytes,
9};
10use super::{
11    OperationError, OperationResult, PageMutation, PageMutationBatch, PageMutationReport, PageRange,
12};
13use crate::parser::PdfReader;
14use std::collections::BTreeSet;
15use std::io::{Cursor, Write};
16use std::path::{Path, PathBuf};
17
18const MAX_SELECTION_PLANNING_WORK: usize = 10_000_000;
19
20const DOCUMENT_STRUCTURES: [(&str, DocumentStructure); 8] = [
21    ("AcroForm", DocumentStructure::Forms),
22    ("Outlines", DocumentStructure::Outlines),
23    ("Names", DocumentStructure::NamesAndAttachments),
24    ("Dests", DocumentStructure::NamedDestinations),
25    ("OCProperties", DocumentStructure::OptionalContent),
26    ("StructTreeRoot", DocumentStructure::StructureTree),
27    ("Metadata", DocumentStructure::MetadataStream),
28    ("PageLabels", DocumentStructure::PageLabels),
29];
30
31/// A catalog-level semantic structure considered by lossless operations.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub enum DocumentStructure {
34    /// Interactive form field tree.
35    Forms,
36    /// Document outline/bookmark tree.
37    Outlines,
38    /// Catalog name trees, including embedded files.
39    NamesAndAttachments,
40    /// Legacy catalog destination dictionary.
41    NamedDestinations,
42    /// Optional-content configuration and groups.
43    OptionalContent,
44    /// Tagged-PDF structure tree.
45    StructureTree,
46    /// Catalog metadata stream.
47    MetadataStream,
48    /// Page-label number tree.
49    PageLabels,
50    /// Digital signature fields with a signature value.
51    DigitalSignatures,
52    /// Trailer-level encryption dictionary.
53    Encryption,
54    /// Page annotations reachable from selected pages.
55    Annotations,
56    /// Trailer `/Info` document metadata.
57    DocumentInfo,
58}
59
60/// Policy applied to a detected document structure.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum StructureDisposition {
63    /// Retained through the byte-preserved base catalog.
64    Preserved,
65    /// The first input wins and the secondary value is deliberately ignored.
66    FirstInputWins,
67    /// The combination cannot be represented safely and planning fails.
68    Rejected,
69    /// Deliberately omitted by the selected reconstructive engine.
70    Discarded,
71}
72
73/// Engine selected for an existing-document operation.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum ExistingDocumentEngine {
76    /// Preserve the first input as an exact byte prefix and mutate incrementally.
77    PreserveBase,
78    /// Rebuild page appearance/content and explicitly discard catalog semantics.
79    Reconstruct,
80}
81
82/// One detected structure and its explicit operation policy.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct StructureSemanticReport {
85    /// Detected structure.
86    pub structure: DocumentStructure,
87    /// Policy applied by the operation.
88    pub disposition: StructureDisposition,
89}
90
91/// How an input document participates in a planned operation.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum InputSemanticRole {
94    /// The input is the byte-preserved base document.
95    PreservedBase,
96    /// The input contributes cloned page graphs.
97    ImportedPages,
98    /// The input contributes page appearance/content through reconstruction.
99    ReconstructedPages,
100}
101
102/// Semantic inventory for one input.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct InputSemanticReport {
105    /// Source path.
106    pub path: PathBuf,
107    /// Role in the output.
108    pub role: InputSemanticRole,
109    /// Catalog-level structures detected in the source.
110    pub structures: Vec<StructureSemanticReport>,
111    /// Selected zero-based source page indexes.
112    pub selected_pages: Vec<usize>,
113}
114
115/// Exact dry-run report returned before a lossless operation writes output.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct SemanticPreservationReport {
118    /// Engine that will execute, or executed, the operation.
119    pub engine: ExistingDocumentEngine,
120    /// Per-input semantic inventory and policy.
121    pub inputs: Vec<InputSemanticReport>,
122    /// Concrete execution plan, whose shape identifies the selected engine.
123    pub plan: ExistingDocumentExecutionPlan,
124}
125
126/// Engine-specific plan for an existing-document operation.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum ExistingDocumentExecutionPlan {
129    /// Exact object-level incremental mutation.
130    Incremental(PageMutationReport),
131    /// Reconstruct selected page appearance/content into a new document.
132    Reconstruct {
133        /// Number of pages in the reconstructed output.
134        page_count: usize,
135    },
136}
137
138impl ExistingDocumentExecutionPlan {
139    /// Number of pages in the planned output.
140    pub const fn page_count(&self) -> usize {
141        match self {
142            Self::Incremental(report) => report.page_count,
143            Self::Reconstruct { page_count } => *page_count,
144        }
145    }
146}
147
148/// Policy for one detected document-level structure.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum DocumentStructurePolicy {
151    /// Reject the operation before writing output.
152    Reject,
153    /// Keep the first input's value and deliberately ignore the secondary value.
154    FirstInputWins,
155}
156
157/// v4-preview compatibility name for [`DocumentStructurePolicy`].
158pub type SecondaryStructurePolicy = DocumentStructurePolicy;
159
160/// Policies for structures detected in secondary inputs of a preserving merge.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct PreserveBasePolicy {
163    /// Interactive forms.
164    forms: DocumentStructurePolicy,
165    /// Outline tree.
166    outlines: DocumentStructurePolicy,
167    /// Name trees and attachments.
168    names_and_attachments: DocumentStructurePolicy,
169    /// Legacy named destinations.
170    named_destinations: DocumentStructurePolicy,
171    /// Optional-content configuration.
172    optional_content: DocumentStructurePolicy,
173    /// Tagged-PDF structure tree.
174    structure_tree: DocumentStructurePolicy,
175    /// Catalog metadata stream.
176    metadata: DocumentStructurePolicy,
177    /// Page labels.
178    page_labels: DocumentStructurePolicy,
179    /// Digital signatures.
180    digital_signatures: DocumentStructurePolicy,
181    /// Encryption.
182    encryption: DocumentStructurePolicy,
183    /// Page annotations.
184    annotations: DocumentStructurePolicy,
185    /// Trailer document information.
186    document_info: DocumentStructurePolicy,
187}
188
189impl PreserveBasePolicy {
190    const fn fail_closed() -> Self {
191        Self {
192            forms: DocumentStructurePolicy::Reject,
193            outlines: DocumentStructurePolicy::Reject,
194            names_and_attachments: DocumentStructurePolicy::Reject,
195            named_destinations: DocumentStructurePolicy::Reject,
196            optional_content: DocumentStructurePolicy::Reject,
197            structure_tree: DocumentStructurePolicy::Reject,
198            metadata: DocumentStructurePolicy::FirstInputWins,
199            page_labels: DocumentStructurePolicy::Reject,
200            digital_signatures: DocumentStructurePolicy::Reject,
201            encryption: DocumentStructurePolicy::Reject,
202            annotations: DocumentStructurePolicy::Reject,
203            document_info: DocumentStructurePolicy::FirstInputWins,
204        }
205    }
206
207    /// Set the policy for page labels found in secondary merge inputs.
208    pub const fn with_page_labels(mut self, policy: DocumentStructurePolicy) -> Self {
209        self.page_labels = policy;
210        self
211    }
212
213    fn disposition(self, structure: DocumentStructure) -> StructureDisposition {
214        let policy = match structure {
215            DocumentStructure::Forms => self.forms,
216            DocumentStructure::Outlines => self.outlines,
217            DocumentStructure::NamesAndAttachments => self.names_and_attachments,
218            DocumentStructure::NamedDestinations => self.named_destinations,
219            DocumentStructure::OptionalContent => self.optional_content,
220            DocumentStructure::StructureTree => self.structure_tree,
221            DocumentStructure::MetadataStream => self.metadata,
222            DocumentStructure::PageLabels => self.page_labels,
223            DocumentStructure::DigitalSignatures => self.digital_signatures,
224            DocumentStructure::Encryption => self.encryption,
225            DocumentStructure::Annotations => self.annotations,
226            DocumentStructure::DocumentInfo => self.document_info,
227        };
228        match policy {
229            DocumentStructurePolicy::Reject => StructureDisposition::Rejected,
230            DocumentStructurePolicy::FirstInputWins => StructureDisposition::FirstInputWins,
231        }
232    }
233}
234
235/// Metadata handling supported by the reconstructive engine.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum ReconstructMetadataPolicy {
238    /// Discard document information metadata.
239    Discard,
240    /// Copy document information metadata from the first input.
241    FirstInputWins,
242}
243
244/// Valid policy for the reconstructive engine.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub struct ReconstructPolicy {
247    metadata: ReconstructMetadataPolicy,
248}
249
250impl ReconstructPolicy {
251    /// Metadata policy selected for reconstruction.
252    pub const fn metadata(self) -> ReconstructMetadataPolicy {
253        self.metadata
254    }
255}
256
257/// Explicit, type-safe policy for existing-document operations.
258///
259/// There is deliberately no [`Default`] implementation: every call site must
260/// choose preservation or reconstruction. Engine-specific policy values also
261/// cannot be assembled from arbitrary structure dispositions.
262///
263/// ```compile_fail
264/// use oxidize_pdf::operations::ExistingDocumentPolicy;
265///
266/// let policy = ExistingDocumentPolicy::default();
267/// ```
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum ExistingDocumentPolicy {
270    /// Preserve the first input byte-for-byte and control secondary structures.
271    PreserveBase(PreserveBasePolicy),
272    /// Reconstruct page appearance/content and deliberately lose other semantics.
273    Reconstruct(ReconstructPolicy),
274}
275
276impl ExistingDocumentPolicy {
277    /// Preserve the base and reject ambiguous secondary structures.
278    pub const fn preserve_base() -> Self {
279        Self::PreserveBase(PreserveBasePolicy::fail_closed())
280    }
281
282    /// Reconstruct page appearance/content and explicitly discard every
283    /// detected document-level structure.
284    pub const fn reconstruct() -> Self {
285        Self::Reconstruct(ReconstructPolicy {
286            metadata: ReconstructMetadataPolicy::Discard,
287        })
288    }
289
290    /// Reconstruct pages while copying document information from the first input.
291    pub const fn reconstruct_with_metadata_from_first() -> Self {
292        Self::Reconstruct(ReconstructPolicy {
293            metadata: ReconstructMetadataPolicy::FirstInputWins,
294        })
295    }
296
297    /// Return the selected execution engine.
298    pub const fn engine(self) -> ExistingDocumentEngine {
299        match self {
300            Self::PreserveBase(_) => ExistingDocumentEngine::PreserveBase,
301            Self::Reconstruct(_) => ExistingDocumentEngine::Reconstruct,
302        }
303    }
304
305    /// Configure page labels in secondary inputs of a preserving merge.
306    /// Reconstructive policies are unchanged because they always discard them.
307    pub const fn with_page_labels(self, policy: DocumentStructurePolicy) -> Self {
308        match self {
309            Self::PreserveBase(preserve) => Self::PreserveBase(preserve.with_page_labels(policy)),
310            reconstruct @ Self::Reconstruct(_) => reconstruct,
311        }
312    }
313
314    fn disposition(
315        self,
316        role: InputSemanticRole,
317        structure: DocumentStructure,
318    ) -> StructureDisposition {
319        match self {
320            Self::PreserveBase(_) if role == InputSemanticRole::PreservedBase => {
321                StructureDisposition::Preserved
322            }
323            Self::PreserveBase(policy) => policy.disposition(structure),
324            Self::Reconstruct(policy) => match structure {
325                DocumentStructure::DocumentInfo
326                    if policy.metadata == ReconstructMetadataPolicy::FirstInputWins =>
327                {
328                    StructureDisposition::FirstInputWins
329                }
330                _ => StructureDisposition::Discarded,
331            },
332        }
333    }
334}
335
336/// Input to a semantic merge of existing PDF documents.
337#[derive(Debug, Clone)]
338pub struct ExistingDocumentMergeInput {
339    /// PDF path.
340    pub path: PathBuf,
341    /// Pages to include, or all pages when omitted.
342    pub pages: Option<PageRange>,
343}
344
345impl ExistingDocumentMergeInput {
346    /// Include every page from `path`.
347    pub fn new(path: impl Into<PathBuf>) -> Self {
348        Self {
349            path: path.into(),
350            pages: None,
351        }
352    }
353
354    /// Include the selected pages from `path`.
355    pub fn with_pages(path: impl Into<PathBuf>, pages: PageRange) -> Self {
356        Self {
357            path: path.into(),
358            pages: Some(pages),
359        }
360    }
361}
362
363/// v4 compatibility name for a preservation-first merge input.
364pub type LosslessMergeInput = ExistingDocumentMergeInput;
365
366fn inspect_input_bytes(
367    path: &Path,
368    bytes: &[u8],
369    role: InputSemanticRole,
370    range: Option<&PageRange>,
371    policy: ExistingDocumentPolicy,
372) -> OperationResult<InputSemanticReport> {
373    let mut reader = PdfReader::new(Cursor::new(bytes)).map_err(|error| {
374        OperationError::ParseError(format!("open snapshot of {}: {error}", path.display()))
375    })?;
376    if reader.is_encrypted() {
377        let disposition = policy.disposition(role, DocumentStructure::Encryption);
378        return Err(OperationError::PdfError(crate::PdfError::PermissionDenied(
379            format!(
380                "existing-document operation cannot process encrypted PDF {} (policy disposition: {:?})",
381                path.display(), disposition
382            ),
383        )));
384    }
385    let page_count = reader.page_count().map_err(|error| {
386        OperationError::ParseError(format!("count pages in {}: {error}", path.display()))
387    })? as usize;
388    let selected_pages = range.unwrap_or(&PageRange::All).get_indices(page_count)?;
389    if selected_pages.is_empty() {
390        return Err(OperationError::NoPagesToProcess);
391    }
392    let catalog = reader.catalog().map_err(|error| {
393        OperationError::ParseError(format!("read catalog in {}: {error}", path.display()))
394    })?;
395    let mut structures: Vec<_> = DOCUMENT_STRUCTURES
396        .iter()
397        .filter_map(|(key, structure)| {
398            catalog
399                .contains_key(key)
400                .then_some(StructureSemanticReport {
401                    structure: *structure,
402                    disposition: policy.disposition(role, *structure),
403                })
404        })
405        .collect();
406    let signatures = crate::signatures::detect_signature_fields(&mut reader).map_err(|error| {
407        OperationError::ParseError(format!("inspect signatures in {}: {error}", path.display()))
408    })?;
409    if !signatures.is_empty() {
410        structures.push(StructureSemanticReport {
411            structure: DocumentStructure::DigitalSignatures,
412            disposition: policy.disposition(role, DocumentStructure::DigitalSignatures),
413        });
414    }
415    if reader.trailer().info().is_some() {
416        structures.push(StructureSemanticReport {
417            structure: DocumentStructure::DocumentInfo,
418            disposition: policy.disposition(role, DocumentStructure::DocumentInfo),
419        });
420    }
421    let document = PdfReader::new(Cursor::new(bytes))
422        .map_err(|error| OperationError::ParseError(error.to_string()))?
423        .into_document();
424    let has_annotations = selected_pages.iter().try_fold(false, |found, page| {
425        if found {
426            return Ok(true);
427        }
428        document
429            .get_page(*page as u32)
430            .map(|page| {
431                page.get_annotations()
432                    .is_some_and(|annots| !annots.is_empty())
433            })
434            .map_err(|error| OperationError::ParseError(error.to_string()))
435    })?;
436    if has_annotations {
437        structures.push(StructureSemanticReport {
438            structure: DocumentStructure::Annotations,
439            disposition: policy.disposition(role, DocumentStructure::Annotations),
440        });
441    }
442    Ok(InputSemanticReport {
443        path: path.to_path_buf(),
444        role,
445        structures,
446        selected_pages,
447    })
448}
449
450fn reject_secondary_document_structures(report: &InputSemanticReport) -> OperationResult<()> {
451    // Metadata has a deterministic first-input-wins policy. Other catalog
452    // structures require cross-document identity/name remapping and therefore
453    // fail closed until that mapping is representable.
454    let unsupported: Vec<_> = report
455        .structures
456        .iter()
457        .filter(|entry| entry.disposition == StructureDisposition::Rejected)
458        .map(|entry| entry.structure)
459        .collect();
460    if unsupported.is_empty() {
461        return Ok(());
462    }
463    Err(OperationError::ProcessingError(format!(
464        "cannot merge document-level structures from secondary input {}: {:?}; the operation was not written",
465        report.path.display(), unsupported
466    )))
467}
468
469fn selection_batch(page_count: usize, selected: &[usize]) -> OperationResult<PageMutationBatch> {
470    let selected_set: BTreeSet<_> = selected.iter().copied().collect();
471    let mut operations: Vec<_> = (0..page_count)
472        .rev()
473        .filter(|index| !selected_set.contains(index))
474        .map(|page| PageMutation::Delete { page })
475        .collect();
476    let mut current: Vec<_> = (0..page_count)
477        .filter(|index| selected_set.contains(index))
478        .collect();
479    let estimated_work = current.len().checked_mul(selected.len()).ok_or_else(|| {
480        OperationError::ProcessingError("page selection work overflow".to_string())
481    })?;
482    let already_ordered = current.as_slice() == selected;
483    if !already_ordered && estimated_work > MAX_SELECTION_PLANNING_WORK {
484        return Err(OperationError::ProcessingError(format!(
485            "page selection would require excessive reorder work ({estimated_work} position checks)"
486        )));
487    }
488    for (target, &source_page) in selected.iter().enumerate() {
489        if current.get(target) == Some(&source_page) {
490            continue;
491        }
492        if let Some(from) = current
493            .iter()
494            .enumerate()
495            .skip(target + 1)
496            .find_map(|(index, page)| (*page == source_page).then_some(index))
497        {
498            operations.push(PageMutation::Move { from, to: target });
499            let page = current.remove(from);
500            current.insert(target, page);
501        } else if let Some(from) = current.iter().position(|page| *page == source_page) {
502            operations.push(PageMutation::Duplicate {
503                page: from,
504                at: target,
505            });
506            current.insert(target, source_page);
507        }
508    }
509    Ok(PageMutationBatch { operations })
510}
511
512fn read_snapshot(path: &Path) -> OperationResult<Vec<u8>> {
513    std::fs::read(path).map_err(OperationError::Io)
514}
515
516fn ensure_snapshot_unchanged(path: &Path, expected: &[u8]) -> OperationResult<()> {
517    if read_snapshot(path)? != expected {
518        return Err(OperationError::ProcessingError(format!(
519            "source {} changed after planning; no output was published",
520            path.display()
521        )));
522    }
523    Ok(())
524}
525
526fn stage_output(path: &Path, bytes: &[u8]) -> OperationResult<tempfile::NamedTempFile> {
527    let parent = path
528        .parent()
529        .filter(|parent| !parent.as_os_str().is_empty());
530    let mut temporary = tempfile::NamedTempFile::new_in(parent.unwrap_or_else(|| Path::new(".")))?;
531    temporary.write_all(bytes)?;
532    temporary.flush()?;
533    temporary.as_file().sync_all()?;
534    Ok(temporary)
535}
536
537fn normalized_path(path: &Path) -> OperationResult<PathBuf> {
538    if path.exists() {
539        return path.canonicalize().map_err(OperationError::Io);
540    }
541    let parent = path
542        .parent()
543        .filter(|parent| !parent.as_os_str().is_empty())
544        .unwrap_or_else(|| Path::new("."))
545        .canonicalize()?;
546    let name = path
547        .file_name()
548        .ok_or_else(|| OperationError::InvalidPath {
549            reason: format!("output path {} has no file name", path.display()),
550        })?;
551    Ok(parent.join(name))
552}
553
554fn plan_batch(
555    base: &[u8],
556    batch: &PageMutationBatch,
557    page_count: usize,
558) -> OperationResult<PageMutationReport> {
559    if batch.operations.is_empty() {
560        return Ok(PageMutationReport {
561            replaced_objects: Vec::new(),
562            added_objects: Vec::new(),
563            unreachable_objects: Vec::new(),
564            page_count,
565        });
566    }
567    plan_pdf_page_mutations_from_bytes(base, batch)
568}
569
570fn materialize_batch(
571    base: &[u8],
572    batch: &PageMutationBatch,
573    page_count: usize,
574) -> OperationResult<(Vec<u8>, PageMutationReport)> {
575    if batch.operations.is_empty() {
576        return Ok((base.to_vec(), plan_batch(base, batch, page_count)?));
577    }
578    materialize_pdf_page_mutations_from_bytes(base, batch)
579}
580
581/// Plan a sparse lossless extraction without writing output.
582///
583/// # Errors
584///
585/// Returns an error for invalid page indexes, encrypted or restricted inputs,
586/// and catalog/page structures that reference pages being removed.
587fn plan_extract_pdf_pages_preserving(
588    input: impl AsRef<Path>,
589    pages: &[usize],
590    policy: ExistingDocumentPolicy,
591) -> OperationResult<SemanticPreservationReport> {
592    let input = input.as_ref();
593    if pages.is_empty() {
594        return Err(OperationError::NoPagesToProcess);
595    }
596    let range = PageRange::List(pages.to_vec());
597    let base = read_snapshot(input)?;
598    let report = inspect_input_bytes(
599        input,
600        &base,
601        InputSemanticRole::PreservedBase,
602        Some(&range),
603        policy,
604    )?;
605    let page_count = PdfReader::new(Cursor::new(&base))
606        .map_err(|error| OperationError::ParseError(error.to_string()))?
607        .page_count()
608        .map_err(|error| OperationError::ParseError(error.to_string()))?
609        as usize;
610    let batch = selection_batch(page_count, &report.selected_pages)?;
611    let mutation = plan_batch(&base, &batch, page_count)?;
612    Ok(SemanticPreservationReport {
613        engine: ExistingDocumentEngine::PreserveBase,
614        inputs: vec![report],
615        plan: ExistingDocumentExecutionPlan::Incremental(mutation),
616    })
617}
618
619/// Extract pages while retaining the source bytes and complete reachable semantics.
620///
621/// Planning is repeated immediately before the atomic write. The output is
622/// reopened and its reachability is checked by the mutation engine.
623///
624/// # Errors
625///
626/// Returns an error under the same conditions as
627/// [`plan_extract_pdf_pages_preserving`], or when atomic publication fails.
628fn extract_pdf_pages_preserving(
629    input: impl AsRef<Path>,
630    output: impl AsRef<Path>,
631    pages: &[usize],
632    policy: ExistingDocumentPolicy,
633) -> OperationResult<SemanticPreservationReport> {
634    let input = input.as_ref();
635    let output = output.as_ref();
636    let base = read_snapshot(input)?;
637    let range = PageRange::List(pages.to_vec());
638    let input_report = inspect_input_bytes(
639        input,
640        &base,
641        InputSemanticRole::PreservedBase,
642        Some(&range),
643        policy,
644    )?;
645    let actual_count = PdfReader::new(Cursor::new(&base))
646        .map_err(|error| OperationError::ParseError(error.to_string()))?
647        .page_count()
648        .map_err(|error| OperationError::ParseError(error.to_string()))?
649        as usize;
650    let batch = selection_batch(actual_count, &input_report.selected_pages)?;
651    let planned = plan_batch(&base, &batch, actual_count)?;
652    let (updated, written) = materialize_batch(&base, &batch, actual_count)?;
653    if written != planned {
654        return Err(OperationError::ProcessingError(
655            "dry-run report differed from materialized extraction; no output was published"
656                .to_string(),
657        ));
658    }
659    ensure_snapshot_unchanged(input, &base)?;
660    stage_output(output, &updated)?
661        .persist(output)
662        .map_err(|error| OperationError::Io(error.error))?;
663    let report = SemanticPreservationReport {
664        engine: ExistingDocumentEngine::PreserveBase,
665        inputs: vec![input_report],
666        plan: ExistingDocumentExecutionPlan::Incremental(planned),
667    };
668    Ok(report)
669}
670
671/// Plan every output of a lossless split without writing files.
672///
673/// # Errors
674///
675/// Returns an error when a range is empty or cannot be represented while
676/// preserving the source's reachable document semantics.
677fn plan_split_pdf_preserving(
678    input: impl AsRef<Path>,
679    ranges: &[PageRange],
680    policy: ExistingDocumentPolicy,
681) -> OperationResult<Vec<SemanticPreservationReport>> {
682    let input = input.as_ref();
683    if ranges.is_empty() {
684        return Err(OperationError::NoPagesToProcess);
685    }
686    let base = read_snapshot(input)?;
687    let mut reader = PdfReader::new(Cursor::new(&base))
688        .map_err(|error| OperationError::ParseError(error.to_string()))?;
689    let page_count = reader
690        .page_count()
691        .map_err(|error| OperationError::ParseError(error.to_string()))?
692        as usize;
693    ranges
694        .iter()
695        .map(|range| {
696            let pages = range.get_indices(page_count)?;
697            let report = inspect_input_bytes(
698                input,
699                &base,
700                InputSemanticRole::PreservedBase,
701                Some(range),
702                policy,
703            )?;
704            let batch = selection_batch(page_count, &pages)?;
705            let mutation = plan_batch(&base, &batch, page_count)?;
706            Ok(SemanticPreservationReport {
707                engine: ExistingDocumentEngine::PreserveBase,
708                inputs: vec![report],
709                plan: ExistingDocumentExecutionPlan::Incremental(mutation),
710            })
711        })
712        .collect()
713}
714
715/// Split a PDF into caller-selected outputs using lossless atomic extraction.
716///
717/// Every output is planned, materialized, and staged before the first
718/// destination is replaced. Each replacement is atomic; if a later filesystem
719/// replacement fails, previously replaced destinations are restored from
720/// snapshots.
721///
722/// # Errors
723///
724/// Returns an error when the range/output counts differ, paths alias, any dry
725/// run fails, or an output cannot be validated and transactionally published.
726fn split_pdf_preserving(
727    input: impl AsRef<Path>,
728    ranges: &[PageRange],
729    outputs: &[PathBuf],
730    policy: ExistingDocumentPolicy,
731) -> OperationResult<Vec<SemanticPreservationReport>> {
732    if ranges.len() != outputs.len() {
733        return Err(OperationError::InvalidPath {
734            reason: format!(
735                "lossless split has {} ranges but {} output paths",
736                ranges.len(),
737                outputs.len()
738            ),
739        });
740    }
741    let input = input.as_ref();
742    let normalized_input = normalized_path(input)?;
743    let mut normalized_outputs = BTreeSet::new();
744    for output in outputs {
745        let normalized = normalized_path(output)?;
746        if normalized == normalized_input {
747            return Err(OperationError::InvalidPath {
748                reason: format!("split output {} aliases the input", output.display()),
749            });
750        }
751        if !normalized_outputs.insert(normalized) {
752            return Err(OperationError::InvalidPath {
753                reason: format!("duplicate split output path {}", output.display()),
754            });
755        }
756    }
757    let base = read_snapshot(input)?;
758    let mut reader = PdfReader::new(Cursor::new(&base))
759        .map_err(|error| OperationError::ParseError(error.to_string()))?;
760    let page_count = reader
761        .page_count()
762        .map_err(|error| OperationError::ParseError(error.to_string()))?
763        as usize;
764    let mut reports = Vec::with_capacity(ranges.len());
765    let mut materialized = Vec::with_capacity(ranges.len());
766    for range in ranges {
767        let pages = range.get_indices(page_count)?;
768        let input_report = inspect_input_bytes(
769            input,
770            &base,
771            InputSemanticRole::PreservedBase,
772            Some(range),
773            policy,
774        )?;
775        let batch = selection_batch(page_count, &pages)?;
776        let planned = plan_batch(&base, &batch, page_count)?;
777        let (bytes, written) = materialize_batch(&base, &batch, page_count)?;
778        if written != planned {
779            return Err(OperationError::ProcessingError(
780                "dry-run report differed from a materialized split output; no output was published"
781                    .to_string(),
782            ));
783        }
784        reports.push(SemanticPreservationReport {
785            engine: ExistingDocumentEngine::PreserveBase,
786            inputs: vec![input_report],
787            plan: ExistingDocumentExecutionPlan::Incremental(planned),
788        });
789        materialized.push(bytes);
790    }
791    ensure_snapshot_unchanged(input, &base)?;
792    let mut staged = outputs
793        .iter()
794        .zip(&materialized)
795        .map(|(path, bytes)| stage_output(path, bytes))
796        .collect::<OperationResult<Vec<_>>>()?;
797    let backups = outputs
798        .iter()
799        .map(|path| {
800            if path.exists() {
801                std::fs::read(path).map(Some)
802            } else {
803                Ok(None)
804            }
805        })
806        .collect::<Result<Vec<_>, _>>()?;
807    for index in 0..staged.len() {
808        let temporary = staged.remove(0);
809        if let Err(error) = temporary.persist(&outputs[index]) {
810            for rollback in 0..index {
811                match &backups[rollback] {
812                    Some(bytes) => {
813                        stage_output(&outputs[rollback], bytes)?
814                            .persist(&outputs[rollback])
815                            .map_err(|persist| OperationError::Io(persist.error))?;
816                    }
817                    None => {
818                        let _ = std::fs::remove_file(&outputs[rollback]);
819                    }
820                }
821            }
822            return Err(OperationError::Io(error.error));
823        }
824    }
825    Ok(reports)
826}
827
828/// Plan a deterministic lossless merge without writing output.
829///
830/// The first input is the preserved base. Secondary inputs may contribute
831/// self-contained page graphs; catalog-level structures in secondary inputs
832/// are rejected because combining their policies would be ambiguous.
833///
834/// # Errors
835///
836/// Returns an error for fewer than one input, invalid selections, encrypted or
837/// restricted PDFs, unsupported imported page graphs, or secondary document
838/// structures that cannot be combined safely.
839fn plan_merge_pdfs_preserving(
840    inputs: &[ExistingDocumentMergeInput],
841    policy: ExistingDocumentPolicy,
842) -> OperationResult<SemanticPreservationReport> {
843    let Some(first) = inputs.first() else {
844        return Err(OperationError::NoPagesToProcess);
845    };
846    let snapshots = inputs
847        .iter()
848        .map(|input| read_snapshot(&input.path))
849        .collect::<OperationResult<Vec<_>>>()?;
850    let snapshot_files = snapshot_files(&snapshots)?;
851    let base = inspect_input_bytes(
852        &first.path,
853        &snapshots[0],
854        InputSemanticRole::PreservedBase,
855        first.pages.as_ref(),
856        policy,
857    )?;
858    let base_count = PdfReader::new(Cursor::new(&snapshots[0]))
859        .map_err(|error| OperationError::ParseError(error.to_string()))?
860        .page_count()
861        .map_err(|error| OperationError::ParseError(error.to_string()))?
862        as usize;
863    let mut batch = selection_batch(base_count, &base.selected_pages)?;
864    let mut reports = vec![base];
865    let mut insertion_index = reports[0].selected_pages.len();
866    for (index, (input, snapshot)) in inputs.iter().zip(&snapshots).enumerate().skip(1) {
867        let report = inspect_input_bytes(
868            &input.path,
869            snapshot,
870            InputSemanticRole::ImportedPages,
871            input.pages.as_ref(),
872            policy,
873        )?;
874        reject_secondary_document_structures(&report)?;
875        for &page in &report.selected_pages {
876            batch.operations.push(PageMutation::Insert {
877                source: snapshot_files[index].path().to_path_buf(),
878                page,
879                at: insertion_index,
880            });
881            insertion_index += 1;
882        }
883        reports.push(report);
884    }
885    let mutation = plan_batch(&snapshots[0], &batch, base_count)?;
886    for (input, snapshot) in inputs.iter().zip(&snapshots) {
887        ensure_snapshot_unchanged(&input.path, snapshot)?;
888    }
889    Ok(SemanticPreservationReport {
890        engine: ExistingDocumentEngine::PreserveBase,
891        inputs: reports,
892        plan: ExistingDocumentExecutionPlan::Incremental(mutation),
893    })
894}
895
896/// Merge PDFs losslessly and publish the validated output atomically.
897///
898/// # Errors
899///
900/// Returns an error under the same conditions as [`plan_merge_pdfs_preserving`],
901/// or when atomic publication or output validation fails.
902fn merge_pdfs_preserving(
903    inputs: &[ExistingDocumentMergeInput],
904    output: impl AsRef<Path>,
905    policy: ExistingDocumentPolicy,
906) -> OperationResult<SemanticPreservationReport> {
907    let Some(first) = inputs.first() else {
908        return Err(OperationError::NoPagesToProcess);
909    };
910    let snapshots = inputs
911        .iter()
912        .map(|input| read_snapshot(&input.path))
913        .collect::<OperationResult<Vec<_>>>()?;
914    let snapshot_files = snapshot_files(&snapshots)?;
915    let base_report = inspect_input_bytes(
916        &first.path,
917        &snapshots[0],
918        InputSemanticRole::PreservedBase,
919        first.pages.as_ref(),
920        policy,
921    )?;
922    let base_count = PdfReader::new(Cursor::new(&snapshots[0]))
923        .map_err(|error| OperationError::ParseError(error.to_string()))?
924        .page_count()
925        .map_err(|error| OperationError::ParseError(error.to_string()))?
926        as usize;
927    let mut batch = selection_batch(base_count, &base_report.selected_pages)?;
928    let mut reports = vec![base_report];
929    let mut at = reports[0].selected_pages.len();
930    for (index, (input, snapshot)) in inputs.iter().zip(&snapshots).enumerate().skip(1) {
931        let input_report = inspect_input_bytes(
932            &input.path,
933            snapshot,
934            InputSemanticRole::ImportedPages,
935            input.pages.as_ref(),
936            policy,
937        )?;
938        reject_secondary_document_structures(&input_report)?;
939        for &page in &input_report.selected_pages {
940            batch.operations.push(PageMutation::Insert {
941                source: snapshot_files[index].path().to_path_buf(),
942                page,
943                at,
944            });
945            at += 1;
946        }
947        reports.push(input_report);
948    }
949    let final_count = at;
950    let planned = plan_batch(&snapshots[0], &batch, final_count)?;
951    let (updated, written) = materialize_batch(&snapshots[0], &batch, final_count)?;
952    if written != planned {
953        return Err(OperationError::ProcessingError(
954            "dry-run report differed from materialized merge; no output was published".to_string(),
955        ));
956    }
957    for (input, snapshot) in inputs.iter().zip(&snapshots) {
958        ensure_snapshot_unchanged(&input.path, snapshot)?;
959    }
960    let output = output.as_ref();
961    stage_output(output, &updated)?
962        .persist(output)
963        .map_err(|error| OperationError::Io(error.error))?;
964    let report = SemanticPreservationReport {
965        engine: ExistingDocumentEngine::PreserveBase,
966        inputs: reports,
967        plan: ExistingDocumentExecutionPlan::Incremental(planned),
968    };
969    Ok(report)
970}
971
972fn reconstructive_report(
973    inputs: &[ExistingDocumentMergeInput],
974    policy: ExistingDocumentPolicy,
975) -> OperationResult<SemanticPreservationReport> {
976    let snapshots = inputs
977        .iter()
978        .map(|input| read_snapshot(&input.path))
979        .collect::<OperationResult<Vec<_>>>()?;
980    reconstructive_report_from_snapshots(inputs, &snapshots, policy)
981}
982
983fn reconstructive_report_from_snapshots(
984    inputs: &[ExistingDocumentMergeInput],
985    snapshots: &[Vec<u8>],
986    policy: ExistingDocumentPolicy,
987) -> OperationResult<SemanticPreservationReport> {
988    let mut reports = Vec::with_capacity(inputs.len());
989    let mut page_count = 0usize;
990    for (input, bytes) in inputs.iter().zip(snapshots) {
991        let report = inspect_input_bytes(
992            &input.path,
993            bytes,
994            InputSemanticRole::ReconstructedPages,
995            input.pages.as_ref(),
996            policy,
997        )?;
998        page_count = page_count
999            .checked_add(report.selected_pages.len())
1000            .ok_or_else(|| OperationError::ProcessingError("page count overflow".to_string()))?;
1001        reports.push(report);
1002    }
1003    if reports.is_empty() || page_count == 0 {
1004        return Err(OperationError::NoPagesToProcess);
1005    }
1006    Ok(SemanticPreservationReport {
1007        engine: ExistingDocumentEngine::Reconstruct,
1008        inputs: reports,
1009        plan: ExistingDocumentExecutionPlan::Reconstruct { page_count },
1010    })
1011}
1012
1013fn reject_output_aliases(output: &Path, inputs: &[&Path]) -> OperationResult<()> {
1014    let normalized_output = normalized_path(output)?;
1015    for input in inputs {
1016        if normalized_output == normalized_path(input)? {
1017            return Err(OperationError::InvalidPath {
1018                reason: format!(
1019                    "output {} aliases input {}",
1020                    output.display(),
1021                    input.display()
1022                ),
1023            });
1024        }
1025    }
1026    Ok(())
1027}
1028
1029fn snapshot_files(snapshots: &[Vec<u8>]) -> OperationResult<Vec<tempfile::NamedTempFile>> {
1030    snapshots
1031        .iter()
1032        .map(|bytes| stage_output(Path::new("snapshot.pdf"), bytes))
1033        .collect()
1034}
1035
1036fn reconstructive_extract_report(
1037    input: &Path,
1038    pages: &[usize],
1039    policy: ExistingDocumentPolicy,
1040) -> OperationResult<SemanticPreservationReport> {
1041    if pages.is_empty() {
1042        return Err(OperationError::NoPagesToProcess);
1043    }
1044    reconstructive_report(
1045        &[ExistingDocumentMergeInput::with_pages(
1046            input,
1047            PageRange::List(pages.to_vec()),
1048        )],
1049        policy,
1050    )
1051}
1052
1053/// Plan a semantic merge using an explicit preservation policy.
1054///
1055/// Unlike the legacy reconstruction API, this operation cannot silently
1056/// discard document semantics.
1057///
1058/// # Errors
1059///
1060/// Returns an error for missing inputs, invalid selections, encrypted or
1061/// restricted PDFs, unsafe imported graphs, or policy-rejected structures.
1062pub fn plan_merge_pdfs(
1063    inputs: &[ExistingDocumentMergeInput],
1064    policy: ExistingDocumentPolicy,
1065) -> OperationResult<SemanticPreservationReport> {
1066    match policy {
1067        ExistingDocumentPolicy::PreserveBase(_) => plan_merge_pdfs_preserving(inputs, policy),
1068        ExistingDocumentPolicy::Reconstruct(_) => reconstructive_report(inputs, policy),
1069    }
1070}
1071
1072/// Merge existing PDFs using an explicit preservation policy.
1073///
1074/// # Errors
1075///
1076/// Returns the planning errors described by [`plan_merge_pdfs`], or an error
1077/// if validation or atomic publication fails. No output is published on error.
1078pub fn merge_pdfs(
1079    inputs: &[ExistingDocumentMergeInput],
1080    output: impl AsRef<Path>,
1081    policy: ExistingDocumentPolicy,
1082) -> OperationResult<SemanticPreservationReport> {
1083    let output = output.as_ref();
1084    let input_paths: Vec<_> = inputs.iter().map(|input| input.path.as_path()).collect();
1085    reject_output_aliases(output, &input_paths)?;
1086    match policy {
1087        ExistingDocumentPolicy::PreserveBase(_) => merge_pdfs_preserving(inputs, output, policy),
1088        ExistingDocumentPolicy::Reconstruct(reconstruct) => {
1089            let snapshots = inputs
1090                .iter()
1091                .map(|input| read_snapshot(&input.path))
1092                .collect::<OperationResult<Vec<_>>>()?;
1093            let report = reconstructive_report_from_snapshots(inputs, &snapshots, policy)?;
1094            let snapshot_files = snapshot_files(&snapshots)?;
1095            let legacy_inputs = inputs
1096                .iter()
1097                .zip(&snapshot_files)
1098                .map(|(input, snapshot)| super::merge::MergeInput {
1099                    path: snapshot.path().to_path_buf(),
1100                    pages: input.pages.clone(),
1101                })
1102                .collect();
1103            let metadata_mode = match reconstruct.metadata() {
1104                ReconstructMetadataPolicy::Discard => super::merge::MetadataMode::None,
1105                ReconstructMetadataPolicy::FirstInputWins => super::merge::MetadataMode::FromFirst,
1106            };
1107            let temporary = tempfile::NamedTempFile::new_in(
1108                output
1109                    .parent()
1110                    .filter(|parent| !parent.as_os_str().is_empty())
1111                    .unwrap_or_else(|| Path::new(".")),
1112            )?;
1113            super::merge::merge_pdfs(
1114                legacy_inputs,
1115                temporary.path(),
1116                super::merge::MergeOptions {
1117                    metadata_mode,
1118                    ..super::merge::MergeOptions::default()
1119                },
1120            )?;
1121            temporary.as_file().sync_all()?;
1122            temporary
1123                .persist(output)
1124                .map_err(|error| OperationError::Io(error.error))?;
1125            Ok(report)
1126        }
1127    }
1128}
1129
1130/// Plan sparse extraction using an explicit preservation policy.
1131///
1132/// # Errors
1133///
1134/// Returns an error for an empty or invalid page selection, encrypted or
1135/// restricted input, or references that make removing pages unsafe.
1136pub fn plan_extract_pdf_pages(
1137    input: impl AsRef<Path>,
1138    pages: &[usize],
1139    policy: ExistingDocumentPolicy,
1140) -> OperationResult<SemanticPreservationReport> {
1141    match policy {
1142        ExistingDocumentPolicy::PreserveBase(_) => {
1143            plan_extract_pdf_pages_preserving(input, pages, policy)
1144        }
1145        ExistingDocumentPolicy::Reconstruct(_) => {
1146            reconstructive_extract_report(input.as_ref(), pages, policy)
1147        }
1148    }
1149}
1150
1151/// Extract pages from an existing PDF using an explicit preservation policy.
1152///
1153/// # Errors
1154///
1155/// Returns the planning errors described by [`plan_extract_pdf_pages`], or an
1156/// error if the source changes, validation fails, or publication fails.
1157pub fn extract_pdf_pages(
1158    input: impl AsRef<Path>,
1159    output: impl AsRef<Path>,
1160    pages: &[usize],
1161    policy: ExistingDocumentPolicy,
1162) -> OperationResult<SemanticPreservationReport> {
1163    let input = input.as_ref();
1164    let output = output.as_ref();
1165    reject_output_aliases(output, &[input])?;
1166    match policy {
1167        ExistingDocumentPolicy::PreserveBase(_) => {
1168            extract_pdf_pages_preserving(input, output, pages, policy)
1169        }
1170        ExistingDocumentPolicy::Reconstruct(_) => {
1171            let snapshot = read_snapshot(input)?;
1172            let merge_input =
1173                ExistingDocumentMergeInput::with_pages(input, PageRange::List(pages.to_vec()));
1174            let report = reconstructive_report_from_snapshots(
1175                std::slice::from_ref(&merge_input),
1176                std::slice::from_ref(&snapshot),
1177                policy,
1178            )?;
1179            let snapshot_file = stage_output(Path::new("snapshot.pdf"), &snapshot)?;
1180            let temporary = tempfile::NamedTempFile::new_in(
1181                output
1182                    .parent()
1183                    .filter(|parent| !parent.as_os_str().is_empty())
1184                    .unwrap_or_else(|| Path::new(".")),
1185            )?;
1186            super::page_extraction::extract_pages_to_file(
1187                snapshot_file.path(),
1188                pages,
1189                temporary.path(),
1190            )?;
1191            temporary.as_file().sync_all()?;
1192            temporary
1193                .persist(output)
1194                .map_err(|error| OperationError::Io(error.error))?;
1195            Ok(report)
1196        }
1197    }
1198}
1199
1200/// Plan a split using an explicit preservation policy.
1201///
1202/// # Errors
1203///
1204/// Returns an error for empty or invalid ranges, encrypted or restricted input,
1205/// or retained structures that reference pages removed from an output.
1206pub fn plan_split_pdf(
1207    input: impl AsRef<Path>,
1208    ranges: &[PageRange],
1209    policy: ExistingDocumentPolicy,
1210) -> OperationResult<Vec<SemanticPreservationReport>> {
1211    match policy {
1212        ExistingDocumentPolicy::PreserveBase(_) => plan_split_pdf_preserving(input, ranges, policy),
1213        ExistingDocumentPolicy::Reconstruct(_) => {
1214            if ranges.is_empty() {
1215                return Err(OperationError::NoPagesToProcess);
1216            }
1217            let input = input.as_ref();
1218            let snapshot = read_snapshot(input)?;
1219            let mut reader = PdfReader::new(Cursor::new(&snapshot))
1220                .map_err(|error| OperationError::ParseError(error.to_string()))?;
1221            let page_count = reader
1222                .page_count()
1223                .map_err(|error| OperationError::ParseError(error.to_string()))?
1224                as usize;
1225            ranges
1226                .iter()
1227                .map(|range| {
1228                    let pages = range.get_indices(page_count)?;
1229                    let merge_input =
1230                        ExistingDocumentMergeInput::with_pages(input, PageRange::List(pages));
1231                    reconstructive_report_from_snapshots(
1232                        std::slice::from_ref(&merge_input),
1233                        std::slice::from_ref(&snapshot),
1234                        policy,
1235                    )
1236                })
1237                .collect()
1238        }
1239    }
1240}
1241
1242/// Split an existing PDF using an explicit preservation policy.
1243///
1244/// # Errors
1245///
1246/// Returns the planning errors described by [`plan_split_pdf`], mismatched or
1247/// aliased paths, validation failures, or transactional publication failures.
1248pub fn split_pdf(
1249    input: impl AsRef<Path>,
1250    ranges: &[PageRange],
1251    outputs: &[PathBuf],
1252    policy: ExistingDocumentPolicy,
1253) -> OperationResult<Vec<SemanticPreservationReport>> {
1254    if ranges.is_empty() {
1255        return Err(OperationError::NoPagesToProcess);
1256    }
1257    match policy {
1258        ExistingDocumentPolicy::PreserveBase(_) => {
1259            split_pdf_preserving(input, ranges, outputs, policy)
1260        }
1261        ExistingDocumentPolicy::Reconstruct(_) => {
1262            if ranges.len() != outputs.len() {
1263                return Err(OperationError::InvalidPath {
1264                    reason: format!(
1265                        "split has {} ranges but {} output paths",
1266                        ranges.len(),
1267                        outputs.len()
1268                    ),
1269                });
1270            }
1271            let input = input.as_ref();
1272            let normalized_input = normalized_path(input)?;
1273            let mut normalized_outputs = BTreeSet::new();
1274            for output in outputs {
1275                let normalized = normalized_path(output)?;
1276                if normalized == normalized_input || !normalized_outputs.insert(normalized) {
1277                    return Err(OperationError::InvalidPath {
1278                        reason: format!("invalid or duplicate split output {}", output.display()),
1279                    });
1280                }
1281            }
1282            let snapshot = read_snapshot(input)?;
1283            let mut reader = PdfReader::new(Cursor::new(&snapshot))
1284                .map_err(|error| OperationError::ParseError(error.to_string()))?;
1285            let page_count = reader
1286                .page_count()
1287                .map_err(|error| OperationError::ParseError(error.to_string()))?
1288                as usize;
1289            let reports = ranges
1290                .iter()
1291                .map(|range| {
1292                    let pages = range.get_indices(page_count)?;
1293                    let merge_input =
1294                        ExistingDocumentMergeInput::with_pages(input, PageRange::List(pages));
1295                    reconstructive_report_from_snapshots(
1296                        std::slice::from_ref(&merge_input),
1297                        std::slice::from_ref(&snapshot),
1298                        policy,
1299                    )
1300                })
1301                .collect::<OperationResult<Vec<_>>>()?;
1302            let snapshot_file = stage_output(Path::new("snapshot.pdf"), &snapshot)?;
1303            let mut staged = Vec::with_capacity(outputs.len());
1304            for (report, output) in reports.iter().zip(outputs) {
1305                let parent = output
1306                    .parent()
1307                    .filter(|parent| !parent.as_os_str().is_empty())
1308                    .unwrap_or_else(|| Path::new("."));
1309                let temporary = tempfile::NamedTempFile::new_in(parent)?;
1310                super::page_extraction::extract_pages_to_file(
1311                    snapshot_file.path(),
1312                    &report.inputs[0].selected_pages,
1313                    temporary.path(),
1314                )?;
1315                temporary.as_file().sync_all()?;
1316                staged.push(temporary);
1317            }
1318            let backups = outputs
1319                .iter()
1320                .map(|path| {
1321                    if path.exists() {
1322                        std::fs::read(path).map(Some)
1323                    } else {
1324                        Ok(None)
1325                    }
1326                })
1327                .collect::<Result<Vec<_>, _>>()?;
1328            for index in 0..staged.len() {
1329                let temporary = staged.remove(0);
1330                if let Err(error) = temporary.persist(&outputs[index]) {
1331                    for rollback in 0..index {
1332                        match &backups[rollback] {
1333                            Some(bytes) => {
1334                                stage_output(&outputs[rollback], bytes)?
1335                                    .persist(&outputs[rollback])
1336                                    .map_err(|persist| OperationError::Io(persist.error))?;
1337                            }
1338                            None => {
1339                                let _ = std::fs::remove_file(&outputs[rollback]);
1340                            }
1341                        }
1342                    }
1343                    return Err(OperationError::Io(error.error));
1344                }
1345            }
1346            Ok(reports)
1347        }
1348    }
1349}
1350
1351/// Plan a v4 preservation-first extraction.
1352pub fn plan_extract_pdf_pages_lossless(
1353    input: impl AsRef<Path>,
1354    pages: &[usize],
1355) -> OperationResult<SemanticPreservationReport> {
1356    plan_extract_pdf_pages(input, pages, ExistingDocumentPolicy::preserve_base())
1357}
1358
1359/// Execute a v4 preservation-first extraction.
1360pub fn extract_pdf_pages_lossless(
1361    input: impl AsRef<Path>,
1362    output: impl AsRef<Path>,
1363    pages: &[usize],
1364) -> OperationResult<SemanticPreservationReport> {
1365    extract_pdf_pages(
1366        input,
1367        output,
1368        pages,
1369        ExistingDocumentPolicy::preserve_base(),
1370    )
1371}
1372
1373/// Plan a v4 preservation-first split.
1374pub fn plan_split_pdf_lossless(
1375    input: impl AsRef<Path>,
1376    ranges: &[PageRange],
1377) -> OperationResult<Vec<SemanticPreservationReport>> {
1378    plan_split_pdf(input, ranges, ExistingDocumentPolicy::preserve_base())
1379}
1380
1381/// Execute a v4 preservation-first split.
1382pub fn split_pdf_lossless(
1383    input: impl AsRef<Path>,
1384    ranges: &[PageRange],
1385    outputs: &[PathBuf],
1386) -> OperationResult<Vec<SemanticPreservationReport>> {
1387    split_pdf(
1388        input,
1389        ranges,
1390        outputs,
1391        ExistingDocumentPolicy::preserve_base(),
1392    )
1393}
1394
1395/// Plan a v4 preservation-first merge.
1396pub fn plan_merge_pdfs_lossless(
1397    inputs: &[LosslessMergeInput],
1398) -> OperationResult<SemanticPreservationReport> {
1399    plan_merge_pdfs(inputs, ExistingDocumentPolicy::preserve_base())
1400}
1401
1402/// Execute a v4 preservation-first merge.
1403pub fn merge_pdfs_lossless(
1404    inputs: &[LosslessMergeInput],
1405    output: impl AsRef<Path>,
1406) -> OperationResult<SemanticPreservationReport> {
1407    merge_pdfs(inputs, output, ExistingDocumentPolicy::preserve_base())
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412    use super::*;
1413
1414    fn semantic_fixture() -> Vec<u8> {
1415        semantic_fixture_with_field(
1416            b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (field) /P 3 0 R /Rect [0 0 10 10] >>",
1417        )
1418    }
1419
1420    fn semantic_fixture_with_field(field: &[u8]) -> Vec<u8> {
1421        let objects = [
1422            b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /Outlines << /Type /Outlines /Count 0 >> /Names << /EmbeddedFiles << /Names [(attachment.txt) << /F (attachment.txt) >>] >> >> /Dests << >> /OCProperties << /OCGs [] /D << >> >> /StructTreeRoot << /Type /StructTreeRoot /K [] >> /Metadata 4 0 R /PageLabels << /Nums [] >> >>".as_slice(),
1423            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".as_slice(),
1424            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /StructParents 0 /Annots [5 0 R 6 0 R] >>".as_slice(),
1425            b"<< /Type /Metadata /Subtype /XML /Length 0 >>\nstream\n\nendstream".as_slice(),
1426            b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [3 0 R /Fit] >>".as_slice(),
1427            field,
1428        ];
1429        let mut bytes = b"%PDF-1.7\n".to_vec();
1430        let mut offsets = Vec::new();
1431        for (index, object) in objects.iter().enumerate() {
1432            offsets.push(bytes.len());
1433            bytes.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes());
1434            bytes.extend_from_slice(object);
1435            bytes.extend_from_slice(b"\nendobj\n");
1436        }
1437        let xref = bytes.len();
1438        bytes.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n");
1439        for offset in offsets {
1440            bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
1441        }
1442        bytes.extend_from_slice(
1443            format!("trailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
1444        );
1445        bytes
1446    }
1447
1448    #[test]
1449    fn inventories_digital_signatures_separately_from_forms() {
1450        let bytes = semantic_fixture_with_field(
1451            b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (signature) /P 3 0 R /Rect [0 0 10 10] /V << /Type /Sig /Filter /Adobe.PPKLite /ByteRange [0 0 0 0] /Contents () >> >>",
1452        );
1453        let report = inspect_input_bytes(
1454            Path::new("signed.pdf"),
1455            &bytes,
1456            InputSemanticRole::PreservedBase,
1457            None,
1458            ExistingDocumentPolicy::preserve_base(),
1459        )
1460        .unwrap();
1461
1462        assert!(report.structures.iter().any(|entry| {
1463            entry.structure == DocumentStructure::DigitalSignatures
1464                && entry.disposition == StructureDisposition::Preserved
1465        }));
1466    }
1467
1468    #[test]
1469    fn empty_annotations_array_is_not_reported_as_document_semantics() {
1470        let fixture = String::from_utf8(semantic_fixture()).unwrap();
1471        let bytes = fixture
1472            .replace("/Annots [5 0 R 6 0 R]", "/Annots []")
1473            .into_bytes();
1474        let report = inspect_input_bytes(
1475            Path::new("empty-annots.pdf"),
1476            &bytes,
1477            InputSemanticRole::ImportedPages,
1478            None,
1479            ExistingDocumentPolicy::preserve_base(),
1480        )
1481        .unwrap();
1482
1483        assert!(!report
1484            .structures
1485            .iter()
1486            .any(|entry| entry.structure == DocumentStructure::Annotations));
1487    }
1488
1489    #[test]
1490    fn inventories_every_required_catalog_structure_with_explicit_dispositions() {
1491        let bytes = semantic_fixture();
1492        let path = Path::new("semantic-fixture.pdf");
1493        let base = inspect_input_bytes(
1494            path,
1495            &bytes,
1496            InputSemanticRole::PreservedBase,
1497            None,
1498            ExistingDocumentPolicy::preserve_base(),
1499        )
1500        .unwrap();
1501        assert_eq!(base.structures.len(), DOCUMENT_STRUCTURES.len() + 1);
1502        assert!(base
1503            .structures
1504            .iter()
1505            .all(|entry| entry.disposition == StructureDisposition::Preserved));
1506
1507        let imported = inspect_input_bytes(
1508            path,
1509            &bytes,
1510            InputSemanticRole::ImportedPages,
1511            None,
1512            ExistingDocumentPolicy::preserve_base(),
1513        )
1514        .unwrap();
1515        assert_eq!(imported.structures.len(), DOCUMENT_STRUCTURES.len() + 1);
1516        assert!(imported.structures.iter().any(|entry| {
1517            entry.structure == DocumentStructure::Annotations
1518                && entry.disposition == StructureDisposition::Rejected
1519        }));
1520        assert!(imported
1521            .structures
1522            .iter()
1523            .any(|entry| entry.structure == DocumentStructure::MetadataStream
1524                && entry.disposition == StructureDisposition::FirstInputWins));
1525        assert_eq!(
1526            imported
1527                .structures
1528                .iter()
1529                .filter(|entry| entry.disposition == StructureDisposition::Rejected)
1530                .count(),
1531            DOCUMENT_STRUCTURES.len()
1532        );
1533    }
1534
1535    #[test]
1536    fn all_page_selection_preserves_annotations_forms_links_and_catalog_bytes_exactly() {
1537        let bytes = semantic_fixture();
1538        let batch = selection_batch(1, &[0]).unwrap();
1539        let (output, report) = materialize_batch(&bytes, &batch, 1).unwrap();
1540        assert_eq!(output, bytes);
1541        assert_eq!(report.page_count, 1);
1542        assert!(report.replaced_objects.is_empty());
1543    }
1544}