use std::{collections::BTreeMap, fmt::Write as _, path::Path};
use lopdf::{Document, Object, ObjectId};
use sha2::{Digest, Sha256};
type Result<T> = std::result::Result<T, String>;
#[derive(Debug, PartialEq, Eq)]
pub struct TemplateInventory {
pub page_count: usize,
pub field_count: usize,
pub text_field_count: usize,
pub button_field_count: usize,
pub untyped_field_count: usize,
pub sha256: String,
}
pub fn acroform_inventory(template: impl AsRef<Path>) -> Result<TemplateInventory> {
let document = Document::load(template).map_err(|error| error.to_string())?;
let (_, catalog) = document
.dereference(
document
.trailer
.get(b"Root")
.map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
let catalog = catalog.as_dict().map_err(|error| error.to_string())?;
let (_, form) = document
.dereference(
catalog
.get(b"AcroForm")
.map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
let form = form.as_dict().map_err(|error| error.to_string())?;
let (_, roots) = document
.dereference(form.get(b"Fields").map_err(|error| error.to_string())?)
.map_err(|error| error.to_string())?;
let mut entries = Vec::new();
for field in roots.as_array().map_err(|error| error.to_string())? {
collect_field_entries(&document, field, "", &mut entries)?;
}
Ok(inventory_from_entries(document.get_pages().len(), entries))
}
pub(super) fn field_index(document: &Document) -> Result<BTreeMap<String, (ObjectId, ObjectId)>> {
let paths = acroform_path_index(document)?;
let mut index = BTreeMap::new();
for (widget, target, name, _) in field_entries_with_ids(document)? {
let path = paths.get(&target).cloned().unwrap_or(name.clone());
let candidate = if name == path
|| name.is_empty()
|| path
.rsplit('.')
.next()
.is_some_and(|segment| segment == name)
{
path
} else {
format!("{path}.{name}")
};
index.insert(candidate, (widget, target));
index.insert(name, (widget, target));
}
Ok(index)
}
fn acroform_path_index(document: &Document) -> Result<BTreeMap<ObjectId, String>> {
let (_, catalog) = document
.dereference(
document
.trailer
.get(b"Root")
.map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
let catalog = catalog.as_dict().map_err(|error| error.to_string())?;
let (_, form) = document
.dereference(
catalog
.get(b"AcroForm")
.map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
let form = form.as_dict().map_err(|error| error.to_string())?;
let (_, roots) = document
.dereference(form.get(b"Fields").map_err(|error| error.to_string())?)
.map_err(|error| error.to_string())?;
let mut index = BTreeMap::new();
for field in roots.as_array().map_err(|error| error.to_string())? {
collect_field_paths(document, field, "", &mut index)?;
}
Ok(index)
}
fn collect_field_paths(
document: &Document,
field: &Object,
parent_name: &str,
index: &mut BTreeMap<ObjectId, String>,
) -> Result<()> {
let (id, field) = document
.dereference(field)
.map_err(|error| error.to_string())?;
let field = field.as_dict().map_err(|error| error.to_string())?;
let full_name = if let Ok(name) = field.get(b"T").and_then(Object::as_str) {
let name = String::from_utf8_lossy(name);
if parent_name.is_empty() {
name.into_owned()
} else {
format!("{parent_name}.{name}")
}
} else {
parent_name.to_owned()
};
if let Some(id) = id {
index.insert(id, full_name.clone());
}
if let Ok(kids) = field.get(b"Kids") {
let (_, kids) = document
.dereference(kids)
.map_err(|error| error.to_string())?;
for child in kids.as_array().map_err(|error| error.to_string())? {
collect_field_paths(document, child, &full_name, index)?;
}
}
Ok(())
}
fn inventory_from_entries(
page_count: usize,
mut entries: Vec<(String, String)>,
) -> TemplateInventory {
entries.sort_unstable();
entries.dedup();
let mut hasher = Sha256::new();
let mut text_field_count = 0;
let mut button_field_count = 0;
let mut untyped_field_count = 0;
for (name, kind) in &entries {
hasher.update(name.as_bytes());
hasher.update(b"\t");
hasher.update(kind.as_bytes());
hasher.update(b"\n");
match kind.as_str() {
"/Tx" => text_field_count += 1,
"/Btn" => button_field_count += 1,
_ => untyped_field_count += 1,
}
}
let mut sha256 = String::new();
for byte in hasher.finalize() {
write!(&mut sha256, "{byte:02x}").expect("writing to a String cannot fail");
}
TemplateInventory {
page_count,
field_count: entries.len(),
text_field_count,
button_field_count,
untyped_field_count,
sha256,
}
}
fn collect_field_entries(
document: &Document,
field: &Object,
parent_name: &str,
entries: &mut Vec<(String, String)>,
) -> Result<()> {
let (_, field) = document
.dereference(field)
.map_err(|error| error.to_string())?;
let field = field.as_dict().map_err(|error| error.to_string())?;
let full_name = if let Ok(name) = field.get(b"T").and_then(Object::as_str) {
let name = String::from_utf8_lossy(name);
let full_name = if parent_name.is_empty() {
name.into_owned()
} else {
format!("{parent_name}.{name}")
};
let kind = field
.get(b"FT")
.ok()
.and_then(|value| value.as_name().ok())
.map_or_else(
|| "None".to_owned(),
|value| format!("/{}", String::from_utf8_lossy(value)),
);
entries.push((full_name.clone(), kind));
full_name
} else {
parent_name.to_owned()
};
if let Ok(kids) = field.get(b"Kids") {
let (_, kids) = document
.dereference(kids)
.map_err(|error| error.to_string())?;
for child in kids.as_array().map_err(|error| error.to_string())? {
collect_field_entries(document, child, &full_name, entries)?;
}
}
Ok(())
}
fn field_entries_with_ids(
document: &Document,
) -> Result<Vec<(ObjectId, ObjectId, String, String)>> {
let mut entries = Vec::new();
for page_id in document.get_pages().into_values() {
let page = document
.get_object(page_id)
.and_then(Object::as_dict)
.map_err(|error| error.to_string())?;
let (_, annotations) = document
.dereference(page.get(b"Annots").map_err(|error| error.to_string())?)
.map_err(|error| error.to_string())?;
let annotations = annotations.as_array().map_err(|error| error.to_string())?;
for annotation in annotations {
let widget_id = annotation
.as_reference()
.map_err(|error| error.to_string())?;
let widget = document
.get_object(widget_id)
.and_then(Object::as_dict)
.map_err(|error| error.to_string())?;
let target = if widget.get(b"T").is_ok() {
widget_id
} else {
widget
.get(b"Parent")
.ok()
.and_then(|parent| parent.as_reference().ok())
.unwrap_or(widget_id)
};
let field = document
.get_object(target)
.and_then(Object::as_dict)
.map_err(|error| error.to_string())?;
let name = widget
.get(b"T")
.or_else(|_| field.get(b"T"))
.and_then(Object::as_str)
.map_err(|error| error.to_string())?;
let kind = field
.get(b"FT")
.or_else(|_| widget.get(b"FT"))
.ok()
.and_then(|value| value.as_name().ok())
.map_or_else(
|| "None".to_owned(),
|value| format!("/{}", String::from_utf8_lossy(value)),
);
entries.push((
widget_id,
target,
String::from_utf8_lossy(name).into_owned(),
kind,
));
}
}
Ok(entries)
}