feat(errors): Add NotAPdf error variant for graceful non-PDF file handling

Validate files against %PDF- magic before parsing, returning a
machine-readable NotAPdf error with a hint about the actual file type
(HTML, XML, JSON, PNG, JPEG, ZIP, plain text). Improves From<lopdf::Error>
with structured matching for IO, encryption, and structural errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-15 21:06:51 -08:00
co-authored by Claude Opus 4.6
parent 4796ccd634
commit fb4d168882
4 changed files with 258 additions and 2 deletions
+4
View File
@@ -72,6 +72,8 @@ pub fn detect_pdf_type_with_config<P: AsRef<Path>>(
path: P,
config: DetectionConfig,
) -> Result<PdfTypeResult, PdfError> {
crate::validate_pdf_file(&path)?;
// First, load metadata only (fast operation)
let metadata = Document::load_metadata(&path)?;
@@ -92,6 +94,8 @@ pub fn detect_pdf_type_mem_with_config(
buffer: &[u8],
config: DetectionConfig,
) -> Result<PdfTypeResult, PdfError> {
crate::validate_pdf_bytes(buffer)?;
// Load metadata first (fast)
let metadata = Document::load_metadata_mem(buffer)?;
+4
View File
@@ -842,12 +842,14 @@ fn should_join_items(prev_item: &TextItem, curr_item: &TextItem) -> bool {
/// Extract text from PDF file as plain string
pub fn extract_text<P: AsRef<Path>>(path: P) -> Result<String, PdfError> {
crate::validate_pdf_file(&path)?;
let doc = Document::load(path)?;
extract_text_from_doc(&doc)
}
/// Extract text from PDF memory buffer
pub fn extract_text_mem(buffer: &[u8]) -> Result<String, PdfError> {
crate::validate_pdf_bytes(buffer)?;
let doc = Document::load_mem(buffer)?;
extract_text_from_doc(&doc)
}
@@ -865,6 +867,7 @@ fn extract_text_from_doc(doc: &Document) -> Result<String, PdfError> {
pub fn extract_text_with_positions<P: AsRef<Path>>(path: P) -> Result<Vec<TextItem>, PdfError> {
// Read the raw PDF bytes for ToUnicode extraction
let pdf_bytes = std::fs::read(path.as_ref())?;
crate::validate_pdf_bytes(&pdf_bytes)?;
let font_cmaps = FontCMaps::from_pdf_bytes(&pdf_bytes);
let doc = Document::load_mem(&pdf_bytes)?;
@@ -873,6 +876,7 @@ pub fn extract_text_with_positions<P: AsRef<Path>>(path: P) -> Result<Vec<TextIt
/// Extract text with positions from memory buffer
pub fn extract_text_with_positions_mem(buffer: &[u8]) -> Result<Vec<TextItem>, PdfError> {
crate::validate_pdf_bytes(buffer)?;
// Extract ToUnicode CMaps from raw PDF bytes
let font_cmaps = FontCMaps::from_pdf_bytes(buffer);
+143 -1
View File
@@ -42,6 +42,8 @@ pub struct PdfProcessResult {
pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError> {
let start = std::time::Instant::now();
validate_pdf_file(&path)?;
// Step 1: Smart detection (fast, no full load)
let detection = detect_pdf_type(&path)?;
@@ -91,6 +93,8 @@ pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError
pub fn process_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
let start = std::time::Instant::now();
validate_pdf_bytes(buffer)?;
// Step 1: Smart detection (fast, no full load)
let detection = detector::detect_pdf_type_mem(buffer)?;
@@ -142,10 +146,148 @@ pub enum PdfError {
Encrypted,
#[error("Invalid PDF structure")]
InvalidStructure,
#[error("Not a PDF: {0}")]
NotAPdf(String),
}
impl From<lopdf::Error> for PdfError {
fn from(e: lopdf::Error) -> Self {
PdfError::Parse(e.to_string())
match e {
lopdf::Error::IO(io_err) => PdfError::Io(io_err),
lopdf::Error::Decryption(_)
| lopdf::Error::InvalidPassword
| lopdf::Error::AlreadyEncrypted
| lopdf::Error::UnsupportedSecurityHandler(_) => PdfError::Encrypted,
lopdf::Error::Parse(ref pe) if pe.to_string().contains("invalid file header") => {
PdfError::NotAPdf("invalid PDF file header".to_string())
}
lopdf::Error::MissingXrefEntry
| lopdf::Error::Xref(_)
| lopdf::Error::IndirectObject { .. }
| lopdf::Error::ObjectIdMismatch
| lopdf::Error::InvalidObjectStream(_)
| lopdf::Error::InvalidOffset(_) => PdfError::InvalidStructure,
other => PdfError::Parse(other.to_string()),
}
}
}
// ---------------------------------------------------------------------------
// PDF validation helpers
// ---------------------------------------------------------------------------
/// Strip UTF-8 BOM and leading ASCII whitespace from a byte slice.
fn strip_bom_and_whitespace(bytes: &[u8]) -> &[u8] {
let b = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
&bytes[3..]
} else {
bytes
};
let start = b
.iter()
.position(|&c| !c.is_ascii_whitespace())
.unwrap_or(b.len());
&b[start..]
}
/// Case-insensitive prefix check on byte slices.
fn starts_with_ci(haystack: &[u8], needle: &[u8]) -> bool {
if haystack.len() < needle.len() {
return false;
}
haystack[..needle.len()]
.iter()
.zip(needle)
.all(|(a, b)| a.eq_ignore_ascii_case(b))
}
/// Try to identify what kind of file the bytes represent.
fn detect_file_type_hint(bytes: &[u8]) -> String {
if bytes.is_empty() {
return "file is empty".to_string();
}
let trimmed = strip_bom_and_whitespace(bytes);
// HTML
if starts_with_ci(trimmed, b"<!doctype html")
|| starts_with_ci(trimmed, b"<html")
|| starts_with_ci(trimmed, b"<head")
|| starts_with_ci(trimmed, b"<body")
{
return "file appears to be HTML".to_string();
}
// XML (but not HTML)
if trimmed.starts_with(b"<?xml") || trimmed.starts_with(b"<") {
// Distinguish generic XML from HTML-like XML
if starts_with_ci(trimmed, b"<?xml") {
return "file appears to be XML".to_string();
}
// Other tags that look like XML
if trimmed.starts_with(b"<") && !trimmed.starts_with(b"<%") {
return "file appears to be XML".to_string();
}
}
// JSON
if trimmed.starts_with(b"{") || trimmed.starts_with(b"[") {
return "file appears to be JSON".to_string();
}
// PNG
if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
return "file appears to be a PNG image".to_string();
}
// JPEG
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return "file appears to be a JPEG image".to_string();
}
// ZIP / Office documents
if bytes.starts_with(&[0x50, 0x4B, 0x03, 0x04]) {
return "file appears to be a ZIP archive (possibly an Office document)".to_string();
}
// If it looks like mostly printable ASCII/UTF-8, call it plain text
let sample = &bytes[..bytes.len().min(512)];
let printable = sample
.iter()
.filter(|&&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
.count();
if printable > sample.len() * 3 / 4 {
return "file appears to be plain text".to_string();
}
"file is not a PDF".to_string()
}
/// Validate that a byte buffer looks like a PDF (has `%PDF-` magic).
///
/// Scans the first 1024 bytes, allowing for a UTF-8 BOM and leading whitespace.
pub(crate) fn validate_pdf_bytes(buffer: &[u8]) -> Result<(), PdfError> {
if buffer.is_empty() {
return Err(PdfError::NotAPdf(detect_file_type_hint(buffer)));
}
let header = &buffer[..buffer.len().min(1024)];
let trimmed = strip_bom_and_whitespace(header);
if trimmed.starts_with(b"%PDF-") {
Ok(())
} else {
Err(PdfError::NotAPdf(detect_file_type_hint(buffer)))
}
}
/// Validate that a file on disk looks like a PDF.
///
/// Reads only the first 1024 bytes and delegates to [`validate_pdf_bytes`].
pub(crate) fn validate_pdf_file<P: AsRef<Path>>(path: P) -> Result<(), PdfError> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut buf = [0u8; 1024];
let n = file.read(&mut buf)?;
validate_pdf_bytes(&buf[..n])
}
+107 -1
View File
@@ -4,7 +4,7 @@ use pdf_inspector::detector::DetectionConfig;
use pdf_inspector::extractor::{group_into_lines, TextLine};
use pdf_inspector::{
detect_pdf_type, extract_text, extract_text_with_positions, to_markdown, MarkdownOptions,
PdfType, TextItem,
PdfError, PdfType, TextItem,
};
// Helper to create test TextItems
@@ -730,3 +730,109 @@ fn test_trailing_newline() {
assert!(md.ends_with('\n'));
assert!(!md.ends_with("\n\n"));
}
// ============================================================================
// NotAPdf Detection Tests
// ============================================================================
/// Helper: assert that an error is NotAPdf and its message contains the given substring.
fn assert_not_a_pdf(result: Result<impl std::fmt::Debug, PdfError>, expected_hint: &str) {
match result {
Err(PdfError::NotAPdf(msg)) => {
assert!(
msg.to_lowercase().contains(&expected_hint.to_lowercase()),
"Expected hint '{}' in NotAPdf message, got: '{}'",
expected_hint,
msg,
);
}
other => panic!(
"Expected Err(NotAPdf) containing '{}', got: {:?}",
expected_hint, other,
),
}
}
#[test]
fn test_not_a_pdf_html_input() {
let html = b"<!DOCTYPE html><html><body>Hello</body></html>";
let result = pdf_inspector::process_pdf_mem(html);
assert_not_a_pdf(result, "HTML");
}
#[test]
fn test_not_a_pdf_xml_input() {
let xml = b"<?xml version=\"1.0\"?><root><item>data</item></root>";
let result = pdf_inspector::process_pdf_mem(xml);
assert_not_a_pdf(result, "XML");
}
#[test]
fn test_not_a_pdf_json_input() {
let json = b"{\"error\": \"download failed\"}";
let result = pdf_inspector::process_pdf_mem(json);
assert_not_a_pdf(result, "JSON");
}
#[test]
fn test_not_a_pdf_plain_text_input() {
let text = b"This is a plain text file that is not a PDF at all.";
let result = pdf_inspector::process_pdf_mem(text);
assert_not_a_pdf(result, "plain text");
}
#[test]
fn test_not_a_pdf_empty_buffer() {
let result = pdf_inspector::process_pdf_mem(b"");
assert_not_a_pdf(result, "empty");
}
#[test]
fn test_valid_pdf_header_not_rejected() {
// A truncated but valid PDF header should NOT produce NotAPdf —
// it should fail with Parse or InvalidStructure instead.
let truncated_pdf = b"%PDF-1.4\ntruncated content";
let result = pdf_inspector::process_pdf_mem(truncated_pdf);
match result {
Err(PdfError::NotAPdf(_)) => panic!("Valid PDF header should not be rejected as NotAPdf"),
_ => {} // Parse or InvalidStructure is fine
}
}
#[test]
fn test_bom_prefixed_pdf_header_not_rejected() {
// UTF-8 BOM + %PDF- should still be recognized as a PDF
let mut bom_pdf = vec![0xEF, 0xBB, 0xBF];
bom_pdf.extend_from_slice(b"%PDF-1.7\ntruncated");
let result = pdf_inspector::process_pdf_mem(&bom_pdf);
match result {
Err(PdfError::NotAPdf(_)) => {
panic!("BOM-prefixed PDF header should not be rejected as NotAPdf")
}
_ => {} // Parse or InvalidStructure is fine
}
}
#[test]
fn test_not_a_pdf_detect_pdf_type_mem() {
// Verify detect_pdf_type_mem is also guarded
let html = b"<html><head><title>Not a PDF</title></head></html>";
let result = pdf_inspector::detector::detect_pdf_type_mem(html);
assert_not_a_pdf(result, "HTML");
}
#[test]
fn test_not_a_pdf_extract_text_with_positions_mem() {
// Verify extract_text_with_positions_mem is also guarded
let html = b"<!DOCTYPE html><html><body>content</body></html>";
let result = pdf_inspector::extractor::extract_text_with_positions_mem(html);
assert_not_a_pdf(result, "HTML");
}
#[test]
fn test_not_a_pdf_extract_text_mem() {
// Verify extract_text_mem is also guarded
let xml = b"<?xml version=\"1.0\"?><data/>";
let result = pdf_inspector::extractor::extract_text_mem(xml);
assert_not_a_pdf(result, "XML");
}