Lines
63.64 %
Functions
77.08 %
Branches
26.42 %
use chrono::{DateTime, Utc};
use float_cmp::approx_eq;
use lopdf::{self, Dictionary, Object};
use predicates::prelude::*;
use predicates::reflection::{Case, Child, PredicateReflection, Product};
use std::cmp;
use std::fmt;
/// Checks that the variable of type [u8] can be parsed as a PDF file.
#[derive(Debug)]
pub struct PdfPredicate {}
impl PdfPredicate {
pub fn with_page_count(self, num_pages: usize) -> DetailPredicate<Self> {
DetailPredicate::<Self> {
p: self,
d: Detail::PageCount(num_pages),
}
pub fn with_page_size(
self,
idx: usize,
width_in_points: f32,
height_in_points: f32,
) -> DetailPredicate<Self> {
d: Detail::PageSize(
Dimensions {
w: width_in_points,
h: height_in_points,
unit: 1.0,
},
idx,
),
pub fn with_creation_date(self, when: DateTime<Utc>) -> DetailPredicate<Self> {
d: Detail::CreationDate(when),
pub fn with_link(self, link: &str) -> DetailPredicate<Self> {
d: Detail::Link(link.to_string()),
pub fn with_text(self, text: &str) -> DetailPredicate<Self> {
d: Detail::Text(text.to_string()),
pub fn with_version(self, version: &str) -> DetailPredicate<Self> {
d: Detail::Version(version.to_string()),
impl Predicate<[u8]> for PdfPredicate {
fn eval(&self, data: &[u8]) -> bool {
lopdf::Document::load_mem(data).is_ok()
fn find_case<'a>(&'a self, _expected: bool, data: &[u8]) -> Option<Case<'a>> {
match lopdf::Document::load_mem(data) {
Ok(_) => None,
Err(e) => Some(Case::new(Some(self), false).add_product(Product::new("Error", e))),
impl PredicateReflection for PdfPredicate {}
impl fmt::Display for PdfPredicate {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "is a PDF")
/// Extends a PdfPredicate by a check for page count, page size or creation date.
pub struct DetailPredicate<PdfPredicate> {
p: PdfPredicate,
d: Detail,
enum Detail {
PageCount(usize),
PageSize(Dimensions, usize),
CreationDate(DateTime<Utc>),
Link(String),
Text(String),
Version(String),
/// A PDF page's dimensions from its `MediaBox`.
///
/// Note that `w` and `h` given in `UserUnit`, which is by default 1.0 = 1/72 inch.
struct Dimensions {
w: f32,
h: f32,
unit: f32, // UserUnit, in points (1/72 of an inch)
impl Dimensions {
pub fn from_media_box(obj: &lopdf::Object, unit: Option<f32>) -> lopdf::Result<Dimensions> {
let a = obj.as_array()?;
Ok(Dimensions {
w: a[2].as_float()?,
h: a[3].as_float()?,
unit: unit.unwrap_or(1.0),
})
pub fn width_in_pt(&self) -> f32 {
self.w * self.unit
pub fn height_in_pt(&self) -> f32 {
self.h * self.unit
impl fmt::Display for Dimensions {
write!(f, "{} pt x {} pt", self.width_in_pt(), self.height_in_pt())
impl cmp::PartialEq for Dimensions {
fn eq(&self, other: &Self) -> bool {
approx_eq!(
f32,
self.width_in_pt(),
other.width_in_pt(),
epsilon = 0.0001
) && approx_eq!(
self.height_in_pt(),
other.height_in_pt(),
)
impl cmp::Eq for Dimensions {}
trait Details {
fn get_page_count(&self) -> usize;
fn get_page_size(&self, idx: usize) -> Option<Dimensions>;
fn get_creation_date(&self) -> Option<DateTime<Utc>>;
fn get_from_trailer<'a>(&'a self, key: &[u8]) -> lopdf::Result<&'a lopdf::Object>;
fn get_from_page<'a>(&'a self, idx: usize, key: &[u8]) -> lopdf::Result<&'a lopdf::Object>;
impl DetailPredicate<PdfPredicate> {
fn eval_doc(&self, doc: &lopdf::Document) -> bool {
match &self.d {
Detail::PageCount(n) => doc.get_page_count() == *n,
Detail::PageSize(d, idx) => doc.get_page_size(*idx).map_or(false, |dim| dim == *d),
Detail::CreationDate(d) => doc.get_creation_date().map_or(false, |date| date == *d),
Detail::Link(link) => document_has_link(doc, link),
Detail::Text(text) => document_has_text(doc, text),
Detail::Version(version) => document_has_version(doc, version),
fn find_case_for_doc<'a>(&'a self, expected: bool, doc: &lopdf::Document) -> Option<Case<'a>> {
if self.eval_doc(doc) == expected {
let product = self.product_for_doc(doc);
Some(Case::new(Some(self), false).add_product(product))
} else {
None
fn product_for_doc(&self, doc: &lopdf::Document) -> Product {
Detail::PageCount(_) => Product::new(
"actual page count",
format!("{} page(s)", doc.get_page_count()),
Detail::PageSize(_, idx) => Product::new(
"actual page size",
match doc.get_page_size(*idx) {
Some(dim) => format!("{}", dim),
None => "None".to_string(),
Detail::CreationDate(_) => Product::new(
"actual creation date",
format!("{:?}", doc.get_creation_date()),
Detail::Link(_) => Product::new(
"actual link contents",
"FIXME: who knows, but it's not what we expected".to_string(),
Detail::Text(_) => {
Product::new("actual text contents", doc.extract_text(&[1]).unwrap())
Detail::Version(_) => Product::new("actual version contents", doc.version.to_string()),
// Extensions to lopdf::Object; can be removed after lopdf 0.26
trait ObjExt {
/// Get the object value as a float.
/// Unlike as_f32() this will also cast an Integer to a Real.
fn as_float(&self) -> lopdf::Result<f32>;
impl ObjExt for lopdf::Object {
fn as_float(&self) -> lopdf::Result<f32> {
match *self {
lopdf::Object::Integer(ref value) => Ok(*value as f32),
lopdf::Object::Real(ref value) => Ok(*value),
_ => Err(lopdf::Error::Type),
impl Details for lopdf::Document {
fn get_page_count(&self) -> usize {
self.get_pages().len()
fn get_page_size(&self, idx: usize) -> Option<Dimensions> {
match self.get_from_page(idx, b"MediaBox") {
Ok(obj) => {
let unit = self
.get_from_page(idx, b"UserUnit")
.and_then(ObjExt::as_float)
.ok();
Dimensions::from_media_box(obj, unit).ok()
Err(_) => None,
fn get_creation_date(&self) -> Option<DateTime<Utc>> {
match self.get_from_trailer(b"CreationDate") {
Ok(obj) => obj.as_datetime().map(|date| date.with_timezone(&Utc)),
fn get_from_trailer<'a>(&'a self, key: &[u8]) -> lopdf::Result<&'a lopdf::Object> {
let id = self.trailer.get(b"Info")?.as_reference()?;
self.get_object(id)?.as_dict()?.get(key)
fn get_from_page<'a>(&'a self, idx: usize, key: &[u8]) -> lopdf::Result<&'a lopdf::Object> {
let mut iter = self.page_iter();
for _ in 0..idx {
let _ = iter.next();
match iter.next() {
Some(id) => self.get_object(id)?.as_dict()?.get(key),
None => Err(lopdf::Error::ObjectNotFound),
impl Predicate<[u8]> for DetailPredicate<PdfPredicate> {
Ok(doc) => self.eval_doc(&doc),
_ => false,
fn find_case<'a>(&'a self, expected: bool, data: &[u8]) -> Option<Case<'a>> {
Ok(doc) => self.find_case_for_doc(expected, &doc),
impl PredicateReflection for DetailPredicate<PdfPredicate> {
fn children<'a>(&'a self) -> Box<dyn Iterator<Item = Child<'a>> + 'a> {
let params = vec![Child::new("predicate", &self.p)];
Box::new(params.into_iter())
impl fmt::Display for DetailPredicate<PdfPredicate> {
Detail::PageCount(n) => write!(f, "is a PDF with {} page(s)", n),
Detail::PageSize(d, _) => write!(f, "is a PDF sized {}", d),
Detail::CreationDate(d) => write!(f, "is a PDF created {:?}", d),
Detail::Link(l) => write!(f, "is a PDF with a link to {}", l),
Detail::Text(t) => write!(f, "is a PDF with \"{}\" in its text content", t),
Detail::Version(v) => write!(f, "is a PDF with version {}", v),
// This is an extremely trivial test for a string being present in the document's
// text objects.
fn document_has_text(document: &lopdf::Document, needle: &str) -> bool {
if let Ok(haystack) = text_from_first_page(document) {
haystack.contains(needle)
false
fn document_has_version(document: &lopdf::Document, version_to_search: &str) -> bool {
document.version == version_to_search
// We do a super simple test that a PDF actually contains an Annotation object
// with a particular link. We don't test that this annotation is actually linked
// from a page; that would be nicer.
fn document_has_link(document: &lopdf::Document, link_text: &str) -> bool {
document
.objects
.values()
.any(|obj| object_is_annotation_with_link(obj, link_text))
fn object_is_annotation_with_link(object: &Object, link_text: &str) -> bool {
object
.as_dict()
.map(|dict| dict_is_annotation(dict) && dict_has_a_with_link(dict, link_text))
.unwrap_or(false)
fn dict_is_annotation(dict: &Dictionary) -> bool {
dict.get(b"Type")
.and_then(|type_val| type_val.as_name_str())
.map(|name| name == "Annot")
fn dict_has_a_with_link(dict: &Dictionary, link_text: &str) -> bool {
dict.get(b"A")
.and_then(|obj| obj.as_dict())
.and_then(|dict| dict.get(b"URI"))
.and_then(|obj| obj.as_str())
.map(|string| string == link_text.as_bytes())
fn text_from_first_page(doc: &lopdf::Document) -> lopdf::Result<String> {
// This is extremely simplistic; lopdf just concatenates all the text in the page
// into a single string.
doc.extract_text(&[1])