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:
co-authored by
Claude Opus 4.6
parent
4796ccd634
commit
fb4d168882
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user