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:
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.