c2pa-rs

A fork of https://github.com/contentauth/c2pa-rs/
git clone git://archive.git.mtrnord.blog/mtrnords-photography-manager/c2pa-rs.git
Log | Files | Refs | README

commit d2946dc71fcb9006222b056c1261db85e15939cc
parent b7b2a92dcf9cef1bf91e49d3c5524ed3f8c4f3ea
Author: Dylan ross <dylan.ssor@gmail.com>
Date:   Thu, 21 Sep 2023 15:38:40 -0700

implements pdf read support (#309)

* implements pdf read support

* implements documentation feedback

* optimizes `use` statements

* prefers `InvalidAsset` error to new pdf specific error

* moves `pdf_utils::pdf` to `asset_handlers`

* adds reader based pdf loader

---------

Co-authored-by: Dylan Ross <dyross@adobe.com>
Diffstat:
Msdk/Cargo.toml | 1+
Msdk/src/asset_handlers/mod.rs | 5+++++
Asdk/src/asset_handlers/pdf.rs | 563+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asdk/src/asset_handlers/pdf_io.rs | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msdk/src/error.rs | 3+++
Msdk/src/jumbf_io.rs | 11++++++++++-
Msdk/src/utils/mod.rs | 2--
Dsdk/src/utils/pdf_utils.rs | 513-------------------------------------------------------------------------------
Asdk/tests/fixtures/basic-no-xmp.pdf | 0
9 files changed, 827 insertions(+), 516 deletions(-)

diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml @@ -135,6 +135,7 @@ web-sys = { version = "0.3.58", features = [ [dev-dependencies] anyhow = "1.0.40" +mockall = "0.11.2" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3.31" diff --git a/sdk/src/asset_handlers/mod.rs b/sdk/src/asset_handlers/mod.rs @@ -18,3 +18,8 @@ pub mod png_io; pub mod riff_io; pub mod svg_io; pub mod tiff_io; + +#[cfg(feature = "pdf")] +pub(crate) mod pdf; +#[cfg(feature = "pdf")] +pub mod pdf_io; diff --git a/sdk/src/asset_handlers/pdf.rs b/sdk/src/asset_handlers/pdf.rs @@ -0,0 +1,563 @@ +// Copyright 2023 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +use std::io::{Read, Write}; + +use lopdf::{ + dictionary, Document, Object, + Object::{Array, Integer, Name, Reference}, + ObjectId, Stream, +}; +use thiserror::Error; + +// Associated File Relationship +static AF_RELATIONSHIP_KEY: &[u8] = b"AFRelationship"; +static ANNOTATIONS_KEY: &[u8] = b"Annots"; +static ASSOCIATED_FILE_KEY: &[u8] = b"AF"; +static C2PA_RELATIONSHIP: &[u8] = b"C2PA_Manifest"; +static CONTENT_CREDS: &str = "Content Credentials"; +static EMBEDDED_FILES_KEY: &[u8] = b"EmbeddedFiles"; +static SUBTYPE_KEY: &[u8] = b"Subtype"; +static NAMES_KEY: &[u8] = b"Names"; + +/// Error representing failure scenarios while interacting with PDFs. +#[derive(Debug, Error)] +pub enum Error { + /// Error occurred while reading the PDF. Look into the wrapped `lopdf::Error` for more + /// information on the cause. + #[error(transparent)] + UnableToReadPdf(#[from] lopdf::Error), + + /// Error occurred while adding a C2PA manifest as an `Annotation` to the PDF. + #[error("Unable to add C2PA manifest as an annotation to the PDF.")] + AddingAnnotation, +} + +const C2PA_MIME_TYPE: &str = "application/x-c2pa-manifest-store"; + +#[cfg_attr(test, mockall::automock)] +pub(crate) trait C2paPdf: Sized { + /// Save the `C2paPdf` implementation to the provided `writer`. + fn save_to<W: Write + 'static>(&mut self, writer: &mut W) -> Result<(), std::io::Error>; + + /// Returns `true` if the `PDF` is password protected, `false` otherwise. + fn is_password_protected(&self) -> bool; + + /// Returns `true` if this PDF has `c2pa` Manifests, `false` otherwise. + fn has_c2pa_manifest(&self) -> bool; + + /// Writes provided `bytes` as a PDF `Embedded File` + fn write_manifest_as_embedded_file(&mut self, bytes: Vec<u8>) -> Result<(), Error>; + + /// Writes provided `bytes` as a PDF `Annotation`. + fn write_manifest_as_annotation(&mut self, vec: Vec<u8>) -> Result<(), Error>; + + /// Returns a reference to the C2PA manifest bytes. + #[allow(clippy::needless_lifetimes)] // required for automock::mockall + fn read_manifest_bytes<'a>(&'a self) -> Result<Option<Vec<&'a [u8]>>, Error>; + + fn read_xmp(&self) -> Option<String>; +} + +pub(crate) struct Pdf { + document: Document, +} + +impl C2paPdf for Pdf { + /// Saves the in-memory PDF to the provided `writer`. + fn save_to<W: Write>(&mut self, writer: &mut W) -> Result<(), std::io::Error> { + self.document.save_to(writer) + } + + fn is_password_protected(&self) -> bool { + self.document.is_encrypted() + } + + /// Determines if this PDF has a C2PA manifest embedded. + /// + /// This is done by checking if the Associated File key of the catalog points to a + /// [lopdf::Object::Dictionary] with an `AFRelationship` set to `C2PA_Manifest`. + fn has_c2pa_manifest(&self) -> bool { + self.document + .catalog() + .and_then(|catalog| catalog.get_deref(ASSOCIATED_FILE_KEY, &self.document)) + .and_then(Object::as_dict) + .and_then(|dict| dict.get_deref(AF_RELATIONSHIP_KEY, &self.document)) + .and_then(Object::as_name) + .map(|name| name == C2PA_RELATIONSHIP) + .unwrap_or_default() + } + + /// Writes the provided `bytes` to the PDF as an `EmbeddedFile`. + fn write_manifest_as_embedded_file(&mut self, bytes: Vec<u8>) -> Result<(), Error> { + // Add `FileStream` and `FileSpec` to the PDF. + let file_stream_ref = self.add_c2pa_embedded_file_stream(bytes); + let file_spec_ref = self.add_embedded_file_specification(file_stream_ref); + + self.set_af_relationship(file_spec_ref)?; + + let mut manifest_name_file_pair = vec![ + Object::string_literal(CONTENT_CREDS), + Reference(file_spec_ref), + ]; + + let Ok(catalog_names) = self.document.catalog_mut()?.get_mut(NAMES_KEY) else { + // No /Names key exists in the Catalog. We can safely add the /Names key and construct + // the remaining objects. + // Add /EmbeddedFiles dictionary as indirect object. + let embedded_files_ref = self.document.add_object(dictionary! { + NAMES_KEY => manifest_name_file_pair + }); + + // Add /Names dictionary as indirect object + let names_ref = self.document.add_object(dictionary! { + EMBEDDED_FILES_KEY => Reference(embedded_files_ref) + }); + + // Set /Names key in `Catalog` to reference above indirect object names dictionary. + self.document.catalog_mut()?.set(NAMES_KEY, names_ref); + return Ok(()); + }; + + // Follows the Reference to the /EmbeddedFiles Dictionary, if the Object is a Reference. + let names_dictionary = match catalog_names.as_reference() { + Ok(object_id) => self.document.get_object_mut(object_id)?.as_dict_mut()?, + _ => catalog_names.as_dict_mut()?, + }; + + let Ok(embedded_files) = names_dictionary.get_mut(EMBEDDED_FILES_KEY) else { + // We have a /Names dictionary, but are missing the /EmbeddedFiles dictionary + // and its /Names array of embedded files. + names_dictionary.set( + EMBEDDED_FILES_KEY, + dictionary! { NAMES_KEY => manifest_name_file_pair }, + ); + return Ok(()); + }; + + // Follows the reference to the /EmbeddedFiles Dictionary, if the Object is a Reference. + let embedded_files_dictionary = match embedded_files.as_reference() { + Ok(object_id) => self.document.get_object_mut(object_id)?.as_dict_mut()?, + _ => embedded_files.as_dict_mut()?, + }; + + let Ok(names) = embedded_files_dictionary.get_mut(NAMES_KEY) else { + // This PDF has the /Names dictionary, and it has the /EmbeddedFiles + // dictionary, but the /EmbeddedFiles Dictionary is missing the /Names Array. + embedded_files_dictionary.set( + NAMES_KEY, + dictionary! { NAMES_KEY => manifest_name_file_pair }, + ); + + return Ok(()); + }; + + // Follows the reference to the /Names Array, if the Object is a Reference. + let names_array = match names.as_reference() { + Ok(object_id) => self.document.get_object_mut(object_id)?.as_array_mut()?, + _ => names.as_array_mut()?, + }; + + // The PDF has the /Names dictionary, which contains the /EmbeddedFiles Dictionary, which + // contains the /Names array. Append the manifest's name (Content Credentials) + // and its reference. + names_array.append(&mut manifest_name_file_pair); + + Ok(()) + } + + /// Writes the provided bytes to the PDF as a `FileAttachment` `Annotation`. This `Annotation` + /// is added to the first page of the `PDF`, to the lower left corner. + fn write_manifest_as_annotation(&mut self, bytes: Vec<u8>) -> Result<(), Error> { + let file_stream_reference = self.add_c2pa_embedded_file_stream(bytes); + let file_spec_reference = self.add_embedded_file_specification(file_stream_reference); + + self.set_af_relationship(file_spec_reference)?; + self.add_file_attachment_annotation(file_spec_reference)?; + + Ok(()) + } + + /// Gets a reference to the `C2PA` manifest bytes of the PDF. + /// + /// This method will read the bytes of the manifest, whether the manifest was added to the + /// PDF via an `Annotation` or an `EmbeddedFile`. + /// + /// Returns an `Ok(None)` if no manifest is present. Returns a `Ok(Some(Vec<&[u8]>))` when a manifest + /// is present. + /// + /// ### Note: + /// + /// A `Vec<&[u8]>` is returned because it's possible for a PDF's manifests to be stored + /// separately, due to PDF's "Incremental Update" feature. See the spec for more details: + /// <https://c2pa.org/specifications/specifications/1.3/specs/C2PA_Specification.html#_embedding_manifests_into_pdfs> + fn read_manifest_bytes(&self) -> Result<Option<Vec<&[u8]>>, Error> { + if !self.has_c2pa_manifest() { + return Ok(None); + }; + + Ok(Some(vec![ + &self + .document + .catalog()? + .get_deref(ASSOCIATED_FILE_KEY, &self.document)? + .as_dict()? + .get_deref(b"EF", &self.document)? + .as_stream()? + .content, + ])) + } + + /// Reads the `Metadata` field referenced in the PDF document's `Catalog` entry. Will return + /// `None` if no Metadata is present. + fn read_xmp(&self) -> Option<String> { + self.document + .catalog() + .and_then(|catalog| catalog.get_deref(b"Metadata", &self.document)) + .and_then(Object::as_stream) + .ok() + .and_then(|stream_dict| { + let Ok(subtype_str) = stream_dict + .dict + .get_deref(SUBTYPE_KEY, &self.document) + .and_then(Object::as_name_str) + else { + return None; + }; + + if subtype_str.to_lowercase() != "xml" { + return None; + } + + String::from_utf8(stream_dict.content.clone()).ok() + }) + } +} + +impl Pdf { + #[allow(dead_code)] + pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> { + let document = Document::load_mem(bytes)?; + Ok(Self { document }) + } + + pub fn from_reader<R: Read>(source: R) -> Result<Self, Error> { + let document = Document::load_from(source)?; + Ok(Self { document }) + } + + /// Adds the C2PA `Annotation` to the PDF. + /// + /// ### Note: + /// The `FileAttachment` annotation is added to the first page of the PDF in the lower + /// left-hand corner. The `FileAttachment`'s location is not defined in the spec as of version + /// `1.3`. + fn add_file_attachment_annotation( + &mut self, + file_spec_reference: ObjectId, + ) -> Result<(), Error> { + let annotation = dictionary! { + "Type" => Name("Annot".into()), + "Contents" => Object::string_literal(CONTENT_CREDS), + "Name" => Object::string_literal(CONTENT_CREDS), + SUBTYPE_KEY => Name("FileAttachment".into()), + "FS" => Reference(file_spec_reference), + // Places annotation in the lower left-hand corner. The icon will be 10x10. + "Rect" => vec![0.into(), 0.into(), 10.into(), 10.into()], + }; + + // Add C2PA annotation as an indirect object. + let annotation_ref = self.document.add_object(annotation); + + // Find the reference to the first page of the PDF. + let first_page_ref = self + .document + .page_iter() + .next() + .ok_or_else(|| Error::AddingAnnotation)?; + + // Get a mutable ref to the first page as a Dictionary object. + let first_page = self + .document + .get_object_mut(first_page_ref)? + .as_dict_mut()?; + + // Ensures the /Annots array exists on the page object. + if !first_page.has(ANNOTATIONS_KEY) { + first_page.set(ANNOTATIONS_KEY, Array(vec![])) + } + + // Follows a reference to the indirect annotations array, if it exists. + let annotation_object = first_page.get_mut(ANNOTATIONS_KEY)?; + let annotations = if let Ok(v) = annotation_object.as_reference() { + self.document.get_object_mut(v)? + } else { + annotation_object + } + .as_array_mut()?; + + annotations.push(Reference(annotation_ref)); + Ok(()) + } + + /// Sets the Associated File (/AF) key of the PDF to the provided embedded file spec reference. + fn set_af_relationship(&mut self, embedded_file_spec_ref: ObjectId) -> Result<(), Error> { + self.document + .catalog_mut()? + .set(ASSOCIATED_FILE_KEY, embedded_file_spec_ref); + + Ok(()) + } + + /// Adds the `Embedded File Specification` to the PDF document. Returns the [lopdf::Object::Reference] + /// to the added `Embedded File Specification`. + fn add_embedded_file_specification(&mut self, file_stream_ref: ObjectId) -> ObjectId { + let embedded_file_stream = dictionary! { + AF_RELATIONSHIP_KEY => Name(C2PA_RELATIONSHIP.into()), + "Desc" => Object::string_literal(CONTENT_CREDS), + "F" => Object::string_literal(CONTENT_CREDS), + "EF" => Reference(file_stream_ref), + "Type" => Name("FileSpec".into()), + "UF" => Object::string_literal(CONTENT_CREDS), + }; + + self.document.add_object(embedded_file_stream) + } + + /// Adds the provided `bytes` as a `StreamDictionary` to the PDF document. Returns the + /// [lopdf::Object::Reference] of the added [lopdf::Object]. + fn add_c2pa_embedded_file_stream(&mut self, bytes: Vec<u8>) -> ObjectId { + let stream = Stream::new( + dictionary! { + SUBTYPE_KEY => C2PA_MIME_TYPE, + "Length" => Integer(bytes.len() as i64), + }, + bytes, + ); + + self.document.add_object(stream) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::*; + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_loads_pdf_from_bytes() { + let bytes = include_bytes!("../../tests/fixtures/basic.pdf"); + let pdf_result = Pdf::from_bytes(bytes); + assert!(pdf_result.is_ok()); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_loads_pdf_from_bytes_with_invalid_file() { + let bytes = include_bytes!("../../tests/fixtures/XCA.jpg"); + let pdf_result = Pdf::from_bytes(bytes); + assert!(matches!(pdf_result, Err(Error::UnableToReadPdf(_)))); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_is_password_protected() { + let bytes = include_bytes!("../../tests/fixtures/basic-password.pdf"); + let pdf_result = Pdf::from_bytes(bytes).unwrap(); + assert!(pdf_result.is_password_protected()); + + let bytes = include_bytes!("../../tests/fixtures/basic.pdf"); + let pdf = Pdf::from_bytes(bytes).unwrap(); + assert!(!pdf.is_password_protected()); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_has_c2pa_manifest_on_file_without_manifest() { + let bytes = include_bytes!("../../tests/fixtures/basic.pdf"); + let pdf = Pdf::from_bytes(bytes).unwrap(); + assert!(!pdf.has_c2pa_manifest()) + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_has_c2pa_manifest_on_file_with_manifest() { + let bytes = include_bytes!("../../tests/fixtures/basic.pdf"); + let mut pdf = Pdf::from_bytes(bytes).unwrap(); + assert!(!pdf.has_c2pa_manifest()); + + pdf.write_manifest_as_annotation(vec![0u8, 1u8]).unwrap(); + assert!(pdf.has_c2pa_manifest()); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_adds_embedded_file_spec_to_pdf_stream() { + let bytes = include_bytes!("../../tests/fixtures/express.pdf"); + let mut pdf = Pdf::from_bytes(bytes).unwrap(); + let object_count_before_add = pdf.document.objects.len(); + + let bytes = vec![10u8]; + let id = pdf.add_c2pa_embedded_file_stream(bytes.clone()); + + // Object added to the PDF's object collection. + assert_eq!(object_count_before_add + 1, pdf.document.objects.len()); + + // We are able to find the object. + let stream = pdf.document.get_object(id); + assert_eq!(stream.unwrap().as_stream().unwrap().content, bytes); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_write_manifest_as_annotation() { + let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/express.pdf")).unwrap(); + assert!(!pdf.has_c2pa_manifest()); + pdf.write_manifest_as_annotation(vec![10u8, 20u8]).unwrap(); + assert!(pdf.has_c2pa_manifest()); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_write_manifest_bytes_to_pdf_with_existing_annotations() { + let mut pdf = + Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic-annotation.pdf")).unwrap(); + pdf.write_manifest_as_annotation(vec![10u8, 20u8]).unwrap(); + assert!(pdf.has_c2pa_manifest()); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_add_manifest_to_embedded_files() { + let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); + pdf.write_manifest_as_embedded_file(vec![10u8, 20u8]) + .unwrap(); + + assert!(pdf.has_c2pa_manifest()); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_add_manifest_to_embedded_files_attachments_present() { + let mut pdf = + Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic-attachments.pdf")).unwrap(); + pdf.write_manifest_as_embedded_file(vec![10u8, 20u8]) + .unwrap(); + + assert!(pdf.has_c2pa_manifest()); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_save_to() { + let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); + assert!(!pdf.has_c2pa_manifest()); + + pdf.write_manifest_as_annotation(vec![10u8]).unwrap(); + assert!(pdf.has_c2pa_manifest()); + + let mut saved_bytes = vec![]; + pdf.save_to(&mut saved_bytes).unwrap(); + + let saved_pdf = Pdf::from_bytes(&saved_bytes).unwrap(); + assert!(saved_pdf.has_c2pa_manifest()); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_reads_manifest_bytes_for_embedded_files_manifest() { + let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/express.pdf")).unwrap(); + assert!(!pdf.has_c2pa_manifest()); + + let manifest_bytes = vec![0u8, 1u8, 1u8, 2u8, 3u8]; + pdf.write_manifest_as_embedded_file(manifest_bytes.clone()) + .unwrap(); + + assert!(pdf.has_c2pa_manifest()); + assert!(matches!( + pdf.read_manifest_bytes(), + Ok(Some(manifests)) if manifests[0] == manifest_bytes + )); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_reads_manifest_bytes_for_annotation_manifest() { + let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); + assert!(!pdf.has_c2pa_manifest()); + + let manifest_bytes = vec![0u8, 1u8, 1u8, 2u8, 3u8]; + pdf.write_manifest_as_annotation(manifest_bytes.clone()) + .unwrap(); + + assert!(pdf.has_c2pa_manifest()); + assert!(matches!( + pdf.read_manifest_bytes(), + Ok(Some(manifests)) if manifests[0] == manifest_bytes + )); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_read_manifest_bytes_from_pdf_without_bytes_returns_none() { + let pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); + assert!(!pdf.has_c2pa_manifest()); + assert!(matches!(pdf.read_manifest_bytes(), Ok(None))); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_read_manifest_bytes_from_pdf_with_other_af_relationship_returns_none() { + let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); + pdf.document + .catalog_mut() + .unwrap() + .set(ASSOCIATED_FILE_KEY, Object::Reference((100, 0))); + + assert!(matches!(pdf.read_manifest_bytes(), Ok(None))); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_read_pdf_with_associated_file_that_is_not_manifest() { + let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); + pdf.document + .catalog_mut() + .unwrap() + .set(ASSOCIATED_FILE_KEY, Object::Reference((100, 0))); + + assert!(matches!(pdf.read_manifest_bytes(), Ok(None))); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_read_xmp_on_pdf_with_none() { + let pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic-no-xmp.pdf")).unwrap(); + assert!(pdf.read_xmp().is_none()); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_read_xmp_on_pdf_with_some_metadata() { + let pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); + assert!(pdf.read_xmp().is_some()); + } +} diff --git a/sdk/src/asset_handlers/pdf_io.rs b/sdk/src/asset_handlers/pdf_io.rs @@ -0,0 +1,245 @@ +// Copyright 2023 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +use std::{fs::File, path::Path}; + +use crate::{ + asset_handlers::pdf::{C2paPdf, Pdf}, + asset_io::{AssetIO, CAIReader, CAIWriter, HashObjectPositions}, + CAIRead, Error, + Error::{JumbfNotFound, NotImplemented, PdfReadError}, +}; + +static SUPPORTED_TYPES: [&str; 2] = ["pdf", "application/pdf"]; +static WRITE_NOT_IMPLEMENTED: &str = "PDF write functionality will be added in a future release"; + +pub struct PdfIO {} + +impl CAIReader for PdfIO { + fn read_cai(&self, asset_reader: &mut dyn CAIRead) -> crate::Result<Vec<u8>> { + asset_reader.rewind()?; + let pdf = Pdf::from_reader(asset_reader).map_err(|e| Error::InvalidAsset(e.to_string()))?; + self.read_manifest_bytes(pdf) + } + + fn read_xmp(&self, asset_reader: &mut dyn CAIRead) -> Option<String> { + if asset_reader.rewind().is_err() { + return None; + } + + let Ok(pdf) = Pdf::from_reader(asset_reader) else { + return None; + }; + + self.read_xmp_from_pdf(pdf) + } +} + +impl PdfIO { + fn read_manifest_bytes(&self, pdf: impl C2paPdf) -> crate::Result<Vec<u8>> { + let Ok(result) = pdf.read_manifest_bytes() else { + return Err(PdfReadError); + }; + + let Some(bytes) = result else { + return Err(JumbfNotFound); + }; + + match bytes.as_slice() { + [bytes] => Ok(bytes.to_vec()), + _ => Err(NotImplemented( + "c2pa-rs only supports reading PDFs with one manifest".into(), + )), + } + } + + fn read_xmp_from_pdf(&self, pdf: impl C2paPdf) -> Option<String> { + pdf.read_xmp() + } +} + +impl AssetIO for PdfIO { + fn new(_asset_type: &str) -> Self + where + Self: Sized, + { + Self {} + } + + fn get_handler(&self, asset_type: &str) -> Box<dyn AssetIO> { + Box::new(PdfIO::new(asset_type)) + } + + fn get_reader(&self) -> &dyn CAIReader { + self + } + + fn get_writer(&self, _asset_type: &str) -> Option<Box<dyn CAIWriter>> { + None + } + + fn read_cai_store(&self, asset_path: &Path) -> crate::Result<Vec<u8>> { + let mut f = File::open(asset_path)?; + self.read_cai(&mut f) + } + + fn save_cai_store(&self, _asset_path: &Path, _store_bytes: &[u8]) -> crate::Result<()> { + Err(NotImplemented(WRITE_NOT_IMPLEMENTED.into())) + } + + fn get_object_locations(&self, _asset_path: &Path) -> crate::Result<Vec<HashObjectPositions>> { + Err(NotImplemented(WRITE_NOT_IMPLEMENTED.into())) + } + + fn remove_cai_store(&self, _asset_path: &Path) -> crate::Result<()> { + Err(NotImplemented(WRITE_NOT_IMPLEMENTED.into())) + } + + fn supported_types(&self) -> &[&str] { + &SUPPORTED_TYPES + } +} + +#[cfg(test)] +pub mod tests { + #![allow(clippy::panic)] + #![allow(clippy::unwrap_used)] + + use std::io::Cursor; + + use crate::{ + asset_handlers, + asset_handlers::{ + pdf::{C2paPdf, MockC2paPdf, Pdf}, + pdf_io::PdfIO, + }, + asset_io::{AssetIO, CAIReader}, + }; + + static MANIFEST_BYTES: &[u8; 2] = &[10u8, 20u8]; + + #[test] + fn test_error_reading_manifest_fails() { + let mut mock_pdf = MockC2paPdf::default(); + mock_pdf.expect_read_manifest_bytes().returning(|| { + Err(asset_handlers::pdf::Error::UnableToReadPdf( + lopdf::Error::ReferenceLimit, + )) + }); + + let pdf_io = PdfIO::new("pdf"); + assert!(matches!( + pdf_io.read_manifest_bytes(mock_pdf), + Err(crate::Error::PdfReadError) + )) + } + + #[test] + fn test_no_manifest_found_returns_no_jumbf_error() { + let mut mock_pdf = MockC2paPdf::default(); + mock_pdf.expect_read_manifest_bytes().returning(|| Ok(None)); + let pdf_io = PdfIO::new("pdf"); + + assert!(matches!( + pdf_io.read_manifest_bytes(mock_pdf), + Err(crate::Error::JumbfNotFound) + )); + } + + #[test] + fn test_one_manifest_found_returns_bytes() { + let mut mock_pdf = MockC2paPdf::default(); + mock_pdf + .expect_read_manifest_bytes() + .returning(|| Ok(Some(vec![MANIFEST_BYTES]))); + + let pdf_io = PdfIO::new("pdf"); + assert_eq!( + pdf_io.read_manifest_bytes(mock_pdf).unwrap(), + MANIFEST_BYTES.to_vec() + ); + } + + #[test] + fn test_multiple_manifest_fail_with_not_implemented_error() { + let mut mock_pdf = MockC2paPdf::default(); + mock_pdf + .expect_read_manifest_bytes() + .returning(|| Ok(Some(vec![MANIFEST_BYTES, MANIFEST_BYTES, MANIFEST_BYTES]))); + + let pdf_io = PdfIO::new("pdf"); + + assert!(matches!( + pdf_io.read_manifest_bytes(mock_pdf), + Err(crate::Error::NotImplemented(_)) + )); + } + + #[test] + fn test_returns_none_when_no_xmp() { + let mut mock_pdf = MockC2paPdf::default(); + mock_pdf.expect_read_xmp().returning(|| None); + + let pdf_io = PdfIO::new("pdf"); + assert!(pdf_io.read_xmp_from_pdf(mock_pdf).is_none()); + } + + #[test] + fn test_returns_some_when_some_xmp() { + let mut mock_pdf = MockC2paPdf::default(); + mock_pdf.expect_read_xmp().returning(|| Some("xmp".into())); + + let pdf_io = PdfIO::new("pdf"); + assert!(pdf_io.read_xmp_from_pdf(mock_pdf).is_some()); + } + + #[test] + fn test_cai_read_finds_no_manifest() { + let source = crate::utils::test::fixture_path("basic.pdf"); + let pdf_io = PdfIO::new("pdf"); + + assert!(matches!( + pdf_io.read_cai_store(&source), + Err(crate::Error::JumbfNotFound) + )); + } + + #[test] + fn test_cai_read_xmp_finds_xmp_data() { + let source = include_bytes!("../../tests/fixtures/basic.pdf"); + let mut stream = Cursor::new(source.to_vec()); + + let pdf_io = PdfIO::new("pdf"); + assert!(pdf_io.read_xmp(&mut stream).is_some()); + } + + #[test] + fn test_read_cai_returns_cai_bytes() { + let source = include_bytes!("../../tests/fixtures/basic.pdf"); + + let mut pdf = Pdf::from_bytes(source).unwrap(); + assert!(pdf.read_manifest_bytes().unwrap().is_none()); + + let mut pdf_with_manifest = vec![]; + let expected_manifest = vec![0, 1, 1, 2, 3, 5, 8, 13, 21, 34]; + + pdf.write_manifest_as_annotation(expected_manifest.clone()) + .unwrap(); + pdf.save_to(&mut pdf_with_manifest).unwrap(); + + let pdf_io = PdfIO::new("pdf"); + let mut cursor = Cursor::new(pdf_with_manifest); + + assert_eq!(pdf_io.read_cai(&mut cursor).unwrap(), expected_manifest); + } +} diff --git a/sdk/src/error.rs b/sdk/src/error.rs @@ -51,6 +51,9 @@ pub enum Error { #[error("required feature missing")] MissingFeature(String), + #[error("feature implementation incomplete")] + NotImplemented(String), + /// The attempt to serialize the claim to CBOR failed. #[error("claim could not be converted to CBOR")] ClaimEncoding, diff --git a/sdk/src/jumbf_io.rs b/sdk/src/jumbf_io.rs @@ -20,6 +20,8 @@ use std::{ use lazy_static::lazy_static; +#[cfg(feature = "pdf")] +use crate::asset_handlers::pdf_io::PdfIO; use crate::{ asset_handlers::{ bmff_io::BmffIO, c2pa_io::C2paIO, jpeg_io::JpegIO, png_io::PngIO, riff_io::RiffIO, @@ -33,6 +35,8 @@ use crate::{ lazy_static! { static ref ASSET_HANDLERS: HashMap<String, Box<dyn AssetIO>> = { let handlers: Vec<Box<dyn AssetIO>> = vec![ + #[cfg(feature = "pdf")] + Box::new(PdfIO::new("")), Box::new(BmffIO::new("")), Box::new(C2paIO::new("")), Box::new(JpegIO::new("")), @@ -41,6 +45,7 @@ lazy_static! { Box::new(SvgIO::new("")), Box::new(TiffIO::new("")), ]; + let mut handler_map = HashMap::new(); // build handler map @@ -330,6 +335,8 @@ pub mod tests { Box::new(C2paIO::new("")), Box::new(BmffIO::new("")), Box::new(JpegIO::new("")), + #[cfg(feature = "pdf")] + Box::new(PdfIO::new("")), Box::new(PngIO::new("")), Box::new(RiffIO::new("")), Box::new(TiffIO::new("")), @@ -380,13 +387,15 @@ pub mod tests { fn test_get_supported_list() { let supported = get_supported_types(); + let pdf_supported = supported.iter().any(|s| s == "pdf"); + assert_eq!(pdf_supported, cfg!(feature = "pdf")); + assert!(supported.iter().any(|s| s == "jpg")); assert!(supported.iter().any(|s| s == "jpeg")); assert!(supported.iter().any(|s| s == "png")); assert!(supported.iter().any(|s| s == "mov")); assert!(supported.iter().any(|s| s == "mp4")); assert!(supported.iter().any(|s| s == "m4a")); - assert!(supported.iter().any(|s| s == "jpg")); assert!(supported.iter().any(|s| s == "avi")); assert!(supported.iter().any(|s| s == "webp")); assert!(supported.iter().any(|s| s == "wav")); diff --git a/sdk/src/utils/mod.rs b/sdk/src/utils/mod.rs @@ -18,8 +18,6 @@ pub(crate) mod hash_utils; pub(crate) mod merkle; #[allow(dead_code)] // for wasm build pub(crate) mod patch; -#[cfg(feature = "pdf")] -pub(crate) mod pdf_utils; #[cfg(feature = "add_thumbnails")] pub(crate) mod thumbnail; pub(crate) mod time_it; diff --git a/sdk/src/utils/pdf_utils.rs b/sdk/src/utils/pdf_utils.rs @@ -1,513 +0,0 @@ -// Copyright 2023 Adobe. All rights reserved. -// This file is licensed to you under the Apache License, -// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -// or the MIT license (http://opensource.org/licenses/MIT), -// at your option. - -// Unless required by applicable law or agreed to in writing, -// this software is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -// implied. See the LICENSE-MIT and LICENSE-APACHE files for the -// specific language governing permissions and limitations under -// each license. - -use std::io::Write; - -use lopdf::{ - dictionary, Document, Object, - Object::{Array, Integer, Name, Reference}, - ObjectId, Stream, -}; -use thiserror::Error; - -// Associated File Relationship -static AF_RELATIONSHIP_KEY: &[u8] = b"AFRelationship"; -static ANNOTATIONS_KEY: &[u8] = b"Annots"; -static ASSOCIATED_FILE_KEY: &[u8] = b"AF"; -static C2PA_RELATIONSHIP: &[u8] = b"C2PA_Manifest"; -static CONTENT_CREDS: &str = "Content Credentials"; -static EMBEDDED_FILES_KEY: &[u8] = b"EmbeddedFiles"; -static SUBTYPE_KEY: &[u8] = b"Subtype"; -static NAMES_KEY: &[u8] = b"Names"; - -#[derive(Debug, Error)] -pub(crate) enum Error { - #[error(transparent)] - UnableToReadPdf(#[from] lopdf::Error), - - #[error("Unable to add C2PA manifest as an annotation to the PDF.")] - AddingAnnotation, -} - -const C2PA_MIME_TYPE: &str = "application/x-c2pa-manifest-store"; - -pub(crate) trait C2paPdf: Sized { - /// Load a PDF from a slice of bytes. - fn from_bytes(bytes: &[u8]) -> Result<Self, Error>; - - /// Save the `C2paPdf` implementation to the provided `writer`. - fn save_to<W: Write>(&mut self, writer: &mut W) -> Result<(), std::io::Error>; - - /// Returns `true` if the `PDF` is password protected, `false` otherwise. - fn is_password_protected(&self) -> bool; - - /// Returns `true` if this PDF has `c2pa` Manifests, `false` otherwise. - fn has_c2pa_manifest(&self) -> bool; - - /// Writes provided `bytes` as a PDF `Embedded File` - fn write_manifest_as_embedded_file(&mut self, bytes: Vec<u8>) -> Result<(), Error>; - - /// Writes provided `bytes` as a PDF `Annotation`. - fn write_manifest_as_annotation(&mut self, vec: Vec<u8>) -> Result<(), Error>; - - /// Returns a reference to the C2PA manifest bytes. - fn read_manifest_bytes(&self) -> Result<Option<Vec<&[u8]>>, Error>; -} - -pub(crate) struct Pdf { - document: Document, -} - -impl C2paPdf for Pdf { - fn from_bytes(bytes: &[u8]) -> Result<Self, Error> { - let document = Document::load_mem(bytes)?; - Ok(Self { document }) - } - - /// Saves the in-memory PDF to the provided `writer`. - fn save_to<W: Write>(&mut self, writer: &mut W) -> Result<(), std::io::Error> { - self.document.save_to(writer) - } - - fn is_password_protected(&self) -> bool { - self.document.is_encrypted() - } - - /// Determines if this PDF has a C2PA manifest embedded. - /// - /// This is done by checking if the Associated File key of the catalog points to a - /// [lopdf::Object::Dictionary] with an `AFRelationship` set to `C2PA_Manifest`. - fn has_c2pa_manifest(&self) -> bool { - self.document - .catalog() - .and_then(|catalog| catalog.get_deref(ASSOCIATED_FILE_KEY, &self.document)) - .and_then(Object::as_dict) - .and_then(|dict| dict.get_deref(AF_RELATIONSHIP_KEY, &self.document)) - .and_then(Object::as_name) - .map(|name| name == C2PA_RELATIONSHIP) - .unwrap_or_default() - } - - /// Writes the provided `bytes` to the PDF as an `EmbeddedFile`. - fn write_manifest_as_embedded_file(&mut self, bytes: Vec<u8>) -> Result<(), Error> { - // Add `FileStream` and `FileSpec` to the PDF. - let file_stream_ref = self.add_c2pa_embedded_file_stream(bytes); - let file_spec_ref = self.add_embedded_file_specification(file_stream_ref); - - self.set_af_relationship(file_spec_ref)?; - - let mut manifest_name_file_pair = vec![ - Object::string_literal(CONTENT_CREDS), - Reference(file_spec_ref), - ]; - - let Ok(catalog_names) = self.document.catalog_mut()?.get_mut(NAMES_KEY) else { - // No /Names key exists in the Catalog. We can safely add the /Names key and construct - // the remaining objects. - // Add /EmbeddedFiles dictionary as indirect object. - let embedded_files_ref = self.document.add_object(dictionary! { - NAMES_KEY => manifest_name_file_pair - }); - - // Add /Names dictionary as indirect object - let names_ref = self.document.add_object(dictionary! { - EMBEDDED_FILES_KEY => Reference(embedded_files_ref) - }); - - // Set /Names key in `Catalog` to reference above indirect object names dictionary. - self.document.catalog_mut()?.set(NAMES_KEY, names_ref); - return Ok(()); - }; - - // Follows the Reference to the /EmbeddedFiles Dictionary, if the Object is a Reference. - let names_dictionary = match catalog_names.as_reference() { - Ok(object_id) => self.document.get_object_mut(object_id)?.as_dict_mut()?, - _ => catalog_names.as_dict_mut()?, - }; - - let Ok(embedded_files) = names_dictionary.get_mut(EMBEDDED_FILES_KEY) else { - // We have a /Names dictionary, but are missing the /EmbeddedFiles dictionary - // and its /Names array of embedded files. - names_dictionary.set( - EMBEDDED_FILES_KEY, - dictionary! { NAMES_KEY => manifest_name_file_pair }, - ); - return Ok(()); - }; - - // Follows the reference to the /EmbeddedFiles Dictionary, if the Object is a Reference. - let embedded_files_dictionary = match embedded_files.as_reference() { - Ok(object_id) => self.document.get_object_mut(object_id)?.as_dict_mut()?, - _ => embedded_files.as_dict_mut()?, - }; - - let Ok(names) = embedded_files_dictionary.get_mut(NAMES_KEY) else { - // This PDF has the /Names dictionary, and it has the /EmbeddedFiles - // dictionary, but the /EmbeddedFiles Dictionary is missing the /Names Array. - embedded_files_dictionary.set( - NAMES_KEY, - dictionary! { NAMES_KEY => manifest_name_file_pair }, - ); - - return Ok(()); - }; - - // Follows the reference to the /Names Array, if the Object is a Reference. - let names_array = match names.as_reference() { - Ok(object_id) => self.document.get_object_mut(object_id)?.as_array_mut()?, - _ => names.as_array_mut()?, - }; - - // The PDF has the /Names dictionary, which contains the /EmbeddedFiles Dictionary, which - // contains the /Names array. Append the manifest's name (Content Credentials) - // and its reference. - names_array.append(&mut manifest_name_file_pair); - - Ok(()) - } - - /// Writes the provided bytes to the PDF as a `FileAttachment` `Annotation`. This `Annotation` - /// is added to the first page of the `PDF`, to the lower left corner. - fn write_manifest_as_annotation(&mut self, bytes: Vec<u8>) -> Result<(), Error> { - let file_stream_reference = self.add_c2pa_embedded_file_stream(bytes); - let file_spec_reference = self.add_embedded_file_specification(file_stream_reference); - - self.set_af_relationship(file_spec_reference)?; - self.add_file_attachment_annotation(file_spec_reference)?; - - Ok(()) - } - - /// Gets a reference to the `C2PA` manifest bytes of the PDF. - /// - /// This method will read the bytes of the manifest, whether the manifest was added to the - /// PDF via an `Annotation` or an `EmbeddedFile`. - /// - /// Returns an `Ok(None)` if no manifest is present. Returns a `Ok(Some(Vec<&[u8]>))` when a manifest - /// is present. - /// - /// ### Note: - /// - /// A `Vec<&[u8]>` is returned because it's possible for a PDF's manifests to be stored - /// separately, due to PDF's "Incremental Update" feature. See the spec for more details: - /// <https://c2pa.org/specifications/specifications/1.3/specs/C2PA_Specification.html#_embedding_manifests_into_pdfs> - fn read_manifest_bytes(&self) -> Result<Option<Vec<&[u8]>>, Error> { - if !self.has_c2pa_manifest() { - return Ok(None); - }; - - Ok(Some(vec![ - &self - .document - .catalog()? - .get_deref(ASSOCIATED_FILE_KEY, &self.document)? - .as_dict()? - .get_deref(b"EF", &self.document)? - .as_stream()? - .content, - ])) - } -} - -impl Pdf { - /// Adds the C2PA `Annotation` to the PDF. - /// - /// ### Note: - /// The `FileAttachment` annotation is added to the first page of the PDF in the lower - /// left-hand corner. The `FileAttachment`'s location is not defined in the spec as of version - /// `1.3`. - fn add_file_attachment_annotation( - &mut self, - file_spec_reference: ObjectId, - ) -> Result<(), Error> { - let annotation = dictionary! { - "Type" => Name("Annot".into()), - "Contents" => Object::string_literal(CONTENT_CREDS), - "Name" => Object::string_literal(CONTENT_CREDS), - SUBTYPE_KEY => Name("FileAttachment".into()), - "FS" => Reference(file_spec_reference), - // Places annotation in the lower left-hand corner. The icon will be 10x10. - "Rect" => vec![0.into(), 0.into(), 10.into(), 10.into()], - }; - - // Add C2PA annotation as an indirect object. - let annotation_ref = self.document.add_object(annotation); - - // Find the reference to the first page of the PDF. - let first_page_ref = self - .document - .page_iter() - .next() - .ok_or_else(|| Error::AddingAnnotation)?; - - // Get a mutable ref to the first page as a Dictionary object. - let first_page = self - .document - .get_object_mut(first_page_ref)? - .as_dict_mut()?; - - // Ensures the /Annots array exists on the page object. - if !first_page.has(ANNOTATIONS_KEY) { - first_page.set(ANNOTATIONS_KEY, Array(vec![])) - } - - // Follows a reference to the indirect annotations array, if it exists. - let annotation_object = first_page.get_mut(ANNOTATIONS_KEY)?; - let annotations = if let Ok(v) = annotation_object.as_reference() { - self.document.get_object_mut(v)? - } else { - annotation_object - } - .as_array_mut()?; - - annotations.push(Reference(annotation_ref)); - Ok(()) - } - - /// Sets the Associated File (/AF) key of the PDF to the provided embedded file spec reference. - fn set_af_relationship(&mut self, embedded_file_spec_ref: ObjectId) -> Result<(), Error> { - self.document - .catalog_mut()? - .set(ASSOCIATED_FILE_KEY, embedded_file_spec_ref); - - Ok(()) - } - - /// Adds the `Embedded File Specification` to the PDF document. Returns the [lopdf::Object::Reference] - /// to the added `Embedded File Specification`. - fn add_embedded_file_specification(&mut self, file_stream_ref: ObjectId) -> ObjectId { - let embedded_file_stream = dictionary! { - AF_RELATIONSHIP_KEY => Name(C2PA_RELATIONSHIP.into()), - "Desc" => Object::string_literal(CONTENT_CREDS), - "F" => Object::string_literal(CONTENT_CREDS), - "EF" => Reference(file_stream_ref), - "Type" => Name("FileSpec".into()), - "UF" => Object::string_literal(CONTENT_CREDS), - }; - - self.document.add_object(embedded_file_stream) - } - - /// Adds the provided `bytes` as a `StreamDictionary` to the PDF document. Returns the - /// [lopdf::Object::Reference] of the added [lopdf::Object]. - fn add_c2pa_embedded_file_stream(&mut self, bytes: Vec<u8>) -> ObjectId { - let stream = Stream::new( - dictionary! { - SUBTYPE_KEY => C2PA_MIME_TYPE, - "Length" => Integer(bytes.len() as i64), - }, - bytes, - ); - - self.document.add_object(stream) - } -} - -#[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used)] - - use super::*; - - #[cfg(target_arch = "wasm32")] - wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); - - #[cfg(target_arch = "wasm32")] - use wasm_bindgen_test::*; - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_loads_pdf_from_bytes() { - let bytes = include_bytes!("../../tests/fixtures/basic.pdf"); - let pdf_result = Pdf::from_bytes(bytes); - assert!(pdf_result.is_ok()); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_loads_pdf_from_bytes_with_invalid_file() { - let bytes = include_bytes!("../../tests/fixtures/XCA.jpg"); - let pdf_result = Pdf::from_bytes(bytes); - assert!(matches!(pdf_result, Err(Error::UnableToReadPdf(_)))); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_is_password_protected() { - let bytes = include_bytes!("../../tests/fixtures/basic-password.pdf"); - let pdf_result = Pdf::from_bytes(bytes).unwrap(); - assert!(pdf_result.is_password_protected()); - - let bytes = include_bytes!("../../tests/fixtures/basic.pdf"); - let pdf = Pdf::from_bytes(bytes).unwrap(); - assert!(!pdf.is_password_protected()); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_has_c2pa_manifest_on_file_without_manifest() { - let bytes = include_bytes!("../../tests/fixtures/basic.pdf"); - let pdf = Pdf::from_bytes(bytes).unwrap(); - assert!(!pdf.has_c2pa_manifest()) - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_has_c2pa_manifest_on_file_with_manifest() { - let bytes = include_bytes!("../../tests/fixtures/basic.pdf"); - let mut pdf = Pdf::from_bytes(bytes).unwrap(); - assert!(!pdf.has_c2pa_manifest()); - - pdf.write_manifest_as_annotation(vec![0u8, 1u8]).unwrap(); - assert!(pdf.has_c2pa_manifest()); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_adds_embedded_file_spec_to_pdf_stream() { - let bytes = include_bytes!("../../tests/fixtures/express.pdf"); - let mut pdf = Pdf::from_bytes(bytes).unwrap(); - let object_count_before_add = pdf.document.objects.len(); - - let bytes = vec![10u8]; - let id = pdf.add_c2pa_embedded_file_stream(bytes.clone()); - - // Object added to the PDF's object collection. - assert_eq!(object_count_before_add + 1, pdf.document.objects.len()); - - // We are able to find the object. - let stream = pdf.document.get_object(id); - assert_eq!(stream.unwrap().as_stream().unwrap().content, bytes); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_write_manifest_as_annotation() { - let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/express.pdf")).unwrap(); - assert!(!pdf.has_c2pa_manifest()); - pdf.write_manifest_as_annotation(vec![10u8, 20u8]).unwrap(); - assert!(pdf.has_c2pa_manifest()); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_write_manifest_bytes_to_pdf_with_existing_annotations() { - let mut pdf = - Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic-annotation.pdf")).unwrap(); - pdf.write_manifest_as_annotation(vec![10u8, 20u8]).unwrap(); - assert!(pdf.has_c2pa_manifest()); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_add_manifest_to_embedded_files() { - let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); - pdf.write_manifest_as_embedded_file(vec![10u8, 20u8]) - .unwrap(); - - assert!(pdf.has_c2pa_manifest()); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_add_manifest_to_embedded_files_attachments_present() { - let mut pdf = - Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic-attachments.pdf")).unwrap(); - pdf.write_manifest_as_embedded_file(vec![10u8, 20u8]) - .unwrap(); - - assert!(pdf.has_c2pa_manifest()); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_save_to() { - let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); - assert!(!pdf.has_c2pa_manifest()); - - pdf.write_manifest_as_annotation(vec![10u8]).unwrap(); - assert!(pdf.has_c2pa_manifest()); - - let mut saved_bytes = vec![]; - pdf.save_to(&mut saved_bytes).unwrap(); - - let saved_pdf = Pdf::from_bytes(&saved_bytes).unwrap(); - assert!(saved_pdf.has_c2pa_manifest()); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_reads_manifest_bytes_for_embedded_files_manifest() { - let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/express.pdf")).unwrap(); - assert!(!pdf.has_c2pa_manifest()); - - let manifest_bytes = vec![0u8, 1u8, 1u8, 2u8, 3u8]; - pdf.write_manifest_as_embedded_file(manifest_bytes.clone()) - .unwrap(); - - assert!(pdf.has_c2pa_manifest()); - assert!(matches!( - pdf.read_manifest_bytes(), - Ok(Some(manifests)) if manifests[0] == manifest_bytes - )); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_reads_manifest_bytes_for_annotation_manifest() { - let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); - assert!(!pdf.has_c2pa_manifest()); - - let manifest_bytes = vec![0u8, 1u8, 1u8, 2u8, 3u8]; - pdf.write_manifest_as_annotation(manifest_bytes.clone()) - .unwrap(); - - assert!(pdf.has_c2pa_manifest()); - assert!(matches!( - pdf.read_manifest_bytes(), - Ok(Some(manifests)) if manifests[0] == manifest_bytes - )); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_read_manifest_bytes_from_pdf_without_bytes_returns_none() { - let pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); - assert!(!pdf.has_c2pa_manifest()); - assert!(matches!(pdf.read_manifest_bytes(), Ok(None))); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_read_manifest_bytes_from_pdf_with_other_af_relationship_returns_none() { - let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); - pdf.document - .catalog_mut() - .unwrap() - .set(ASSOCIATED_FILE_KEY, Object::Reference((100, 0))); - - assert!(matches!(pdf.read_manifest_bytes(), Ok(None))); - } - - #[cfg_attr(not(target_arch = "wasm32"), test)] - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] - fn test_read_pdf_with_associated_file_that_is_not_manifest() { - let mut pdf = Pdf::from_bytes(include_bytes!("../../tests/fixtures/basic.pdf")).unwrap(); - pdf.document - .catalog_mut() - .unwrap() - .set(ASSOCIATED_FILE_KEY, Object::Reference((100, 0))); - - assert!(matches!(pdf.read_manifest_bytes(), Ok(None))); - } -} diff --git a/sdk/tests/fixtures/basic-no-xmp.pdf b/sdk/tests/fixtures/basic-no-xmp.pdf Binary files differ.