use crate::{PdfError, Result};
use crate::PdfInput;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
pub struct RepairOptions {
pub fix_dangling_refs: bool,
pub fix_xref: bool,
pub fix_encoding: bool,
pub strip_unparsed_streams: bool,
}
impl Default for RepairOptions {
fn default() -> Self {
Self {
fix_dangling_refs: true,
fix_xref: true,
fix_encoding: true,
strip_unparsed_streams: true,
}
}
}
impl RepairOptions {
#[must_use]
pub const fn none() -> Self {
Self {
fix_dangling_refs: false,
fix_xref: false,
fix_encoding: false,
strip_unparsed_streams: false,
}
}
}
#[must_use]
pub fn is_likely_corrupt(input: &PdfInput) -> bool {
let bytes = match input {
PdfInput::Path(path) => {
let Ok(data) = std::fs::read(path) else {
return true;
};
data
}
PdfInput::Bytes(data) => data.clone(),
};
lopdf::Document::load_mem(&bytes).is_err()
}
pub fn attempt_repair(input: &PdfInput, options: &RepairOptions) -> Result<Vec<u8>> {
let bytes = match input {
PdfInput::Path(path) => std::fs::read(path)?,
PdfInput::Bytes(data) => data.clone(),
};
let mut document = lopdf::Document::load_mem(&bytes)
.map_err(|error| PdfError::Parse(format!("cannot parse PDF for repair: {error}")))?;
if options.fix_dangling_refs {
strip_dangling_refs(&mut document);
}
if options.fix_xref {
document.renumber_objects();
}
let mut output = Vec::new();
document
.save_to(&mut output)
.map_err(|error| PdfError::Io(std::io::Error::other(error)))?;
Ok(output)
}
fn strip_dangling_refs(document: &mut lopdf::Document) {
let valid_ids: std::collections::HashSet<lopdf::ObjectId> =
document.objects.keys().copied().collect();
document.traverse_objects(|object| {
nullify_dangling(object, &valid_ids);
});
}
fn nullify_dangling(
object: &mut lopdf::Object,
valid_ids: &std::collections::HashSet<lopdf::ObjectId>,
) {
match object {
lopdf::Object::Reference(id) => {
if !valid_ids.contains(id) {
*object = lopdf::Object::Null;
}
}
lopdf::Object::Dictionary(dict) => {
let keys: Vec<Vec<u8>> = dict.iter().map(|(k, _)| k.clone()).collect();
for key in keys {
if let Ok(mut value) = dict.get(&key).cloned() {
nullify_dangling(&mut value, valid_ids);
dict.set(key, value);
}
}
}
lopdf::Object::Array(array) => {
for item in array.iter_mut() {
nullify_dangling(item, valid_ids);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_likely_corrupt_rejects_garbage() {
let input = PdfInput::from_bytes(b"this is not a pdf");
assert!(is_likely_corrupt(&input));
}
#[test]
fn is_likely_corrupt_rejects_empty() {
let input = PdfInput::from_bytes(b"");
assert!(is_likely_corrupt(&input));
}
#[test]
fn is_likely_corrupt_rejects_partial_header() {
let input = PdfInput::from_bytes(b"%PDF-1.4\n% corrupted\n%%EOF");
assert!(is_likely_corrupt(&input));
}
#[test]
fn attempt_repair_rejects_garbage() {
let input = PdfInput::from_bytes(b"not a pdf at all");
let result = attempt_repair(&input, &RepairOptions::default());
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("cannot parse"), "unexpected message: {msg}");
}
#[test]
fn attempt_repair_roundtrips_valid_pdf() {
let mut doc = lopdf::Document::new();
let content_id = doc.add_object(lopdf::Object::Stream(lopdf::Stream::new(
lopdf::Dictionary::new(),
b"BT /F1 12 Tf (Hello) Tj ET".to_vec(),
)));
let mut page_dict = lopdf::Dictionary::new();
page_dict.set("Type", lopdf::Object::Name(b"Page".to_vec()));
page_dict.set(
"MediaBox",
lopdf::Object::Array(vec![0.into(), 0.into(), 595.into(), 842.into()]),
);
page_dict.set("Contents", lopdf::Object::Reference(content_id));
let page_id = doc.add_object(lopdf::Object::Dictionary(page_dict));
let mut pages = lopdf::Dictionary::new();
pages.set("Type", lopdf::Object::Name(b"Pages".to_vec()));
pages.set(
"Kids",
lopdf::Object::Array(vec![lopdf::Object::Reference(page_id)]),
);
pages.set("Count", lopdf::Object::Integer(1));
let pages_id = doc.add_object(lopdf::Object::Dictionary(pages));
let mut catalog = lopdf::Dictionary::new();
catalog.set("Type", lopdf::Object::Name(b"Catalog".to_vec()));
catalog.set("Pages", lopdf::Object::Reference(pages_id));
let catalog_id = doc.add_object(lopdf::Object::Dictionary(catalog));
doc.trailer
.set("Root", lopdf::Object::Reference(catalog_id));
let mut original = Vec::new();
doc.save_to(&mut original).unwrap();
let input = PdfInput::from_bytes(original.clone());
let repaired = attempt_repair(&input, &RepairOptions::default()).unwrap();
let reloaded = lopdf::Document::load_mem(&repaired);
assert!(reloaded.is_ok(), "repaired PDF should parse");
}
#[test]
fn repair_options_none_disables_all() {
let opts = RepairOptions::none();
assert!(!opts.fix_dangling_refs);
assert!(!opts.fix_xref);
assert!(!opts.fix_encoding);
assert!(!opts.strip_unparsed_streams);
}
#[test]
fn repair_options_default_enables_all() {
let opts = RepairOptions::default();
assert!(opts.fix_dangling_refs);
assert!(opts.fix_xref);
assert!(opts.fix_encoding);
assert!(opts.strip_unparsed_streams);
}
}