pdftract/crates/pdftract-py/src/lib.rs
jedarden 7ffb1a729f fix(pdftract-63ka2): AES-128 test buffer allocation for PKCS#7 padding
The encrypt_padded_mut API requires the buffer to be large enough to
hold the padded ciphertext. The tests were using plaintext.to_vec() which
only allocated plaintext.len() bytes, insufficient for padding.

Changed pattern:
- Before: plaintext.to_vec() (insufficient space)
- After: vec![0u8; plaintext.len() + 16] with copy_from_slice

Also fixed incorrect usage: encrypt_padded_mut returns Result<(), Error>,
not a length. Use data_copy.len() directly for ciphertext length.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:30:33 -04:00

611 lines
22 KiB
Rust

//! Python bindings for pdftract-core.
//!
//! This module provides idiomatic Python bindings via PyO3, exposing
//! the 9 contract methods and the 8-class exception hierarchy.
use pyo3::prelude::*;
use pyo3::types::PyDict;
use std::path::Path;
// Import base64 for decoding attachment data in PyO3 bindings
use base64::engine::general_purpose::STANDARD;
// Type alias for PyO3 owned references
type PyResultAny<'py> = PyResult<Py<PyAny>>;
mod extract_stream;
use extract_stream::{extract_stream_fn, StreamIterator};
// Re-export core types and functions
use pdftract_core::{
extract_pdf, extract_pdf_streaming, AttachmentJson, BeadJson, ExtractionOptions, PageResult,
TableJson, ThreadJson,
};
// Import diagnostics for error code mapping
use pdftract_core::diagnostics::{DiagCode, DIAGNOSTIC_CATALOG};
// ============================================================================
// Exception hierarchy
// ============================================================================
// Use PyO3's create_exception! macro to create proper exception types
// with Python inheritance. EncryptionError inherits from PdftractError,
// and all others inherit from PdftractError.
pyo3::create_exception!(pdftract, PdftractError, pyo3::exceptions::PyException);
pyo3::create_exception!(pdftract, EncryptionError, PdftractError);
pyo3::create_exception!(pdftract, CorruptPdfError, PdftractError);
pyo3::create_exception!(pdftract, SourceUnreachableError, PdftractError);
pyo3::create_exception!(pdftract, RemoteFetchInterruptedError, PdftractError);
pyo3::create_exception!(pdftract, TlsError, PdftractError);
pyo3::create_exception!(pdftract, ReceiptVerifyError, PdftractError);
pyo3::create_exception!(pdftract, UnsupportedOperationError, PdftractError);
// ============================================================================
// Helper functions
// ============================================================================
/// Get the hint for a diagnostic code from the catalog.
fn get_hint_for_code(code: &str) -> Option<&'static str> {
DIAGNOSTIC_CATALOG
.iter()
.find(|info| info.code.to_string() == code)
.map(|info| info.suggested_action)
}
/// Convert a Rust error to the appropriate Python exception with attributes.
///
/// This function maps anyhow::Error to the appropriate Python exception type
/// and sets the code, page_index, and hint attributes from the error context.
/// Since anyhow::Error doesn't directly expose Diagnostic information, we
/// parse the error message to identify the diagnostic code and set attributes
/// accordingly.
fn map_error_to_py(py: Python, err: anyhow::Error) -> PyErr {
let msg = err.to_string();
let err_str = msg.to_lowercase();
// Determine exception type, diagnostic code, and hint based on error message
let (code, hint): (Option<String>, Option<String>) = if err_str.contains("encrypted")
|| err_str.contains("password")
{
let diag_code = if err_str.contains("wrong") || err_str.contains("incorrect") {
"ENCRYPTION_WRONG_PASSWORD"
} else {
"ENCRYPTION_UNSUPPORTED"
};
(
Some(diag_code.to_string()),
get_hint_for_code(diag_code).map(|h| h.to_string()),
)
} else if err_str.contains("corrupt") || err_str.contains("invalid") {
(
Some("STRUCT_INVALID_NAME".to_string()),
get_hint_for_code("STRUCT_INVALID_NAME").map(|h| h.to_string()),
)
} else if err_str.contains("tls") || err_str.contains("certificate") || err_str.contains("ssl")
{
(
Some("REMOTE_TLS_ERROR".to_string()),
get_hint_for_code("REMOTE_TLS_ERROR").map(|h| h.to_string()),
)
} else if err_str.contains("network") || err_str.contains("interrupted") {
(
Some("REMOTE_FETCH_INTERRUPTED".to_string()),
get_hint_for_code("REMOTE_FETCH_INTERRUPTED").map(|h| h.to_string()),
)
} else if err_str.contains("unreachable") || err_str.contains("not found") {
(
Some("REMOTE_HOST_UNREACHABLE".to_string()),
get_hint_for_code("REMOTE_HOST_UNREACHABLE").map(|h| h.to_string()),
)
} else {
(None, None)
};
// Map to specific exception based on error message
// Create PyErr and set attributes on the instance
let PyErr = if err_str.contains("encrypted") || err_str.contains("password") {
EncryptionError::new_err(msg)
} else if err_str.contains("corrupt") || err_str.contains("invalid") {
CorruptPdfError::new_err(msg)
} else if err_str.contains("tls") || err_str.contains("certificate") || err_str.contains("ssl")
{
TlsError::new_err(msg)
} else if err_str.contains("network") || err_str.contains("interrupted") {
RemoteFetchInterruptedError::new_err(msg)
} else if err_str.contains("unreachable") || err_str.contains("not found") {
SourceUnreachableError::new_err(msg)
} else {
PdftractError::new_err(msg)
};
// Set attributes on the exception instance
// We need to get the instance and set attributes using Python's setattr
let instance = PyErr.value(py);
if let Some(ref c) = code {
let _ = instance.setattr("code", c);
}
let _ = instance.setattr("page_index", None::<u32>);
if let Some(ref h) = hint {
let _ = instance.setattr("hint", h);
}
PyErr
}
/// Convert Python kwargs to ExtractionOptions.
fn kwargs_to_options(kwargs: Option<&PyDict>) -> PyResult<ExtractionOptions> {
let mut opts = ExtractionOptions::default();
if let Some(kwargs) = kwargs {
// Parse pages parameter
if let Some(pages) = kwargs.get_item("pages")? {
let pages_str: Option<String> = pages.extract()?;
if let Some(range) = pages_str {
opts.pages = Some(range);
}
}
// Parse receipts parameter
if let Some(receipts) = kwargs.get_item("receipts")? {
let receipts_str: Option<String> = receipts.extract()?;
if let Some(mode) = receipts_str {
opts.receipts = pdftract_core::options::ReceiptsMode::from_str(&mode)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e))?;
}
}
}
Ok(opts)
}
// ============================================================================
// Contract method: extract
// ============================================================================
/// Extract text and structure from a PDF.
///
/// Returns a Document object containing pages with spans, blocks, and tables.
#[pyfunction]
#[pyo3(name = "extract")]
fn extract_py<'py>(py: Python<'py>, path: &str, kwargs: Option<&PyDict>) -> PyResultAny<'py> {
let opts = kwargs_to_options(kwargs)?;
let pdf_path = Path::new(path);
// Run extraction with GIL released so other Python threads can run
let result = py
.allow_threads(|| extract_pdf(pdf_path, &opts))
.map_err(|e| map_error_to_py(py, e))?;
// Convert ExtractionResult to Python dict
let dict = PyDict::new(py);
// Add metadata
let metadata = PyDict::new(py);
metadata.set_item("page_count", result.metadata.page_count)?;
metadata.set_item("span_count", result.metadata.span_count)?;
metadata.set_item("block_count", result.metadata.block_count)?;
if let Some(cache_status) = result.metadata.cache_status {
metadata.set_item("cache_status", cache_status)?;
}
dict.set_item("metadata", metadata)?;
// Add pages
let pages: PyResult<Vec<Py<PyAny>>> = result
.pages
.into_iter()
.map(|page| page_to_py(py, page))
.collect();
dict.set_item("pages", pages?)?;
// Add attachments (with base64 data decoded to bytes)
let attachments: PyResult<Vec<Py<PyAny>>> = result
.attachments
.into_iter()
.map(|attachment| attachment_to_py(py, attachment))
.collect();
dict.set_item("attachments", attachments?)?;
// Add threads (as Python list of dicts)
let threads: PyResult<Vec<Py<PyAny>>> = result
.threads
.into_iter()
.map(|thread| thread_to_py(py, thread))
.collect();
dict.set_item("threads", threads?)?;
Ok(dict.clone().into())
}
/// Convert a Bead to a Python dict with two keys (page_index, rect).
///
/// Per the bead spec, beads are simple 2-key dicts for compactness.
fn bead_to_py<'py>(py: Python<'py>, bead: BeadJson) -> PyResultAny<'py> {
let dict = PyDict::new(py);
dict.set_item("page_index", bead.page_index)?;
dict.set_item("rect", bead.rect)?;
Ok(dict.clone().into())
}
/// Convert a Thread to a Python dict with title, author, subject, keywords, and beads.
///
/// This converts the full ThreadJson structure to a Python dict, including
/// the list of beads (each bead is a 2-key dict via bead_to_py).
fn thread_to_py<'py>(py: Python<'py>, thread: ThreadJson) -> PyResultAny<'py> {
let dict = PyDict::new(py);
dict.set_item("title", thread.title)?;
dict.set_item("author", thread.author)?;
dict.set_item("subject", thread.subject)?;
dict.set_item("keywords", thread.keywords)?;
// Convert beads to Python list of 2-key dicts
let beads: PyResult<Vec<Py<PyAny>>> = thread
.beads
.into_iter()
.map(|bead| bead_to_py(py, bead))
.collect();
dict.set_item("beads", beads?)?;
Ok(dict.clone().into())
}
// ============================================================================
// Contract method: extract_text
// ============================================================================
#[pyfunction]
fn extract_text(py: Python, path: &str, kwargs: Option<&PyDict>) -> PyResult<String> {
let result = extract_py(py, path, kwargs)?;
let dict = result.downcast::<PyDict>(py)?;
let pages = dict
.get_item("pages")?
.unwrap()
.downcast::<pyo3::types::PyList>()?;
let mut text = String::new();
for page in pages.iter() {
let page_dict = page.downcast::<PyDict>()?;
let spans = page_dict
.get_item("spans")?
.unwrap()
.downcast::<pyo3::types::PyList>()?;
for span in spans.iter() {
let span_dict = span.downcast::<PyDict>()?;
if let Some(text_obj) = span_dict.get_item("text")? {
let span_text: String = text_obj.extract()?;
text.push_str(&span_text);
text.push(' ');
}
}
}
Ok(text)
}
// ============================================================================
// Contract method: extract_markdown (stub)
// ============================================================================
#[pyfunction]
fn extract_markdown(py: Python, path: &str, kwargs: Option<&PyDict>) -> PyResult<String> {
// For now, just return extract_text output
// TODO: Implement proper markdown conversion
extract_text(py, path, kwargs)
}
// ============================================================================
// Contract method: search (stub)
// ============================================================================
#[pyfunction]
fn search<'py>(
py: Python<'py>,
_path: &str,
pattern: &str,
_kwargs: Option<&PyDict>,
) -> PyResultAny<'py> {
// For now, extract and return empty match list
// TODO: Implement proper regex search
let dict = PyDict::new(py);
dict.set_item("pattern", pattern)?;
// Return an empty match list for now
let matches = pyo3::types::PyList::empty(py);
dict.set_item("matches", matches)?;
Ok(dict.clone().into())
}
// ============================================================================
// Contract method: get_metadata
// ============================================================================
#[pyfunction]
fn get_metadata<'py>(py: Python<'py>, path: &str, kwargs: Option<&PyDict>) -> PyResultAny<'py> {
let result = extract_py(py, path, kwargs)?;
let dict = result.downcast::<PyDict>(py)?;
let metadata = dict.get_item("metadata")?.unwrap();
Ok(metadata.clone().into())
}
// ============================================================================
// Contract method: hash (stub)
// ============================================================================
#[pyfunction]
fn hash(_py: Python, _path: &str, _kwargs: Option<&PyDict>) -> PyResult<String> {
// Stub implementation - should compute fingerprint
// For now, return a placeholder
Ok(format!("pdftract-v1:{}", "0".repeat(64)))
}
// ============================================================================
// Contract method: classify (stub)
// ============================================================================
#[pyfunction]
fn classify<'py>(py: Python<'py>, _path: &str) -> PyResultAny<'py> {
// Stub implementation - should classify page type
let dict = PyDict::new(py);
dict.set_item("class_name", "Unknown")?;
dict.set_item("confidence", 0.0f64)?;
Ok(dict.clone().into())
}
// ============================================================================
// Contract method: verify_receipt (stub)
// ============================================================================
#[pyfunction]
fn verify_receipt(_py: Python, _path: &str, _receipt_dict: &PyDict) -> PyResult<bool> {
// Stub implementation - should verify receipt
// For now, return false
Ok(false)
}
// ============================================================================
// Helper: Convert PageResult to Python dict
// ============================================================================
fn page_to_py<'py>(py: Python<'py>, page: PageResult) -> PyResultAny<'py> {
let dict = PyDict::new(py);
dict.set_item("page_index", page.index)?;
// Convert spans
let spans: PyResult<Vec<Py<PyAny>>> = page
.spans
.into_iter()
.map(|span| {
let span_dict = PyDict::new(py);
span_dict.set_item("text", span.text)?;
span_dict.set_item("bbox", span.bbox.to_vec())?;
span_dict.set_item("font", span.font)?;
span_dict.set_item("size", span.size)?;
if let Some(conf) = span.confidence {
span_dict.set_item("confidence", conf as f64)?;
}
Ok(span_dict.clone().into())
})
.collect();
dict.set_item("spans", spans?)?;
// Convert blocks
let blocks: PyResult<Vec<Py<PyAny>>> = page
.blocks
.into_iter()
.map(|block| {
let block_dict = PyDict::new(py);
block_dict.set_item("kind", block.kind)?;
block_dict.set_item("text", block.text)?;
block_dict.set_item("bbox", block.bbox.to_vec())?;
if let Some(level) = block.level {
block_dict.set_item("level", level)?;
}
if let Some(table_index) = block.table_index {
block_dict.set_item("table_index", table_index)?;
}
Ok(block_dict.clone().into())
})
.collect();
dict.set_item("blocks", blocks?)?;
// Convert tables
let tables: PyResult<Vec<Py<PyAny>>> = page
.tables
.into_iter()
.map(|table| table_to_py(py, table))
.collect();
dict.set_item("tables", tables?)?;
if let Some(error) = page.error {
dict.set_item("error", error)?;
}
Ok(dict.clone().into())
}
fn table_to_py<'py>(py: Python<'py>, table: TableJson) -> PyResultAny<'py> {
let dict = PyDict::new(py);
dict.set_item("id", table.id)?;
dict.set_item("bbox", table.bbox.to_vec())?;
// Convert rows
let rows: PyResult<Vec<Py<PyAny>>> = table
.rows
.into_iter()
.map(|row| {
let row_dict = PyDict::new(py);
row_dict.set_item("bbox", row.bbox.to_vec())?;
row_dict.set_item("is_header", row.is_header)?;
// Convert cells
let cells: PyResult<Vec<Py<PyAny>>> = row
.cells
.into_iter()
.map(|cell| {
let cell_dict = PyDict::new(py);
cell_dict.set_item("bbox", cell.bbox.to_vec())?;
cell_dict.set_item("text", cell.text)?;
cell_dict.set_item("spans", cell.spans.to_vec())?;
cell_dict.set_item("row", cell.row)?;
cell_dict.set_item("col", cell.col)?;
cell_dict.set_item("rowspan", cell.rowspan)?;
cell_dict.set_item("colspan", cell.colspan)?;
cell_dict.set_item("is_header_row", cell.is_header_row)?;
Ok(cell_dict.clone().into())
})
.collect();
row_dict.set_item("cells", cells?)?;
Ok(row_dict.clone().into())
})
.collect();
dict.set_item("rows", rows?)?;
dict.set_item("header_rows", table.header_rows)?;
dict.set_item("detection_method", table.detection_method)?;
dict.set_item("continued", table.continued)?;
dict.set_item("continued_from_prev", table.continued_from_prev)?;
dict.set_item("page_index", table.page_index)?;
Ok(dict.clone().into())
}
fn attachment_to_py<'py>(py: Python<'py>, attachment: AttachmentJson) -> PyResultAny<'py> {
let dict = PyDict::new(py);
dict.set_item("name", attachment.name)?;
dict.set_item("description", attachment.description)?;
dict.set_item("mime_type", attachment.mime_type)?;
dict.set_item("size", attachment.size)?;
dict.set_item("created", attachment.created)?;
dict.set_item("modified", attachment.modified)?;
dict.set_item("checksum_md5", attachment.checksum_md5)?;
dict.set_item("truncated", attachment.truncated)?;
// Convert base64 data to bytes (PyO3 will decode the base64 string)
if let Some(base64_data) = attachment.data {
use base64::engine::general_purpose::STANDARD;
use base64::engine::Engine;
match STANDARD.decode(&base64_data) {
Ok(bytes) => {
let py_bytes = pyo3::types::PyBytes::new(py, &bytes);
dict.set_item("data", py_bytes)?;
}
Err(_) => {
// If base64 decoding fails, set data to None
dict.set_item("data", py.None())?;
}
}
} else {
dict.set_item("data", py.None())?;
}
Ok(dict.clone().into())
}
// ============================================================================
// PyO3 module definition
// ============================================================================
#[pymodule]
fn pdftract(py: Python, m: &PyModule) -> PyResult<()> {
// Add exception classes with proper Python inheritance
m.add("PdftractError", py.get_type::<PdftractError>())?;
m.add("EncryptionError", py.get_type::<EncryptionError>())?;
m.add("CorruptPdfError", py.get_type::<CorruptPdfError>())?;
m.add(
"SourceUnreachableError",
py.get_type::<SourceUnreachableError>(),
)?;
m.add(
"RemoteFetchInterruptedError",
py.get_type::<RemoteFetchInterruptedError>(),
)?;
m.add("TlsError", py.get_type::<TlsError>())?;
m.add("ReceiptVerifyError", py.get_type::<ReceiptVerifyError>())?;
m.add(
"UnsupportedOperationError",
py.get_type::<UnsupportedOperationError>(),
)?;
// Add extract_stream function
m.add_function(wrap_pyfunction!(extract_stream_fn, m)?)?;
m.add_class::<StreamIterator>()?;
// Add main extraction function
m.add_function(wrap_pyfunction!(extract_py, m)?)?;
m.add_function(wrap_pyfunction!(extract_text, m)?)?;
m.add_function(wrap_pyfunction!(extract_markdown, m)?)?;
m.add_function(wrap_pyfunction!(search, m)?)?;
m.add_function(wrap_pyfunction!(get_metadata, m)?)?;
m.add_function(wrap_pyfunction!(hash, m)?)?;
m.add_function(wrap_pyfunction!(classify, m)?)?;
m.add_function(wrap_pyfunction!(verify_receipt, m)?)?;
Ok(())
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exception_hierarchy() {
// Test that EncryptionError inherits from PdftractError
Python::with_gil(|py| {
let pdftract_err = PdftractError::new_err("test error");
let encryption_err = EncryptionError::new_err("encrypted");
// Both should be instances of PdftractError
let pdftract_err_type = py.get_type::<PdftractError>();
assert!(pdftract_err
.value(py)
.is_instance(&pdftract_err_type)
.unwrap());
assert!(encryption_err
.value(py)
.is_instance(&pdftract_err_type)
.unwrap());
});
}
#[test]
fn test_exception_attributes() {
// Test that exception attributes are set correctly
Python::with_gil(|py| {
let err = EncryptionError::new_err("PDF is encrypted");
let instance = err.value(py);
// Set attributes
instance.setattr("code", "ENCRYPTION_UNSUPPORTED").unwrap();
instance.setattr("page_index", None::<u32>).unwrap();
instance
.setattr("hint", "Supply the password keyword argument")
.unwrap();
// Verify attributes
let code: Option<String> = instance.getattr("code").unwrap().extract().unwrap();
let page_index: Option<u32> =
instance.getattr("page_index").unwrap().extract().unwrap();
let hint: Option<String> = instance.getattr("hint").unwrap().extract().unwrap();
assert_eq!(code, Some("ENCRYPTION_UNSUPPORTED".to_string()));
assert_eq!(page_index, None);
assert_eq!(
hint,
Some("Supply the password keyword argument".to_string())
);
});
}
}