1use 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#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct SemanticComparisonLimits {
17 pub max_objects: usize,
19 pub max_depth: usize,
21 pub max_decoded_stream_bytes: usize,
23 pub max_canonical_bytes: usize,
25 pub max_extracted_text_bytes: usize,
27 pub max_unreachable_objects: usize,
29 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct SemanticComparisonOptions {
50 pub limits: SemanticComparisonLimits,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SemanticDifferenceClass {
57 Visual,
59 Textual,
61 Structural,
63 Metadata,
65 Security,
67 SerializationOnly,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SemanticPdfDifference {
74 pub path: String,
76 pub class: SemanticDifferenceClass,
78 pub description: String,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum RevisionObjectChangeKind {
85 Added,
87 Replaced,
89 Freed,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct RevisionObjectChange {
96 pub object_number: u32,
98 pub generation: u16,
100 pub kind: RevisionObjectChangeKind,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct PdfRevisionSummary {
107 pub index: usize,
109 pub xref_offset: u64,
111 pub object_changes: Vec<RevisionObjectChange>,
113 pub semantic_fingerprint: [u8; 32],
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct SemanticComparisonResult {
120 pub semantically_equal: bool,
122 pub differences: Vec<SemanticPdfDifference>,
124 pub left_revisions: Vec<PdfRevisionSummary>,
126 pub right_revisions: Vec<PdfRevisionSummary>,
128 pub left_unreachable_objects: Vec<(u32, u16)>,
130 pub right_unreachable_objects: Vec<(u32, u16)>,
132 pub semantically_equivalent_objects: Vec<((u32, u16), (u32, u16))>,
134}
135
136pub 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}