Skip to main content

oxidize_pdf/verification/
semantic_comparison.rs

1//! Bounded semantic comparison of complete PDF object graphs.
2
3use crate::error::{PdfError, Result};
4use crate::parser::objects::{PdfArray, PdfDictionary, PdfObject};
5use crate::parser::{ParseOptions, PdfDocument, PdfReader};
6use quick_xml::events::{BytesStart, BytesText, Event};
7use quick_xml::{Reader as XmlReader, Writer as XmlWriter};
8use sha2::{Digest, Sha256};
9use std::cell::RefCell;
10use std::collections::{BTreeMap, HashMap, HashSet};
11use std::io::Cursor;
12use std::rc::Rc;
13
14/// Resource limits applied independently to each compared document.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct SemanticComparisonLimits {
17    /// Maximum number of indirect objects reachable from one comparison root.
18    pub max_objects: usize,
19    /// Maximum nesting depth of direct and indirect objects.
20    pub max_depth: usize,
21    /// Maximum total decoded stream bytes visited in one document.
22    pub max_decoded_stream_bytes: usize,
23    /// Maximum total bytes materialized as canonical representations.
24    pub max_canonical_bytes: usize,
25    /// Maximum total extracted logical-text bytes.
26    pub max_extracted_text_bytes: usize,
27    /// Maximum number of latest in-use objects outside `/Root` and `/Info`.
28    pub max_unreachable_objects: usize,
29    /// Maximum number of physical incremental revisions.
30    pub max_revisions: usize,
31}
32
33impl Default for SemanticComparisonLimits {
34    fn default() -> Self {
35        Self {
36            max_objects: 100_000,
37            max_depth: 256,
38            max_decoded_stream_bytes: 256 * 1024 * 1024,
39            max_canonical_bytes: 256 * 1024 * 1024,
40            max_extracted_text_bytes: 64 * 1024 * 1024,
41            max_unreachable_objects: 10_000,
42            max_revisions: 1_024,
43        }
44    }
45}
46
47/// Options controlling semantic PDF comparison.
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct SemanticComparisonOptions {
50    /// Hard limits for adversarial object graphs and decoded streams.
51    pub limits: SemanticComparisonLimits,
52}
53
54/// Independent semantic domain associated with a difference.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SemanticDifferenceClass {
57    /// Rendered page appearance changed.
58    Visual,
59    /// Extracted logical text changed.
60    Textual,
61    /// Reachable document structure or page-appearance operators changed.
62    Structural,
63    /// Document information metadata changed.
64    Metadata,
65    /// Signatures, permissions, encryption, or signed byte ranges changed.
66    Security,
67    /// Physical serialization differs without changing supported semantics.
68    SerializationOnly,
69}
70
71/// One stable, machine-readable semantic difference.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SemanticPdfDifference {
74    /// Stable logical path, independent of indirect object numbering.
75    pub path: String,
76    /// Semantic domain affected by the change.
77    pub class: SemanticDifferenceClass,
78    /// Concise description of the observed change.
79    pub description: String,
80}
81
82/// How one indirect object changed in a physical PDF revision.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum RevisionObjectChangeKind {
85    /// The object became in-use for the first time.
86    Added,
87    /// A later revision supplied a new definition or generation.
88    Replaced,
89    /// The xref revision marked a previously known object free.
90    Freed,
91}
92
93/// Revision-attributed indirect-object change.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct RevisionObjectChange {
96    /// Object number as physically serialized in this document.
97    pub object_number: u32,
98    /// Generation recorded by the revision's xref entry.
99    pub generation: u16,
100    /// State transition introduced in this revision.
101    pub kind: RevisionObjectChangeKind,
102}
103
104/// One physical cross-reference revision, ordered oldest to newest.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct PdfRevisionSummary {
107    /// Zero-based chronological revision index.
108    pub index: usize,
109    /// Byte offset of this revision's xref table or xref stream.
110    pub xref_offset: u64,
111    /// Object changes attributed to this revision.
112    pub object_changes: Vec<RevisionObjectChange>,
113    /// Hash of the normalized document state at the end of this revision.
114    pub semantic_fingerprint: [u8; 32],
115}
116
117/// Result of a bounded semantic comparison.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct SemanticComparisonResult {
120    /// True when every currently supported semantic domain is equivalent.
121    pub semantically_equal: bool,
122    /// Stable differences sorted by logical path.
123    pub differences: Vec<SemanticPdfDifference>,
124    /// Physical revision history of the left input.
125    pub left_revisions: Vec<PdfRevisionSummary>,
126    /// Physical revision history of the right input.
127    pub right_revisions: Vec<PdfRevisionSummary>,
128    /// Latest in-use indirect objects unreachable from `/Root` and `/Info`.
129    pub left_unreachable_objects: Vec<(u32, u16)>,
130    /// Latest in-use indirect objects unreachable from `/Root` and `/Info`.
131    pub right_unreachable_objects: Vec<(u32, u16)>,
132    /// Reachable indirect objects paired by their stable semantic traversal path.
133    pub semantically_equivalent_objects: Vec<((u32, u16), (u32, u16))>,
134}
135
136/// Compare two PDFs after normalizing object numbers, dictionary order, stream
137/// filters, stream lengths, and volatile creation/modification timestamps.
138///
139/// # Errors
140///
141/// Returns an error when either PDF is malformed, encrypted, exceeds a
142/// configured limit, or contains a stream that cannot be decoded safely.
143pub fn compare_pdfs_semantically(
144    left: &[u8],
145    right: &[u8],
146    options: &SemanticComparisonOptions,
147) -> Result<SemanticComparisonResult> {
148    let left_bytes = left;
149    let right_bytes = right;
150    let left = SemanticSnapshot::build(left_bytes, options)?;
151    let right = SemanticSnapshot::build(right_bytes, options)?;
152    let mut differences = Vec::new();
153    for (path, left_domain) in &left.domains {
154        let right_domain = right.domains.get(path);
155        if right_domain != Some(left_domain) {
156            differences.push(domain_difference(path));
157        }
158    }
159    for path in right.domains.keys() {
160        if !left.domains.contains_key(path) {
161            differences.push(domain_difference(path));
162        }
163    }
164    if left.catalog != right.catalog && left.domains == right.domains {
165        differences.push(SemanticPdfDifference {
166            path: "/Catalog/ReachableGraph".to_string(),
167            class: SemanticDifferenceClass::Structural,
168            description: "reachable object graph differs outside a specialized domain".to_string(),
169        });
170    }
171    if left.metadata != right.metadata {
172        differences.push(SemanticPdfDifference {
173            path: "/Trailer/Info".to_string(),
174            class: SemanticDifferenceClass::Metadata,
175            description: "document information dictionary differs".to_string(),
176        });
177    }
178    if left.text != right.text {
179        differences.push(SemanticPdfDifference {
180            path: "/Pages/Text".to_string(),
181            class: SemanticDifferenceClass::Textual,
182            description: "extracted logical text differs".to_string(),
183        });
184    }
185    if left.signatures != right.signatures {
186        differences.push(SemanticPdfDifference {
187            path: "/Signatures".to_string(),
188            class: SemanticDifferenceClass::Security,
189            description: "signature byte ranges or post-signing changes differ".to_string(),
190        });
191    }
192    if left.unreachable_semantics != right.unreachable_semantics {
193        differences.push(SemanticPdfDifference {
194            path: "/UnreachableObjects".to_string(),
195            class: SemanticDifferenceClass::Structural,
196            description: "unreachable indirect-object content differs".to_string(),
197        });
198    }
199    let mut left_revision_semantics: Vec<_> = left
200        .revisions
201        .iter()
202        .map(|revision| revision.semantic_fingerprint)
203        .collect();
204    let mut right_revision_semantics: Vec<_> = right
205        .revisions
206        .iter()
207        .map(|revision| revision.semantic_fingerprint)
208        .collect();
209    left_revision_semantics.dedup();
210    right_revision_semantics.dedup();
211    if left_revision_semantics != right_revision_semantics {
212        differences.push(SemanticPdfDifference {
213            path: "/Revisions".to_string(),
214            class: SemanticDifferenceClass::Structural,
215            description: "semantic document history differs across incremental revisions"
216                .to_string(),
217        });
218    }
219    let semantically_equivalent_objects =
220        pair_equivalent_objects(&left.object_fingerprints, &right.object_fingerprints);
221    if differences.is_empty() && left_bytes != right_bytes {
222        differences.push(SemanticPdfDifference {
223            path: "/Serialization".to_string(),
224            class: SemanticDifferenceClass::SerializationOnly,
225            description: "physical serialization differs without a supported semantic change"
226                .to_string(),
227        });
228    }
229    differences.sort_by(|left, right| left.path.cmp(&right.path));
230    let semantically_equal = differences
231        .iter()
232        .all(|difference| difference.class == SemanticDifferenceClass::SerializationOnly);
233    Ok(SemanticComparisonResult {
234        semantically_equal,
235        differences,
236        left_revisions: left.revisions,
237        right_revisions: right.revisions,
238        left_unreachable_objects: left.unreachable_objects,
239        right_unreachable_objects: right.unreachable_objects,
240        semantically_equivalent_objects,
241    })
242}
243
244fn pair_equivalent_objects(
245    left: &[((u32, u16), [u8; 32])],
246    right: &[((u32, u16), [u8; 32])],
247) -> Vec<((u32, u16), (u32, u16))> {
248    let mut left_by_fingerprint: BTreeMap<[u8; 32], Vec<(u32, u16)>> = BTreeMap::new();
249    let mut right_by_fingerprint: BTreeMap<[u8; 32], Vec<(u32, u16)>> = BTreeMap::new();
250    for (reference, fingerprint) in left {
251        left_by_fingerprint
252            .entry(*fingerprint)
253            .or_default()
254            .push(*reference);
255    }
256    for (reference, fingerprint) in right {
257        right_by_fingerprint
258            .entry(*fingerprint)
259            .or_default()
260            .push(*reference);
261    }
262    let mut pairs = Vec::new();
263    for (fingerprint, mut left_references) in left_by_fingerprint {
264        let Some(mut right_references) = right_by_fingerprint.remove(&fingerprint) else {
265            continue;
266        };
267        left_references.sort_unstable();
268        right_references.sort_unstable();
269        pairs.extend(left_references.into_iter().zip(right_references));
270    }
271    pairs.sort_unstable();
272    pairs
273}
274
275fn domain_difference(path: &str) -> SemanticPdfDifference {
276    let (class, description) = if path.starts_with("/Pages/") {
277        if path.ends_with("/Annotations") {
278            (
279                SemanticDifferenceClass::Structural,
280                "page annotations differ",
281            )
282        } else {
283            (SemanticDifferenceClass::Visual, "page appearance differs")
284        }
285    } else {
286        match path {
287            "/Catalog/OCProperties" => (
288                SemanticDifferenceClass::Visual,
289                "optional content configuration differs",
290            ),
291            "/Catalog/Metadata" => (SemanticDifferenceClass::Metadata, "XMP metadata differs"),
292            "/Catalog/Perms" | "/Catalog/DSS" => (
293                SemanticDifferenceClass::Security,
294                "signature permissions or validation data differs",
295            ),
296            "/Catalog/AcroForm" => (
297                SemanticDifferenceClass::Structural,
298                "interactive forms differ",
299            ),
300            "/Catalog/Names" => (
301                SemanticDifferenceClass::Structural,
302                "names or attachments differ",
303            ),
304            "/Catalog/Outlines" => (SemanticDifferenceClass::Structural, "outlines differ"),
305            "/Catalog/StructTreeRoot" | "/Catalog/MarkInfo" => {
306                (SemanticDifferenceClass::Structural, "tag structure differs")
307            }
308            _ => (
309                SemanticDifferenceClass::Structural,
310                "reachable structure differs",
311            ),
312        }
313    };
314    SemanticPdfDifference {
315        path: path.to_string(),
316        class,
317        description: description.to_string(),
318    }
319}
320
321#[derive(Clone)]
322struct ComparisonBudget {
323    limits: SemanticComparisonLimits,
324    usage: Rc<RefCell<ComparisonUsage>>,
325}
326
327#[derive(Default)]
328struct ComparisonUsage {
329    decoded_stream_bytes: usize,
330    canonical_bytes: usize,
331    indirect_objects: HashSet<(u32, u16)>,
332}
333
334impl ComparisonBudget {
335    fn new(limits: &SemanticComparisonLimits) -> Self {
336        Self {
337            limits: limits.clone(),
338            usage: Rc::new(RefCell::new(ComparisonUsage::default())),
339        }
340    }
341
342    fn charge_decoded(&self, bytes: usize) -> Result<()> {
343        let mut usage = self.usage.borrow_mut();
344        usage.decoded_stream_bytes = usage
345            .decoded_stream_bytes
346            .checked_add(bytes)
347            .ok_or_else(|| limit("decoded stream bytes", self.limits.max_decoded_stream_bytes))?;
348        if usage.decoded_stream_bytes > self.limits.max_decoded_stream_bytes {
349            return Err(limit(
350                "decoded stream bytes",
351                self.limits.max_decoded_stream_bytes,
352            ));
353        }
354        Ok(())
355    }
356
357    fn charge_canonical(&self, bytes: usize) -> Result<()> {
358        let mut usage = self.usage.borrow_mut();
359        usage.canonical_bytes = usage
360            .canonical_bytes
361            .checked_add(bytes)
362            .ok_or_else(|| limit("canonical bytes", self.limits.max_canonical_bytes))?;
363        if usage.canonical_bytes > self.limits.max_canonical_bytes {
364            return Err(limit("canonical bytes", self.limits.max_canonical_bytes));
365        }
366        Ok(())
367    }
368
369    fn charge_reference(&self, reference: (u32, u16)) -> Result<()> {
370        let mut usage = self.usage.borrow_mut();
371        usage.indirect_objects.insert(reference);
372        if usage.indirect_objects.len() > self.limits.max_objects {
373            return Err(limit("indirect objects", self.limits.max_objects));
374        }
375        Ok(())
376    }
377
378    fn ensure_local_output(&self, current: usize, additional: usize) -> Result<()> {
379        let local = current
380            .checked_add(additional)
381            .ok_or_else(|| limit("canonical bytes", self.limits.max_canonical_bytes))?;
382        let committed = self.usage.borrow().canonical_bytes;
383        if committed
384            .checked_add(local)
385            .is_none_or(|total| total > self.limits.max_canonical_bytes)
386        {
387            return Err(limit("canonical bytes", self.limits.max_canonical_bytes));
388        }
389        Ok(())
390    }
391}
392
393struct SemanticSnapshot {
394    catalog: Vec<u8>,
395    object_fingerprints: Vec<((u32, u16), [u8; 32])>,
396    domains: BTreeMap<String, Vec<u8>>,
397    text: Vec<String>,
398    signatures: Vec<Vec<u8>>,
399    unreachable_semantics: Vec<Vec<u8>>,
400    metadata: Option<Vec<u8>>,
401    revisions: Vec<PdfRevisionSummary>,
402    unreachable_objects: Vec<(u32, u16)>,
403}
404
405impl SemanticSnapshot {
406    fn build(bytes: &[u8], options: &SemanticComparisonOptions) -> Result<Self> {
407        let budget = ComparisonBudget::new(&options.limits);
408        let text_reader = PdfReader::new(Cursor::new(bytes))
409            .map_err(|error| invalid(format!("parse PDF for text extraction: {error}")))?;
410        if text_reader.is_encrypted() {
411            return Err(PdfError::PermissionDenied(
412                "semantic comparison requires decrypted PDF input".to_string(),
413            ));
414        }
415        let text_document = PdfDocument::new(text_reader);
416        let page_count = text_document
417            .page_count()
418            .map_err(|error| invalid(format!("count pages: {error}")))?;
419        let pages: Vec<_> = (0..page_count)
420            .map(|index| {
421                text_document
422                    .get_page(index)
423                    .map_err(|error| invalid(format!("read page {index}: {error}")))
424            })
425            .collect::<Result<_>>()?;
426        let extracted = text_document
427            .extract_text_with_options(crate::text::ExtractionOptions {
428                max_extracted_bytes: Some(options.limits.max_extracted_text_bytes),
429                ..crate::text::ExtractionOptions::default()
430            })
431            .map_err(|error| invalid(format!("extract logical text: {error}")))?;
432        let mut extracted_text_bytes = 0usize;
433        let mut text = Vec::with_capacity(extracted.len());
434        for page in extracted {
435            if page.truncated {
436                return Err(limit(
437                    "extracted text bytes",
438                    options.limits.max_extracted_text_bytes,
439                ));
440            }
441            extracted_text_bytes = extracted_text_bytes
442                .checked_add(page.text.len())
443                .ok_or_else(|| {
444                    limit(
445                        "extracted text bytes",
446                        options.limits.max_extracted_text_bytes,
447                    )
448                })?;
449            if extracted_text_bytes > options.limits.max_extracted_text_bytes {
450                return Err(limit(
451                    "extracted text bytes",
452                    options.limits.max_extracted_text_bytes,
453                ));
454            }
455            text.push(page.text);
456        }
457        let mut reader = PdfReader::new(Cursor::new(bytes))
458            .map_err(|error| invalid(format!("parse PDF: {error}")))?;
459        if reader.is_encrypted() {
460            return Err(PdfError::PermissionDenied(
461                "semantic comparison requires decrypted PDF input".to_string(),
462            ));
463        }
464        let mut signatures: Vec<_> = reader
465            .signatures()
466            .map_err(|error| invalid(format!("read signatures: {error}")))?
467            .into_iter()
468            .map(|signature| {
469                let mut value = format!(
470                    "{:?}|{}|{}|{:?}|{:?}|{:?}|{:?}|{:?}",
471                    signature.name,
472                    signature.byte_range,
473                    crate::signatures::has_incremental_update(bytes, &signature.byte_range),
474                    signature.filter,
475                    signature.sub_filter,
476                    signature.reason,
477                    signature.location,
478                    signature.signing_time,
479                )
480                .into_bytes();
481                value.extend_from_slice(&signature.contents);
482                value
483            })
484            .collect();
485        signatures.sort();
486        let mut revisions = summarize_revisions(&reader);
487        if revisions.len() > options.limits.max_revisions {
488            return Err(limit("physical revisions", options.limits.max_revisions));
489        }
490        attach_revision_fingerprints(bytes, &mut revisions, &budget)?;
491        let root = reader
492            .trailer()
493            .root()
494            .map_err(|error| invalid(format!("read trailer root: {error}")))?;
495        let catalog_dictionary = reader
496            .catalog()
497            .map_err(|error| invalid(format!("read catalog: {error}")))?
498            .clone();
499        let mut domains = BTreeMap::new();
500        for key in [
501            "OCProperties",
502            "Metadata",
503            "AcroForm",
504            "Perms",
505            "DSS",
506            "Names",
507            "Outlines",
508            "StructTreeRoot",
509            "MarkInfo",
510        ] {
511            if let Some(value) = catalog_dictionary.get(key) {
512                let (canonical, _, _) = Canonicalizer::new(&mut reader, &options.limits, &budget)
513                    .canonicalize(value, &[])?;
514                domains.insert(format!("/Catalog/{key}"), canonical);
515            }
516        }
517        let pages_root = catalog_dictionary
518            .get("Pages")
519            .ok_or_else(|| invalid("catalog is missing /Pages"))?;
520        let (page_tree, _, _) = Canonicalizer::new(&mut reader, &options.limits, &budget)
521            .canonicalize(pages_root, &["Kids"])?;
522        domains.insert("/Pages/Tree".to_string(), page_tree);
523        for (index, parsed_page) in pages.iter().enumerate() {
524            let (number, generation) = parsed_page.obj_ref;
525            let page = reader
526                .get_object(number, generation)
527                .map_err(|error| invalid(format!("resolve page {index}: {error}")))?
528                .clone();
529            let mut appearance_page = page
530                .as_dict()
531                .cloned()
532                .ok_or_else(|| invalid(format!("page {index} is not a dictionary")))?;
533            if !appearance_page.contains_key("Resources") {
534                if let Some(resources) = &parsed_page.inherited_resources {
535                    appearance_page.insert(
536                        "Resources".to_string(),
537                        PdfObject::Dictionary(resources.clone()),
538                    );
539                }
540            }
541            if !appearance_page.contains_key("MediaBox") {
542                appearance_page.insert(
543                    "MediaBox".to_string(),
544                    PdfObject::Array(PdfArray(
545                        parsed_page
546                            .media_box
547                            .iter()
548                            .copied()
549                            .map(PdfObject::Real)
550                            .collect(),
551                    )),
552                );
553            }
554            if !appearance_page.contains_key("CropBox") {
555                if let Some(crop_box) = parsed_page.crop_box {
556                    appearance_page.insert(
557                        "CropBox".to_string(),
558                        PdfObject::Array(PdfArray(
559                            crop_box.iter().copied().map(PdfObject::Real).collect(),
560                        )),
561                    );
562                }
563            }
564            if !appearance_page.contains_key("Rotate") && parsed_page.rotation != 0 {
565                appearance_page.insert(
566                    "Rotate".to_string(),
567                    PdfObject::Integer(i64::from(parsed_page.rotation)),
568                );
569            }
570            let (appearance, _, _) = Canonicalizer::new(&mut reader, &options.limits, &budget)
571                .canonicalize(
572                    &PdfObject::Dictionary(appearance_page),
573                    &["Parent", "Annots"],
574                )?;
575            domains.insert(format!("/Pages/{index}/Appearance"), appearance);
576            if let Some(annotations) = page.as_dict().and_then(|page| page.get("Annots")) {
577                let (annotations, _, _) = Canonicalizer::new(&mut reader, &options.limits, &budget)
578                    .canonicalize(annotations, &["P"])?;
579                budget.charge_canonical(annotations.len())?;
580                domains.insert(
581                    format!("/Pages/{index}/AnnotationAppearance"),
582                    annotations.clone(),
583                );
584                domains.insert(format!("/Pages/{index}/Annotations"), annotations);
585            }
586        }
587        let domain_keys: HashSet<_> = [
588            "Pages",
589            "OCProperties",
590            "Metadata",
591            "AcroForm",
592            "Perms",
593            "DSS",
594            "Names",
595            "Outlines",
596            "StructTreeRoot",
597            "MarkInfo",
598        ]
599        .into_iter()
600        .collect();
601        let residual_catalog = PdfDictionary(
602            catalog_dictionary
603                .0
604                .iter()
605                .filter(|(key, _)| !domain_keys.contains(key.0.as_str()))
606                .map(|(key, value)| (key.clone(), value.clone()))
607                .collect(),
608        );
609        let (residual, _, _) = Canonicalizer::new(&mut reader, &options.limits, &budget)
610            .canonicalize(&PdfObject::Dictionary(residual_catalog), &[])?;
611        domains.insert("/Catalog".to_string(), residual);
612        let (catalog, mut reachable, _) = Canonicalizer::new(&mut reader, &options.limits, &budget)
613            .canonicalize(&PdfObject::Reference(root.0, root.1), &[])?;
614        let metadata = reader.trailer().info().map(|reference| {
615            Canonicalizer::new(&mut reader, &options.limits, &budget).canonicalize(
616                &PdfObject::Reference(reference.0, reference.1),
617                &[
618                    "CreationDate",
619                    "ModDate",
620                    "oxidize-pdf-build",
621                    "oxidize-pdf-features",
622                ],
623            )
624        });
625        let metadata = metadata.transpose()?;
626        if let Some((_, metadata_references, _)) = &metadata {
627            reachable.extend(metadata_references);
628        }
629        let object_references = reader.object_references();
630        let mut unreachable_objects: Vec<_> = object_references
631            .iter()
632            .copied()
633            .into_iter()
634            .filter(|reference| !reachable.contains(reference))
635            .collect();
636        unreachable_objects.sort_unstable();
637        if unreachable_objects.len() > options.limits.max_unreachable_objects {
638            return Err(limit(
639                "unreachable objects",
640                options.limits.max_unreachable_objects,
641            ));
642        }
643        let unreachable_set: HashSet<_> = unreachable_objects.iter().copied().collect();
644        let mut unreachable_semantics = Vec::with_capacity(unreachable_objects.len());
645        let mut object_fingerprints = Vec::with_capacity(object_references.len());
646        for (number, generation) in object_references {
647            let (canonical, _, _) = Canonicalizer::new(&mut reader, &options.limits, &budget)
648                .canonicalize(&PdfObject::Reference(number, generation), &[])?;
649            let fingerprint: [u8; 32] = Sha256::digest(&canonical).into();
650            object_fingerprints.push(((number, generation), fingerprint));
651            if unreachable_set.contains(&(number, generation)) {
652                unreachable_semantics.push(canonical);
653            }
654        }
655        unreachable_semantics.sort();
656        Ok(Self {
657            catalog,
658            object_fingerprints,
659            domains,
660            text,
661            signatures,
662            unreachable_semantics,
663            metadata: metadata.map(|(bytes, _, _)| bytes),
664            revisions,
665            unreachable_objects,
666        })
667    }
668}
669
670fn summarize_revisions<R: std::io::Read + std::io::Seek>(
671    reader: &PdfReader<R>,
672) -> Vec<PdfRevisionSummary> {
673    let mut known = HashSet::new();
674    reader
675        .xref_revisions()
676        .into_iter()
677        .enumerate()
678        .map(|(index, revision)| {
679            let mut object_changes = Vec::new();
680            for entry in revision.entries {
681                if entry.object_number == 0 {
682                    continue;
683                }
684                let kind = if entry.in_use {
685                    if known.insert(entry.object_number) {
686                        RevisionObjectChangeKind::Added
687                    } else {
688                        RevisionObjectChangeKind::Replaced
689                    }
690                } else if known.remove(&entry.object_number) {
691                    RevisionObjectChangeKind::Freed
692                } else {
693                    continue;
694                };
695                object_changes.push(RevisionObjectChange {
696                    object_number: entry.object_number,
697                    generation: entry.generation,
698                    kind,
699                });
700            }
701            PdfRevisionSummary {
702                index,
703                xref_offset: revision.xref_offset,
704                object_changes,
705                semantic_fingerprint: [0; 32],
706            }
707        })
708        .collect()
709}
710
711fn attach_revision_fingerprints(
712    bytes: &[u8],
713    revisions: &mut [PdfRevisionSummary],
714    budget: &ComparisonBudget,
715) -> Result<()> {
716    for revision in revisions {
717        let xref_offset = usize::try_from(revision.xref_offset)
718            .ok()
719            .and_then(|offset| bytes.get(offset..).map(|tail| (offset, tail)))
720            .ok_or_else(|| invalid("revision xref offset exceeds input size"))?;
721        let eof_relative = find_bytes(xref_offset.1, b"%%EOF")
722            .ok_or_else(|| invalid("incremental revision is missing %%EOF"))?;
723        let revision_end = xref_offset
724            .0
725            .checked_add(eof_relative)
726            .and_then(|offset| offset.checked_add(b"%%EOF".len()))
727            .ok_or_else(|| invalid("incremental revision boundary overflows input size"))?;
728        let prefix = bytes
729            .get(..revision_end)
730            .ok_or_else(|| invalid("incremental revision boundary exceeds input size"))?;
731        let mut reader = PdfReader::new(Cursor::new(prefix))
732            .map_err(|error| invalid(format!("parse incremental revision: {error}")))?;
733        let root = reader
734            .trailer()
735            .root()
736            .map_err(|error| invalid(format!("read incremental revision root: {error}")))?;
737        let (catalog, mut reachable, _) =
738            Canonicalizer::new(&mut reader, &budget.limits, budget)
739                .canonicalize(&PdfObject::Reference(root.0, root.1), &[])?;
740        let metadata = reader.trailer().info().map(|reference| {
741            Canonicalizer::new(&mut reader, &budget.limits, budget).canonicalize(
742                &PdfObject::Reference(reference.0, reference.1),
743                &[
744                    "CreationDate",
745                    "ModDate",
746                    "oxidize-pdf-build",
747                    "oxidize-pdf-features",
748                ],
749            )
750        });
751        let metadata = metadata.transpose()?;
752        let mut digest = Sha256::new();
753        digest.update((catalog.len() as u64).to_be_bytes());
754        digest.update(catalog);
755        if let Some((metadata, metadata_references, _)) = metadata {
756            reachable.extend(metadata_references);
757            digest.update((metadata.len() as u64).to_be_bytes());
758            digest.update(metadata);
759        }
760        let mut unreachable = Vec::new();
761        for reference in reader
762            .object_references()
763            .into_iter()
764            .filter(|reference| !reachable.contains(reference))
765        {
766            let (canonical, _, _) = Canonicalizer::new(&mut reader, &budget.limits, budget)
767                .canonicalize(&PdfObject::Reference(reference.0, reference.1), &[])?;
768            unreachable.push(canonical);
769        }
770        if unreachable.len() > budget.limits.max_unreachable_objects {
771            return Err(limit(
772                "unreachable objects",
773                budget.limits.max_unreachable_objects,
774            ));
775        }
776        unreachable.sort();
777        for canonical in unreachable {
778            digest.update((canonical.len() as u64).to_be_bytes());
779            digest.update(canonical);
780        }
781        revision.semantic_fingerprint = digest.finalize().into();
782    }
783    Ok(())
784}
785
786struct Canonicalizer<'a, R: std::io::Read + std::io::Seek> {
787    reader: &'a mut PdfReader<R>,
788    limits: &'a SemanticComparisonLimits,
789    budget: ComparisonBudget,
790    references: HashMap<(u32, u16), usize>,
791}
792
793impl<'a, R: std::io::Read + std::io::Seek> Canonicalizer<'a, R> {
794    fn new(
795        reader: &'a mut PdfReader<R>,
796        limits: &'a SemanticComparisonLimits,
797        budget: &ComparisonBudget,
798    ) -> Self {
799        Self {
800            reader,
801            limits,
802            budget: budget.clone(),
803            references: HashMap::new(),
804        }
805    }
806
807    fn canonicalize(
808        mut self,
809        root: &PdfObject,
810        ignored_keys: &[&str],
811    ) -> Result<(Vec<u8>, HashSet<(u32, u16)>, Vec<(u32, u16)>)> {
812        let mut output = Vec::new();
813        self.write_object(root, 0, ignored_keys, &mut output)?;
814        self.budget.charge_canonical(output.len())?;
815        let mut ordered_references: Vec<_> = self
816            .references
817            .iter()
818            .map(|(reference, identifier)| (*identifier, *reference))
819            .collect();
820        ordered_references.sort_unstable_by_key(|(identifier, _)| *identifier);
821        Ok((
822            output,
823            self.references.keys().copied().collect(),
824            ordered_references
825                .into_iter()
826                .map(|(_, reference)| reference)
827                .collect(),
828        ))
829    }
830
831    fn write_object(
832        &mut self,
833        object: &PdfObject,
834        depth: usize,
835        ignored_keys: &[&str],
836        output: &mut Vec<u8>,
837    ) -> Result<()> {
838        if depth > self.limits.max_depth {
839            return Err(limit("object nesting depth", self.limits.max_depth));
840        }
841        match object {
842            PdfObject::Null => output.extend_from_slice(b"null"),
843            PdfObject::Boolean(value) => {
844                output.extend_from_slice(if *value { b"true" } else { b"false" })
845            }
846            PdfObject::Integer(value) => output.extend_from_slice(format!("i{value};").as_bytes()),
847            PdfObject::Real(value) => {
848                let normalized = if *value == 0.0 { 0.0 } else { *value };
849                output.extend_from_slice(format!("r{:016x};", normalized.to_bits()).as_bytes());
850            }
851            PdfObject::String(value) => self.write_bytes(b's', &value.0, output)?,
852            PdfObject::Name(value) => self.write_bytes(b'n', value.0.as_bytes(), output)?,
853            PdfObject::Array(array) => {
854                output.push(b'[');
855                for value in &array.0 {
856                    self.write_object(value, depth + 1, ignored_keys, output)?;
857                }
858                output.push(b']');
859            }
860            PdfObject::Dictionary(dictionary) => {
861                self.write_dictionary(dictionary, depth, ignored_keys, output)?;
862            }
863            PdfObject::Stream(stream) => {
864                output.extend_from_slice(b"stream");
865                self.write_dictionary_filtered(
866                    &stream.dict,
867                    depth,
868                    ignored_keys,
869                    &["Length", "Filter", "DecodeParms"],
870                    output,
871                )?;
872                let decoded = stream
873                    .decode(&ParseOptions::default())
874                    .map_err(|error| invalid(format!("decode stream: {error}")))?;
875                self.budget.charge_decoded(decoded.len())?;
876                let normalized = if stream.dict.get_type() == Some("Metadata")
877                    && stream
878                        .dict
879                        .get("Subtype")
880                        .and_then(PdfObject::as_name)
881                        .is_some_and(|name| name.0 == "XML")
882                {
883                    normalize_xmp_noise(decoded)
884                } else {
885                    decoded
886                };
887                self.write_bytes(b'd', &normalized, output)?;
888            }
889            PdfObject::Reference(number, generation) => {
890                let reference = (*number, *generation);
891                if let Some(identifier) = self.references.get(&reference) {
892                    output.extend_from_slice(format!("@{identifier};").as_bytes());
893                    return Ok(());
894                }
895                if self.references.len() >= self.limits.max_objects {
896                    return Err(limit("reachable indirect objects", self.limits.max_objects));
897                }
898                self.budget.charge_reference(reference)?;
899                let identifier = self.references.len();
900                self.references.insert(reference, identifier);
901                output.extend_from_slice(format!("&{identifier}=").as_bytes());
902                let resolved = self
903                    .reader
904                    .get_object(*number, *generation)
905                    .map_err(|error| invalid(format!("resolve indirect object: {error}")))?
906                    .clone();
907                self.write_object(&resolved, depth + 1, ignored_keys, output)?;
908            }
909        }
910        Ok(())
911    }
912
913    fn write_dictionary(
914        &mut self,
915        dictionary: &PdfDictionary,
916        depth: usize,
917        ignored_keys: &[&str],
918        output: &mut Vec<u8>,
919    ) -> Result<()> {
920        self.write_dictionary_filtered(dictionary, depth, ignored_keys, &[], output)
921    }
922
923    fn write_dictionary_filtered(
924        &mut self,
925        dictionary: &PdfDictionary,
926        depth: usize,
927        ignored_keys: &[&str],
928        additional_ignored: &[&str],
929        output: &mut Vec<u8>,
930    ) -> Result<()> {
931        output.push(b'<');
932        let mut entries: Vec<_> = dictionary
933            .0
934            .iter()
935            .filter(|(key, _)| {
936                !ignored_keys.contains(&key.0.as_str())
937                    && !additional_ignored.contains(&key.0.as_str())
938            })
939            .collect();
940        entries.sort_by(|left, right| left.0 .0.cmp(&right.0 .0));
941        for (key, value) in entries {
942            self.write_bytes(b'k', key.0.as_bytes(), output)?;
943            self.write_object(value, depth + 1, ignored_keys, output)?;
944        }
945        output.push(b'>');
946        Ok(())
947    }
948
949    fn write_bytes(&self, prefix: u8, bytes: &[u8], output: &mut Vec<u8>) -> Result<()> {
950        let length_digits = bytes.len().max(1).ilog10() as usize + 1;
951        self.budget.ensure_local_output(
952            output.len(),
953            bytes.len().saturating_add(length_digits).saturating_add(3),
954        )?;
955        output.push(prefix);
956        output.extend_from_slice(format!("{}:", bytes.len()).as_bytes());
957        output.extend_from_slice(bytes);
958        output.push(b';');
959        Ok(())
960    }
961}
962
963fn normalize_xmp_noise(bytes: Vec<u8>) -> Vec<u8> {
964    let mut reader = XmlReader::from_reader(bytes.as_slice());
965    reader.config_mut().trim_text(false);
966    let mut writer = XmlWriter::new(Vec::with_capacity(bytes.len()));
967    let mut buffer = Vec::new();
968    let mut date_elements = Vec::new();
969    let mut date_text_written = Vec::new();
970
971    loop {
972        let event = match reader.read_event_into(&mut buffer) {
973            Ok(Event::Eof) => break,
974            Ok(event) => event,
975            Err(_) => return bytes,
976        };
977        let write_result = match event {
978            Event::Start(start) => {
979                let is_date = is_xmp_date_name(start.local_name().as_ref());
980                date_elements.push(is_date);
981                date_text_written.push(false);
982                writer.write_event(Event::Start(normalize_xmp_attributes(&start)))
983            }
984            Event::Empty(start) => {
985                writer.write_event(Event::Empty(normalize_xmp_attributes(&start)))
986            }
987            Event::Text(_) if date_elements.last() == Some(&true) => {
988                if date_text_written.last() == Some(&false) {
989                    if let Some(written) = date_text_written.last_mut() {
990                        *written = true;
991                    }
992                    writer.write_event(Event::Text(BytesText::new("normalized")))
993                } else {
994                    Ok(())
995                }
996            }
997            Event::CData(_) if date_elements.last() == Some(&true) => {
998                if date_text_written.last() == Some(&false) {
999                    if let Some(written) = date_text_written.last_mut() {
1000                        *written = true;
1001                    }
1002                    writer.write_event(Event::Text(BytesText::new("normalized")))
1003                } else {
1004                    Ok(())
1005                }
1006            }
1007            Event::End(end) => {
1008                date_elements.pop();
1009                date_text_written.pop();
1010                writer.write_event(Event::End(end.into_owned()))
1011            }
1012            event => writer.write_event(event.into_owned()),
1013        };
1014        if write_result.is_err() {
1015            return bytes;
1016        }
1017        buffer.clear();
1018    }
1019    writer.into_inner()
1020}
1021
1022fn normalize_xmp_attributes(start: &BytesStart<'_>) -> BytesStart<'static> {
1023    let name = String::from_utf8_lossy(start.name().as_ref()).into_owned();
1024    let mut normalized = BytesStart::new(name);
1025    for attribute in start.attributes().with_checks(false).flatten() {
1026        let key = String::from_utf8_lossy(attribute.key.as_ref()).into_owned();
1027        let value = if is_xmp_date_name(attribute.key.local_name().as_ref()) {
1028            "normalized".to_string()
1029        } else {
1030            String::from_utf8_lossy(attribute.value.as_ref()).into_owned()
1031        };
1032        normalized.push_attribute((key.as_str(), value.as_str()));
1033    }
1034    normalized.into_owned()
1035}
1036
1037fn is_xmp_date_name(name: &[u8]) -> bool {
1038    matches!(name, b"CreateDate" | b"ModifyDate" | b"MetadataDate")
1039}
1040
1041fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1042    haystack
1043        .windows(needle.len())
1044        .position(|window| window == needle)
1045}
1046
1047fn limit(resource: &str, maximum: usize) -> PdfError {
1048    invalid(format!(
1049        "semantic comparison {resource} limit exceeded ({maximum})"
1050    ))
1051}
1052
1053fn invalid(message: impl Into<String>) -> PdfError {
1054    PdfError::InvalidStructure(message.into())
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060    use crate::writer::{IncrementalOcrLayerEditor, OcrLayerFragment, OcrLayerPage};
1061    use crate::{Document, Font, Page};
1062
1063    fn document(compress: bool) -> Vec<u8> {
1064        let mut document = Document::new();
1065        document.set_compress(compress);
1066        let mut page = Page::a4();
1067        page.text()
1068            .set_font(Font::Helvetica, 12.0)
1069            .at(20.0, 800.0)
1070            .write("semantic content")
1071            .unwrap();
1072        document.add_page(page);
1073        document.to_bytes().unwrap()
1074    }
1075
1076    fn numbered_pdf(numbers: [u32; 4], definition_order: [usize; 4]) -> Vec<u8> {
1077        let [catalog, pages, page, contents] = numbers;
1078        let bodies = [
1079            format!("<< /Type /Catalog /Pages {pages} 0 R >>"),
1080            format!("<< /Type /Pages /Kids [{page} 0 R] /Count 1 >>"),
1081            format!("<< /Type /Page /Parent {pages} 0 R /MediaBox [0 0 300 300] /Contents {contents} 0 R >>"),
1082            "<< /Length 16 >>\nstream\nBT (same) Tj ET\nendstream".to_string(),
1083        ];
1084        let mut output = b"%PDF-1.7\n".to_vec();
1085        let size = numbers.iter().copied().max().unwrap() + 1;
1086        let mut offsets = vec![None; size as usize];
1087        for index in definition_order {
1088            let number = numbers[index];
1089            offsets[number as usize] = Some(output.len());
1090            output.extend_from_slice(
1091                format!("{number} 0 obj\n{}\nendobj\n", bodies[index]).as_bytes(),
1092            );
1093        }
1094        let xref = output.len();
1095        output.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
1096        for offset in offsets.into_iter().skip(1) {
1097            match offset {
1098                Some(offset) => {
1099                    output.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes())
1100                }
1101                None => output.extend_from_slice(b"0000000000 00000 f \n"),
1102            }
1103        }
1104        output.extend_from_slice(
1105            format!("trailer\n<< /Size {size} /Root {catalog} 0 R >>\nstartxref\n{xref}\n%%EOF\n")
1106                .as_bytes(),
1107        );
1108        output
1109    }
1110
1111    fn raw_pdf(objects: &[(u32, &str)], root: u32, trailer_extra: &str) -> Vec<u8> {
1112        let mut output = b"%PDF-1.7\n".to_vec();
1113        let size = objects.iter().map(|(number, _)| *number).max().unwrap() + 1;
1114        let mut offsets = vec![None; size as usize];
1115        for (number, body) in objects {
1116            offsets[*number as usize] = Some(output.len());
1117            output.extend_from_slice(format!("{number} 0 obj\n{body}\nendobj\n").as_bytes());
1118        }
1119        let xref = output.len();
1120        output.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
1121        for offset in offsets.into_iter().skip(1) {
1122            if let Some(offset) = offset {
1123                output.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
1124            } else {
1125                output.extend_from_slice(b"0000000000 00000 f \n");
1126            }
1127        }
1128        output.extend_from_slice(
1129            format!(
1130                "trailer\n<< /Size {size} /Root {root} 0 R {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n"
1131            )
1132            .as_bytes(),
1133        );
1134        output
1135    }
1136
1137    fn minimal_objects(catalog_suffix: &str, page_suffix: &str) -> Vec<(u32, String)> {
1138        vec![
1139            (
1140                1,
1141                format!("<< /Type /Catalog /Pages 2 0 R {catalog_suffix} >>"),
1142            ),
1143            (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string()),
1144            (
1145                3,
1146                format!(
1147                    "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R {page_suffix} >>"
1148                ),
1149            ),
1150            (
1151                4,
1152                "<< /Length 16 >>\nstream\nBT (base) Tj ET\nendstream".to_string(),
1153            ),
1154        ]
1155    }
1156
1157    fn append_replacement(mut base: Vec<u8>, object_number: u32, body: &str) -> Vec<u8> {
1158        let marker = b"startxref\n";
1159        let start = base
1160            .windows(marker.len())
1161            .rposition(|window| window == marker)
1162            .unwrap()
1163            + marker.len();
1164        let end = base[start..]
1165            .iter()
1166            .position(|byte| *byte == b'\n')
1167            .unwrap()
1168            + start;
1169        let previous_xref = std::str::from_utf8(&base[start..end])
1170            .unwrap()
1171            .parse::<usize>()
1172            .unwrap();
1173        let object_offset = base.len();
1174        base.extend_from_slice(format!("{object_number} 0 obj\n{body}\nendobj\n").as_bytes());
1175        let xref = base.len();
1176        base.extend_from_slice(
1177            format!(
1178                "xref\n{object_number} 1\n{object_offset:010} 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R /Prev {previous_xref} >>\nstartxref\n{xref}\n%%EOF\n"
1179            )
1180            .as_bytes(),
1181        );
1182        base
1183    }
1184
1185    fn append_free(mut base: Vec<u8>, object_number: u32, generation: u16) -> Vec<u8> {
1186        let marker = b"startxref\n";
1187        let start = base
1188            .windows(marker.len())
1189            .rposition(|window| window == marker)
1190            .unwrap()
1191            + marker.len();
1192        let end = base[start..]
1193            .iter()
1194            .position(|byte| *byte == b'\n')
1195            .unwrap()
1196            + start;
1197        let previous_xref = std::str::from_utf8(&base[start..end])
1198            .unwrap()
1199            .parse::<usize>()
1200            .unwrap();
1201        let xref = base.len();
1202        base.extend_from_slice(
1203            format!(
1204                "xref\n{object_number} 1\n0000000000 {generation:05} f \ntrailer\n<< /Size 6 /Root 1 0 R /Prev {previous_xref} >>\nstartxref\n{xref}\n%%EOF\n"
1205            )
1206            .as_bytes(),
1207        );
1208        base
1209    }
1210
1211    #[test]
1212    fn stream_compression_is_serialization_only() {
1213        let left_bytes = document(false);
1214        let right_bytes = document(true);
1215        let result = compare_pdfs_semantically(
1216            &left_bytes,
1217            &right_bytes,
1218            &SemanticComparisonOptions::default(),
1219        )
1220        .unwrap();
1221        assert!(result.semantically_equal, "{:?}", result.differences);
1222        assert!(result
1223            .differences
1224            .iter()
1225            .any(|difference| { difference.class == SemanticDifferenceClass::SerializationOnly }));
1226    }
1227
1228    #[test]
1229    fn object_numbers_and_definition_order_are_serialization_only() {
1230        let left = numbered_pdf([1, 2, 3, 4], [0, 1, 2, 3]);
1231        let right = numbered_pdf([11, 7, 19, 5], [3, 1, 0, 2]);
1232        let result =
1233            compare_pdfs_semantically(&left, &right, &SemanticComparisonOptions::default())
1234                .unwrap();
1235        assert!(result.semantically_equal, "{:?}", result.differences);
1236    }
1237
1238    #[test]
1239    fn visible_content_change_is_visual_and_textual() {
1240        let left = document(false);
1241        let mut right = left.clone();
1242        let position = right
1243            .windows(b"semantic content".len())
1244            .position(|window| window == b"semantic content")
1245            .unwrap();
1246        right[position..position + b"semantic content".len()].copy_from_slice(b"different text!!");
1247        let result =
1248            compare_pdfs_semantically(&left, &right, &SemanticComparisonOptions::default())
1249                .unwrap();
1250        assert!(!result.semantically_equal);
1251        assert!(result.differences.iter().any(|difference| {
1252            difference.path == "/Pages/0/Appearance"
1253                && difference.class == SemanticDifferenceClass::Visual
1254        }));
1255        assert!(result.differences.iter().any(|difference| {
1256            difference.path == "/Pages/Text" && difference.class == SemanticDifferenceClass::Textual
1257        }));
1258    }
1259
1260    #[test]
1261    fn content_outside_page_bounds_is_still_reported() {
1262        let left_content = "BT 10000 10000 Td (hidden-a) Tj ET\n";
1263        let right_content = "BT 10000 10000 Td (hidden-b) Tj ET\n";
1264        let mut left_objects = minimal_objects("", "");
1265        left_objects[3].1 = format!(
1266            "<< /Length {} >>\nstream\n{left_content}endstream",
1267            left_content.len()
1268        );
1269        let mut right_objects = minimal_objects("", "");
1270        right_objects[3].1 = format!(
1271            "<< /Length {} >>\nstream\n{right_content}endstream",
1272            right_content.len()
1273        );
1274        let left_refs: Vec<_> = left_objects
1275            .iter()
1276            .map(|(number, body)| (*number, body.as_str()))
1277            .collect();
1278        let right_refs: Vec<_> = right_objects
1279            .iter()
1280            .map(|(number, body)| (*number, body.as_str()))
1281            .collect();
1282        let result = compare_pdfs_semantically(
1283            &raw_pdf(&left_refs, 1, ""),
1284            &raw_pdf(&right_refs, 1, ""),
1285            &SemanticComparisonOptions::default(),
1286        )
1287        .unwrap();
1288        assert!(result.differences.iter().any(|difference| {
1289            difference.path == "/Pages/0/Appearance"
1290                && difference.class == SemanticDifferenceClass::Visual
1291        }));
1292    }
1293
1294    #[test]
1295    fn reachable_object_budget_fails_closed() {
1296        let options = SemanticComparisonOptions {
1297            limits: SemanticComparisonLimits {
1298                max_objects: 1,
1299                ..SemanticComparisonLimits::default()
1300            },
1301        };
1302        let pdf = document(false);
1303        let error = compare_pdfs_semantically(&pdf, &pdf, &options).unwrap_err();
1304        assert!(
1305            matches!(error, PdfError::InvalidStructure(message) if message.contains("limit exceeded"))
1306        );
1307    }
1308
1309    #[test]
1310    fn incremental_changes_are_attributed_to_their_revision() {
1311        let base = document(false);
1312        let updated = IncrementalOcrLayerEditor::new(&base)
1313            .apply(&[OcrLayerPage {
1314                page_index: 0,
1315                language: "en".to_string(),
1316                fragments: vec![OcrLayerFragment {
1317                    text: "revision".to_string(),
1318                    region: [20.0, 700.0, 60.0, 12.0],
1319                    confidence: 0.9,
1320                    reading_order: 0,
1321                }],
1322            }])
1323            .unwrap();
1324        let result = compare_pdfs_semantically(
1325            &base,
1326            &updated.pdf_bytes,
1327            &SemanticComparisonOptions::default(),
1328        )
1329        .unwrap();
1330        assert_eq!(result.left_revisions.len(), 1);
1331        assert_eq!(result.right_revisions.len(), 2);
1332        let latest = &result.right_revisions[1];
1333        assert!(latest
1334            .object_changes
1335            .iter()
1336            .any(|change| change.kind == RevisionObjectChangeKind::Replaced));
1337        assert!(latest
1338            .object_changes
1339            .iter()
1340            .any(|change| change.kind == RevisionObjectChangeKind::Added));
1341
1342        let mut objects = minimal_objects("", "");
1343        objects.push((5, "(temporary)".to_string()));
1344        let refs: Vec<_> = objects
1345            .iter()
1346            .map(|(number, body)| (*number, body.as_str()))
1347            .collect();
1348        let freed = append_free(raw_pdf(&refs, 1, ""), 5, 1);
1349        let freed_result =
1350            compare_pdfs_semantically(&freed, &freed, &SemanticComparisonOptions::default())
1351                .unwrap();
1352        assert!(freed_result.right_revisions[1]
1353            .object_changes
1354            .iter()
1355            .any(|change| change.kind == RevisionObjectChangeKind::Freed));
1356    }
1357
1358    #[test]
1359    fn semantically_redundant_incremental_revision_is_serialization_only() {
1360        let objects = minimal_objects("", "");
1361        let refs: Vec<_> = objects
1362            .iter()
1363            .map(|(number, body)| (*number, body.as_str()))
1364            .collect();
1365        let base = raw_pdf(&refs, 1, "");
1366        let updated = append_replacement(base.clone(), 4, &objects[3].1);
1367
1368        let result =
1369            compare_pdfs_semantically(&base, &updated, &SemanticComparisonOptions::default())
1370                .unwrap();
1371
1372        assert!(result.semantically_equal, "{:?}", result.differences);
1373        assert_eq!(result.right_revisions.len(), 2);
1374        assert!(result
1375            .differences
1376            .iter()
1377            .all(|difference| difference.class == SemanticDifferenceClass::SerializationOnly));
1378    }
1379
1380    #[test]
1381    fn signatures_and_post_signing_updates_are_security_differences() {
1382        let signed = include_bytes!("../../tests/fixtures/signatures/signed_rsa.pdf");
1383        let incrementally_updated =
1384            include_bytes!("../../tests/fixtures/signatures/signed_rsa_incremental.pdf");
1385        let result = compare_pdfs_semantically(
1386            signed,
1387            incrementally_updated,
1388            &SemanticComparisonOptions::default(),
1389        )
1390        .unwrap();
1391
1392        assert!(result.differences.iter().any(|difference| {
1393            difference.path == "/Signatures"
1394                && difference.class == SemanticDifferenceClass::Security
1395        }));
1396    }
1397
1398    #[test]
1399    fn optional_content_annotations_metadata_and_attachments_have_stable_paths() {
1400        let base_objects = minimal_objects("", "");
1401        let base_refs: Vec<_> = base_objects
1402            .iter()
1403            .map(|(number, body)| (*number, body.as_str()))
1404            .collect();
1405        let base = raw_pdf(&base_refs, 1, "");
1406
1407        let optional_objects = minimal_objects("/OCProperties << /D << /Name (Layer) >> >>", "");
1408        let optional_refs: Vec<_> = optional_objects
1409            .iter()
1410            .map(|(number, body)| (*number, body.as_str()))
1411            .collect();
1412        let optional = raw_pdf(&optional_refs, 1, "");
1413        let optional_result =
1414            compare_pdfs_semantically(&base, &optional, &SemanticComparisonOptions::default())
1415                .unwrap();
1416        assert!(optional_result.differences.iter().any(|difference| {
1417            difference.path == "/Catalog/OCProperties"
1418                && difference.class == SemanticDifferenceClass::Visual
1419        }));
1420
1421        let mut annotation_objects = minimal_objects("", "/Annots [5 0 R]");
1422        annotation_objects.push((
1423            5,
1424            "<< /Type /Annot /Subtype /Text /Rect [10 10 20 20] /Contents (note) >>".to_string(),
1425        ));
1426        let annotation_refs: Vec<_> = annotation_objects
1427            .iter()
1428            .map(|(number, body)| (*number, body.as_str()))
1429            .collect();
1430        let annotation = raw_pdf(&annotation_refs, 1, "");
1431        let annotation_result =
1432            compare_pdfs_semantically(&base, &annotation, &SemanticComparisonOptions::default())
1433                .unwrap();
1434        assert!(annotation_result.differences.iter().any(|difference| {
1435            difference.path == "/Pages/0/Annotations"
1436                && difference.class == SemanticDifferenceClass::Structural
1437        }));
1438        assert!(annotation_result.differences.iter().any(|difference| {
1439            difference.path == "/Pages/0/AnnotationAppearance"
1440                && difference.class == SemanticDifferenceClass::Visual
1441        }));
1442
1443        let mut metadata_objects = minimal_objects("", "");
1444        metadata_objects.push((5, "<< /Title (changed) >>".to_string()));
1445        let metadata_refs: Vec<_> = metadata_objects
1446            .iter()
1447            .map(|(number, body)| (*number, body.as_str()))
1448            .collect();
1449        let metadata = raw_pdf(&metadata_refs, 1, "/Info 5 0 R");
1450        let metadata_result =
1451            compare_pdfs_semantically(&base, &metadata, &SemanticComparisonOptions::default())
1452                .unwrap();
1453        assert!(metadata_result.differences.iter().any(|difference| {
1454            difference.path == "/Trailer/Info"
1455                && difference.class == SemanticDifferenceClass::Metadata
1456        }));
1457
1458        let mut attachment_objects = minimal_objects(
1459            "/Names << /EmbeddedFiles << /Names [(data.txt) 5 0 R] >> >>",
1460            "",
1461        );
1462        attachment_objects.push((
1463            5,
1464            "<< /Type /Filespec /F (data.txt) /EF << /F 6 0 R >> >>".to_string(),
1465        ));
1466        attachment_objects.push((
1467            6,
1468            "<< /Type /EmbeddedFile /Length 5 >>\nstream\ndata\nendstream".to_string(),
1469        ));
1470        let attachment_refs: Vec<_> = attachment_objects
1471            .iter()
1472            .map(|(number, body)| (*number, body.as_str()))
1473            .collect();
1474        let attachment = raw_pdf(&attachment_refs, 1, "");
1475        let attachment_result =
1476            compare_pdfs_semantically(&base, &attachment, &SemanticComparisonOptions::default())
1477                .unwrap();
1478        assert!(attachment_result.differences.iter().any(|difference| {
1479            difference.path == "/Catalog/Names"
1480                && difference.class == SemanticDifferenceClass::Structural
1481        }));
1482    }
1483
1484    #[test]
1485    fn unreachable_and_historical_changes_are_reported() {
1486        let mut left_objects = minimal_objects("", "");
1487        left_objects.push((5, "(orphan-left)".to_string()));
1488        let left_refs: Vec<_> = left_objects
1489            .iter()
1490            .map(|(number, body)| (*number, body.as_str()))
1491            .collect();
1492        let left = raw_pdf(&left_refs, 1, "");
1493        let mut right_objects = minimal_objects("", "");
1494        right_objects.push((5, "(orphan-right)".to_string()));
1495        let right_refs: Vec<_> = right_objects
1496            .iter()
1497            .map(|(number, body)| (*number, body.as_str()))
1498            .collect();
1499        let right = raw_pdf(&right_refs, 1, "");
1500        let unreachable_result =
1501            compare_pdfs_semantically(&left, &right, &SemanticComparisonOptions::default())
1502                .unwrap();
1503        assert!(unreachable_result
1504            .differences
1505            .iter()
1506            .any(|difference| { difference.path == "/UnreachableObjects" }));
1507
1508        let left_base_objects = minimal_objects("", "");
1509        let mut right_base_objects = minimal_objects("", "");
1510        right_base_objects[3].1 =
1511            "<< /Length 16 >>\nstream\nBT (past) Tj ET\nendstream".to_string();
1512        let left_base_refs: Vec<_> = left_base_objects
1513            .iter()
1514            .map(|(number, body)| (*number, body.as_str()))
1515            .collect();
1516        let right_base_refs: Vec<_> = right_base_objects
1517            .iter()
1518            .map(|(number, body)| (*number, body.as_str()))
1519            .collect();
1520        let final_body = "<< /Length 17 >>\nstream\nBT (final) Tj ET\nendstream";
1521        let historical_left = append_replacement(raw_pdf(&left_base_refs, 1, ""), 4, final_body);
1522        let historical_right = append_replacement(raw_pdf(&right_base_refs, 1, ""), 4, final_body);
1523        let history_result = compare_pdfs_semantically(
1524            &historical_left,
1525            &historical_right,
1526            &SemanticComparisonOptions::default(),
1527        )
1528        .unwrap();
1529        assert!(history_result
1530            .differences
1531            .iter()
1532            .any(|difference| { difference.path == "/Revisions" }));
1533        assert_eq!(history_result.left_revisions.len(), 2);
1534        assert_eq!(history_result.right_revisions.len(), 2);
1535
1536        let historical_orphan_left = append_free(left, 5, 1);
1537        let historical_orphan_right = append_free(right, 5, 1);
1538        let orphan_history_result = compare_pdfs_semantically(
1539            &historical_orphan_left,
1540            &historical_orphan_right,
1541            &SemanticComparisonOptions::default(),
1542        )
1543        .unwrap();
1544        assert!(orphan_history_result.left_unreachable_objects.is_empty());
1545        assert!(orphan_history_result.right_unreachable_objects.is_empty());
1546        assert!(orphan_history_result
1547            .differences
1548            .iter()
1549            .any(|difference| difference.path == "/Revisions"));
1550    }
1551
1552    #[test]
1553    fn cyclic_graphs_terminate_and_respect_object_limits() {
1554        let mut objects = minimal_objects("/Custom 5 0 R", "");
1555        objects.push((5, "<< /Next 6 0 R >>".to_string()));
1556        objects.push((6, "<< /Next 5 0 R >>".to_string()));
1557        let refs: Vec<_> = objects
1558            .iter()
1559            .map(|(number, body)| (*number, body.as_str()))
1560            .collect();
1561        let pdf = raw_pdf(&refs, 1, "");
1562        let equal =
1563            compare_pdfs_semantically(&pdf, &pdf, &SemanticComparisonOptions::default()).unwrap();
1564        assert!(equal.semantically_equal);
1565
1566        let options = SemanticComparisonOptions {
1567            limits: SemanticComparisonLimits {
1568                max_objects: 1,
1569                ..SemanticComparisonLimits::default()
1570            },
1571        };
1572        assert!(compare_pdfs_semantically(&pdf, &pdf, &options).is_err());
1573    }
1574
1575    #[test]
1576    fn forms_outlines_and_tags_are_independent_structural_domains() {
1577        let base_objects = minimal_objects("", "");
1578        let base_refs: Vec<_> = base_objects
1579            .iter()
1580            .map(|(number, body)| (*number, body.as_str()))
1581            .collect();
1582        let base = raw_pdf(&base_refs, 1, "");
1583
1584        for (suffix, expected_path) in [
1585            ("/AcroForm << /Fields [] >>", "/Catalog/AcroForm"),
1586            ("/Outlines << /Count 0 >>", "/Catalog/Outlines"),
1587            (
1588                "/MarkInfo << /Marked true >> /StructTreeRoot << /Type /StructTreeRoot /K [] >>",
1589                "/Catalog/StructTreeRoot",
1590            ),
1591        ] {
1592            let objects = minimal_objects(suffix, "");
1593            let refs: Vec<_> = objects
1594                .iter()
1595                .map(|(number, body)| (*number, body.as_str()))
1596                .collect();
1597            let changed = raw_pdf(&refs, 1, "");
1598            let result =
1599                compare_pdfs_semantically(&base, &changed, &SemanticComparisonOptions::default())
1600                    .unwrap();
1601            assert!(result.differences.iter().any(|difference| {
1602                difference.path == expected_path
1603                    && difference.class == SemanticDifferenceClass::Structural
1604            }));
1605        }
1606    }
1607
1608    #[test]
1609    fn every_configured_budget_fails_closed() {
1610        let objects = minimal_objects("", "");
1611        let refs: Vec<_> = objects
1612            .iter()
1613            .map(|(number, body)| (*number, body.as_str()))
1614            .collect();
1615        let pdf = raw_pdf(&refs, 1, "");
1616        for limits in [
1617            SemanticComparisonLimits {
1618                max_depth: 1,
1619                ..SemanticComparisonLimits::default()
1620            },
1621            SemanticComparisonLimits {
1622                max_decoded_stream_bytes: 1,
1623                ..SemanticComparisonLimits::default()
1624            },
1625            SemanticComparisonLimits {
1626                max_canonical_bytes: 1,
1627                ..SemanticComparisonLimits::default()
1628            },
1629            SemanticComparisonLimits {
1630                max_extracted_text_bytes: 1,
1631                ..SemanticComparisonLimits::default()
1632            },
1633        ] {
1634            let error =
1635                compare_pdfs_semantically(&pdf, &pdf, &SemanticComparisonOptions { limits })
1636                    .unwrap_err();
1637            assert!(
1638                matches!(error, PdfError::InvalidStructure(message) if message.contains("limit exceeded"))
1639            );
1640        }
1641
1642        let updated = append_replacement(
1643            pdf,
1644            4,
1645            "<< /Length 17 >>\nstream\nBT (final) Tj ET\nendstream",
1646        );
1647        let revision_error = compare_pdfs_semantically(
1648            &updated,
1649            &updated,
1650            &SemanticComparisonOptions {
1651                limits: SemanticComparisonLimits {
1652                    max_revisions: 1,
1653                    ..SemanticComparisonLimits::default()
1654                },
1655            },
1656        )
1657        .unwrap_err();
1658        assert!(
1659            matches!(revision_error, PdfError::InvalidStructure(message) if message.contains("physical revisions limit exceeded"))
1660        );
1661
1662        let mut orphan_objects = minimal_objects("", "");
1663        orphan_objects.push((5, "(orphan)".to_string()));
1664        let orphan_refs: Vec<_> = orphan_objects
1665            .iter()
1666            .map(|(number, body)| (*number, body.as_str()))
1667            .collect();
1668        let orphan_pdf = raw_pdf(&orphan_refs, 1, "");
1669        let unreachable_error = compare_pdfs_semantically(
1670            &orphan_pdf,
1671            &orphan_pdf,
1672            &SemanticComparisonOptions {
1673                limits: SemanticComparisonLimits {
1674                    max_unreachable_objects: 0,
1675                    ..SemanticComparisonLimits::default()
1676                },
1677            },
1678        )
1679        .unwrap_err();
1680        assert!(
1681            matches!(unreachable_error, PdfError::InvalidStructure(message) if message.contains("unreachable objects limit exceeded"))
1682        );
1683    }
1684
1685    #[test]
1686    fn equivalent_objects_are_reported_even_when_the_documents_differ() {
1687        let left = document(false);
1688        let mut right = left.clone();
1689        let position = right
1690            .windows(b"semantic content".len())
1691            .position(|window| window == b"semantic content")
1692            .unwrap();
1693        right[position..position + b"semantic content".len()].copy_from_slice(b"different text!!");
1694
1695        let result =
1696            compare_pdfs_semantically(&left, &right, &SemanticComparisonOptions::default())
1697                .unwrap();
1698
1699        assert!(!result.semantically_equal);
1700        assert!(!result.semantically_equivalent_objects.is_empty());
1701    }
1702
1703    #[test]
1704    fn all_xmp_timestamp_forms_are_normalized() {
1705        let left = br#"<rdf:Description xmp:CreateDate="2020" xmp:ModifyDate="2021"><xmp:MetadataDate><![CDATA[2022]]></xmp:MetadataDate><xmp:CreateDate>2023</xmp:CreateDate></rdf:Description>"#;
1706        let right = br#"<rdf:Description xmp:CreateDate="2030" xmp:ModifyDate="2031"><xmp:MetadataDate>2032</xmp:MetadataDate><xmp:CreateDate>2033</xmp:CreateDate></rdf:Description>"#;
1707
1708        assert_eq!(
1709            normalize_xmp_noise(left.to_vec()),
1710            normalize_xmp_noise(right.to_vec())
1711        );
1712    }
1713
1714    #[test]
1715    fn permitted_info_and_xmp_timestamp_noise_is_ignored() {
1716        let mut left_objects = minimal_objects("/Metadata 5 0 R", "");
1717        left_objects.push((
1718            5,
1719            "<< /Type /Metadata /Subtype /XML /Length 44 >>\nstream\n<xmp:CreateDate>2020-01-01</xmp:CreateDate>\nendstream".to_string(),
1720        ));
1721        left_objects.push((
1722            6,
1723            "<< /CreationDate (D:20200101000000Z) /ModDate (D:20200101000000Z) >>".to_string(),
1724        ));
1725        let left_refs: Vec<_> = left_objects
1726            .iter()
1727            .map(|(number, body)| (*number, body.as_str()))
1728            .collect();
1729        let left = raw_pdf(&left_refs, 1, "/Info 6 0 R");
1730
1731        let mut right_objects = minimal_objects("/Metadata 5 0 R", "");
1732        right_objects.push((
1733            5,
1734            "<< /Type /Metadata /Subtype /XML /Length 44 >>\nstream\n<xmp:CreateDate>2030-12-31</xmp:CreateDate>\nendstream".to_string(),
1735        ));
1736        right_objects.push((
1737            6,
1738            "<< /CreationDate (D:20301231000000Z) /ModDate (D:20301231000000Z) >>".to_string(),
1739        ));
1740        let right_refs: Vec<_> = right_objects
1741            .iter()
1742            .map(|(number, body)| (*number, body.as_str()))
1743            .collect();
1744        let right = raw_pdf(&right_refs, 1, "/Info 6 0 R");
1745
1746        let result =
1747            compare_pdfs_semantically(&left, &right, &SemanticComparisonOptions::default())
1748                .unwrap();
1749        assert!(result.semantically_equal, "{:?}", result.differences);
1750        assert!(result
1751            .differences
1752            .iter()
1753            .all(|difference| difference.class == SemanticDifferenceClass::SerializationOnly));
1754    }
1755}