1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub enum DocumentStructure {
34 Forms,
36 Outlines,
38 NamesAndAttachments,
40 NamedDestinations,
42 OptionalContent,
44 StructureTree,
46 MetadataStream,
48 PageLabels,
50 DigitalSignatures,
52 Encryption,
54 Annotations,
56 DocumentInfo,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum StructureDisposition {
63 Preserved,
65 FirstInputWins,
67 Rejected,
69 Discarded,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum ExistingDocumentEngine {
76 PreserveBase,
78 Reconstruct,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct StructureSemanticReport {
85 pub structure: DocumentStructure,
87 pub disposition: StructureDisposition,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum InputSemanticRole {
94 PreservedBase,
96 ImportedPages,
98 ReconstructedPages,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct InputSemanticReport {
105 pub path: PathBuf,
107 pub role: InputSemanticRole,
109 pub structures: Vec<StructureSemanticReport>,
111 pub selected_pages: Vec<usize>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct SemanticPreservationReport {
118 pub engine: ExistingDocumentEngine,
120 pub inputs: Vec<InputSemanticReport>,
122 pub plan: ExistingDocumentExecutionPlan,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum ExistingDocumentExecutionPlan {
129 Incremental(PageMutationReport),
131 Reconstruct {
133 page_count: usize,
135 },
136}
137
138impl ExistingDocumentExecutionPlan {
139 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum DocumentStructurePolicy {
151 Reject,
153 FirstInputWins,
155}
156
157pub type SecondaryStructurePolicy = DocumentStructurePolicy;
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct PreserveBasePolicy {
163 forms: DocumentStructurePolicy,
165 outlines: DocumentStructurePolicy,
167 names_and_attachments: DocumentStructurePolicy,
169 named_destinations: DocumentStructurePolicy,
171 optional_content: DocumentStructurePolicy,
173 structure_tree: DocumentStructurePolicy,
175 metadata: DocumentStructurePolicy,
177 page_labels: DocumentStructurePolicy,
179 digital_signatures: DocumentStructurePolicy,
181 encryption: DocumentStructurePolicy,
183 annotations: DocumentStructurePolicy,
185 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum ReconstructMetadataPolicy {
238 Discard,
240 FirstInputWins,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub struct ReconstructPolicy {
247 metadata: ReconstructMetadataPolicy,
248}
249
250impl ReconstructPolicy {
251 pub const fn metadata(self) -> ReconstructMetadataPolicy {
253 self.metadata
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum ExistingDocumentPolicy {
270 PreserveBase(PreserveBasePolicy),
272 Reconstruct(ReconstructPolicy),
274}
275
276impl ExistingDocumentPolicy {
277 pub const fn preserve_base() -> Self {
279 Self::PreserveBase(PreserveBasePolicy::fail_closed())
280 }
281
282 pub const fn reconstruct() -> Self {
285 Self::Reconstruct(ReconstructPolicy {
286 metadata: ReconstructMetadataPolicy::Discard,
287 })
288 }
289
290 pub const fn reconstruct_with_metadata_from_first() -> Self {
292 Self::Reconstruct(ReconstructPolicy {
293 metadata: ReconstructMetadataPolicy::FirstInputWins,
294 })
295 }
296
297 pub const fn engine(self) -> ExistingDocumentEngine {
299 match self {
300 Self::PreserveBase(_) => ExistingDocumentEngine::PreserveBase,
301 Self::Reconstruct(_) => ExistingDocumentEngine::Reconstruct,
302 }
303 }
304
305 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#[derive(Debug, Clone)]
338pub struct ExistingDocumentMergeInput {
339 pub path: PathBuf,
341 pub pages: Option<PageRange>,
343}
344
345impl ExistingDocumentMergeInput {
346 pub fn new(path: impl Into<PathBuf>) -> Self {
348 Self {
349 path: path.into(),
350 pages: None,
351 }
352 }
353
354 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
363pub 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 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
581fn 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
619fn 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
671fn 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
715fn 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
828fn 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
896fn 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
1053pub 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
1072pub 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
1130pub 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
1151pub 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
1200pub 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
1242pub 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
1351pub 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
1359pub 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
1373pub 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
1381pub 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
1395pub fn plan_merge_pdfs_lossless(
1397 inputs: &[LosslessMergeInput],
1398) -> OperationResult<SemanticPreservationReport> {
1399 plan_merge_pdfs(inputs, ExistingDocumentPolicy::preserve_base())
1400}
1401
1402pub 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}