Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0159910b5f | ||
|
|
661233628c |
@@ -208,6 +208,7 @@ fn main() {
|
||||
eprintln!(" --raw Output only markdown (no headers)");
|
||||
eprintln!(" --pages Insert page break markers (<!-- Page N -->)");
|
||||
eprintln!(" --select-pages N Only process specified pages (e.g. 1,3,5-10)");
|
||||
eprintln!(" --password PW Password for an encrypted PDF");
|
||||
eprintln!(" --detect-only Only detect PDF type (no extraction)");
|
||||
eprintln!(" --analyze Detect + extract + layout analysis (no markdown)");
|
||||
process::exit(1);
|
||||
@@ -221,6 +222,16 @@ fn main() {
|
||||
let detect_only = args.iter().any(|a| a == "--detect-only");
|
||||
let analyze = args.iter().any(|a| a == "--analyze");
|
||||
|
||||
// Parse --password value
|
||||
let password = args.iter().position(|a| a == "--password").map(|i| {
|
||||
args.get(i + 1)
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Error: --password requires a value");
|
||||
process::exit(1);
|
||||
})
|
||||
.clone()
|
||||
});
|
||||
|
||||
// Parse --select-pages value
|
||||
let page_filter = args
|
||||
.iter()
|
||||
@@ -269,6 +280,7 @@ fn main() {
|
||||
if let Some(pages) = page_filter {
|
||||
options.page_filter = Some(pages);
|
||||
}
|
||||
options.password = password;
|
||||
|
||||
match process_pdf_with_options(pdf_path, options) {
|
||||
Ok(result) => {
|
||||
|
||||
+70
-10
@@ -125,7 +125,7 @@ pub struct PdfProcessResult {
|
||||
/// .mode(ProcessMode::Analyze)
|
||||
/// .pages([1, 3, 5]);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct PdfOptions {
|
||||
/// How far the pipeline should run (default: [`ProcessMode::Full`]).
|
||||
pub mode: ProcessMode,
|
||||
@@ -135,6 +135,23 @@ pub struct PdfOptions {
|
||||
pub markdown: MarkdownOptions,
|
||||
/// Optional set of 1-indexed pages to process. `None` = all pages.
|
||||
pub page_filter: Option<HashSet<u32>>,
|
||||
/// Password for decrypting an encrypted PDF. `None` falls back to the
|
||||
/// empty password (owner-only encryption).
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
// Manual `Debug` so the password is never leaked through debug logging or a
|
||||
// panic that formats the options; it renders as `Some("[REDACTED]")`.
|
||||
impl std::fmt::Debug for PdfOptions {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PdfOptions")
|
||||
.field("mode", &self.mode)
|
||||
.field("detection", &self.detection)
|
||||
.field("markdown", &self.markdown)
|
||||
.field("page_filter", &self.page_filter)
|
||||
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PdfOptions {
|
||||
@@ -144,6 +161,7 @@ impl Default for PdfOptions {
|
||||
detection: DetectionConfig::default(),
|
||||
markdown: MarkdownOptions::default(),
|
||||
page_filter: None,
|
||||
password: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +203,12 @@ impl PdfOptions {
|
||||
self.page_filter = Some(pages.into_iter().collect());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the password used to decrypt an encrypted PDF.
|
||||
pub fn password(mut self, password: impl Into<String>) -> Self {
|
||||
self.password = Some(password.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -217,7 +241,8 @@ pub fn process_pdf_with_options<P: AsRef<Path>>(
|
||||
validate_pdf_file(&path)?;
|
||||
|
||||
// Load the document once — shared by detection AND extraction.
|
||||
let (doc, page_count) = load_document_from_path(&path)?;
|
||||
let (doc, page_count) =
|
||||
load_document_from_path_with_password(&path, options.password.as_deref())?;
|
||||
|
||||
process_document(doc, page_count, options, start)
|
||||
}
|
||||
@@ -242,7 +267,8 @@ pub fn process_pdf_mem_with_options(
|
||||
let start = std::time::Instant::now();
|
||||
validate_pdf_bytes(buffer)?;
|
||||
|
||||
let (doc, page_count) = load_document_from_mem(buffer)?;
|
||||
let (doc, page_count) =
|
||||
load_document_from_mem_with_password(buffer, options.password.as_deref())?;
|
||||
|
||||
process_document(doc, page_count, options, start)
|
||||
}
|
||||
@@ -3267,24 +3293,40 @@ fn tsr_region_contains_item(item: &TextItem, bounds: RegionBounds) -> bool {
|
||||
/// page count from it directly to avoid the metadata-only round-trip.
|
||||
pub(crate) fn load_document_from_path<P: AsRef<Path>>(
|
||||
path: P,
|
||||
) -> Result<(Document, u32), PdfError> {
|
||||
load_document_from_path_with_password(path, None)
|
||||
}
|
||||
|
||||
/// Load a PDF file, decrypting with `password` if the file is encrypted.
|
||||
pub(crate) fn load_document_from_path_with_password<P: AsRef<Path>>(
|
||||
path: P,
|
||||
password: Option<&str>,
|
||||
) -> Result<(Document, u32), PdfError> {
|
||||
let buffer = std::fs::read(&path)?;
|
||||
load_document_from_mem(&buffer)
|
||||
load_document_from_mem_with_password(&buffer, password)
|
||||
}
|
||||
|
||||
/// Load a PDF from a memory buffer.
|
||||
pub(crate) fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), PdfError> {
|
||||
load_document_from_mem_with_password(buffer, None)
|
||||
}
|
||||
|
||||
/// Load a PDF from a memory buffer, decrypting with `password` if encrypted.
|
||||
pub(crate) fn load_document_from_mem_with_password(
|
||||
buffer: &[u8],
|
||||
password: Option<&str>,
|
||||
) -> Result<(Document, u32), PdfError> {
|
||||
// Fix malformed struct element names before parsing. Some PDF generators
|
||||
// write bare names (/S Code) instead of proper PDF names (/S /Code), which
|
||||
// causes lopdf to silently drop the entire object.
|
||||
let fixed = structure_tree::fix_bare_struct_names(buffer);
|
||||
let buf = fixed.as_ref();
|
||||
|
||||
let doc = match load_document_bytes(buf) {
|
||||
let doc = match load_document_bytes(buf, password) {
|
||||
Ok(doc) => doc,
|
||||
Err(first_err) => {
|
||||
for repaired in repair_pdf_container_candidates(buf) {
|
||||
match load_document_bytes(&repaired) {
|
||||
match load_document_bytes(&repaired, password) {
|
||||
Ok(doc) => {
|
||||
log::debug!("loaded PDF after repairing malformed container bytes");
|
||||
let page_count = doc.get_pages().len() as u32;
|
||||
@@ -3304,16 +3346,34 @@ pub(crate) fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), P
|
||||
Ok((doc, page_count))
|
||||
}
|
||||
|
||||
fn load_document_bytes(buf: &[u8]) -> Result<Document, lopdf::Error> {
|
||||
fn load_document_bytes(buf: &[u8], password: Option<&str>) -> Result<Document, lopdf::Error> {
|
||||
match Document::load_mem(buf) {
|
||||
// Some encrypted PDFs load structurally but leave their streams
|
||||
// encrypted (`is_encrypted()` stays true); reading them yields garbage
|
||||
// until we re-load with a password. Others fail load_mem outright with
|
||||
// an encryption error. Handle both by re-loading with the password.
|
||||
Ok(doc) if doc.is_encrypted() => decrypt_document_bytes(buf, password),
|
||||
Ok(doc) => Ok(doc),
|
||||
Err(ref e) if is_encrypted_lopdf_error(e) => {
|
||||
Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(""))
|
||||
}
|
||||
Err(ref e) if is_encrypted_lopdf_error(e) => decrypt_document_bytes(buf, password),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-load an encrypted PDF, decrypting with `password`. Falls back to the
|
||||
/// empty password (owner-only encryption, the common "protected" case) when a
|
||||
/// non-empty password was supplied but rejected.
|
||||
fn decrypt_document_bytes(buf: &[u8], password: Option<&str>) -> Result<Document, lopdf::Error> {
|
||||
let pw = password.unwrap_or("");
|
||||
match Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(pw)) {
|
||||
Ok(doc) => Ok(doc),
|
||||
Err(inner) if !pw.is_empty() => {
|
||||
Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(""))
|
||||
.map_err(|_| inner)
|
||||
}
|
||||
Err(inner) => Err(inner),
|
||||
}
|
||||
}
|
||||
|
||||
fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
|
||||
BIN
Binary file not shown.
@@ -3606,3 +3606,45 @@ fn test_markdown_options_default_has_include_images_false() {
|
||||
let opts = MarkdownOptions::default();
|
||||
assert!(!opts.include_images);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_pdf_decrypts_with_correct_password() {
|
||||
let path = "tests/fixtures/encrypted-secret123.pdf";
|
||||
|
||||
// No password: the file is encrypted and can't be read.
|
||||
let no_pw = process_pdf_with_options(path, PdfOptions::new());
|
||||
assert!(
|
||||
matches!(no_pw, Err(PdfError::Encrypted)),
|
||||
"expected Encrypted without a password, got {no_pw:?}"
|
||||
);
|
||||
|
||||
// Wrong password: still rejected.
|
||||
let wrong = process_pdf_with_options(path, PdfOptions::new().password("wrong"));
|
||||
assert!(
|
||||
matches!(wrong, Err(PdfError::Encrypted)),
|
||||
"expected Encrypted with a wrong password, got {wrong:?}"
|
||||
);
|
||||
|
||||
// Correct password: decrypts and extracts real content.
|
||||
let ok = process_pdf_with_options(path, PdfOptions::new().password("secret123"))
|
||||
.expect("correct password should decrypt");
|
||||
let md = ok.markdown.unwrap_or_default();
|
||||
// Assert a stable fixture token so a garbled-but-long extraction (the
|
||||
// encrypted-stream regression this guards) still fails the test.
|
||||
assert!(
|
||||
md.contains("Procurement"),
|
||||
"decrypted markdown should contain the fixture's real text, got {} chars",
|
||||
md.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_options_debug_redacts_password() {
|
||||
let opts = PdfOptions::new().password("secret123");
|
||||
let dbg = format!("{opts:?}");
|
||||
assert!(
|
||||
!dbg.contains("secret123"),
|
||||
"password leaked in Debug: {dbg}"
|
||||
);
|
||||
assert!(dbg.contains("REDACTED"), "expected redaction marker: {dbg}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user