Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0159910b5f | ||
|
|
661233628c |
@@ -208,6 +208,7 @@ fn main() {
|
|||||||
eprintln!(" --raw Output only markdown (no headers)");
|
eprintln!(" --raw Output only markdown (no headers)");
|
||||||
eprintln!(" --pages Insert page break markers (<!-- Page N -->)");
|
eprintln!(" --pages Insert page break markers (<!-- Page N -->)");
|
||||||
eprintln!(" --select-pages N Only process specified pages (e.g. 1,3,5-10)");
|
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!(" --detect-only Only detect PDF type (no extraction)");
|
||||||
eprintln!(" --analyze Detect + extract + layout analysis (no markdown)");
|
eprintln!(" --analyze Detect + extract + layout analysis (no markdown)");
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
@@ -221,6 +222,16 @@ fn main() {
|
|||||||
let detect_only = args.iter().any(|a| a == "--detect-only");
|
let detect_only = args.iter().any(|a| a == "--detect-only");
|
||||||
let analyze = args.iter().any(|a| a == "--analyze");
|
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
|
// Parse --select-pages value
|
||||||
let page_filter = args
|
let page_filter = args
|
||||||
.iter()
|
.iter()
|
||||||
@@ -269,6 +280,7 @@ fn main() {
|
|||||||
if let Some(pages) = page_filter {
|
if let Some(pages) = page_filter {
|
||||||
options.page_filter = Some(pages);
|
options.page_filter = Some(pages);
|
||||||
}
|
}
|
||||||
|
options.password = password;
|
||||||
|
|
||||||
match process_pdf_with_options(pdf_path, options) {
|
match process_pdf_with_options(pdf_path, options) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
|
|||||||
+70
-10
@@ -125,7 +125,7 @@ pub struct PdfProcessResult {
|
|||||||
/// .mode(ProcessMode::Analyze)
|
/// .mode(ProcessMode::Analyze)
|
||||||
/// .pages([1, 3, 5]);
|
/// .pages([1, 3, 5]);
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PdfOptions {
|
pub struct PdfOptions {
|
||||||
/// How far the pipeline should run (default: [`ProcessMode::Full`]).
|
/// How far the pipeline should run (default: [`ProcessMode::Full`]).
|
||||||
pub mode: ProcessMode,
|
pub mode: ProcessMode,
|
||||||
@@ -135,6 +135,23 @@ pub struct PdfOptions {
|
|||||||
pub markdown: MarkdownOptions,
|
pub markdown: MarkdownOptions,
|
||||||
/// Optional set of 1-indexed pages to process. `None` = all pages.
|
/// Optional set of 1-indexed pages to process. `None` = all pages.
|
||||||
pub page_filter: Option<HashSet<u32>>,
|
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 {
|
impl Default for PdfOptions {
|
||||||
@@ -144,6 +161,7 @@ impl Default for PdfOptions {
|
|||||||
detection: DetectionConfig::default(),
|
detection: DetectionConfig::default(),
|
||||||
markdown: MarkdownOptions::default(),
|
markdown: MarkdownOptions::default(),
|
||||||
page_filter: None,
|
page_filter: None,
|
||||||
|
password: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,6 +203,12 @@ impl PdfOptions {
|
|||||||
self.page_filter = Some(pages.into_iter().collect());
|
self.page_filter = Some(pages.into_iter().collect());
|
||||||
self
|
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)?;
|
validate_pdf_file(&path)?;
|
||||||
|
|
||||||
// Load the document once — shared by detection AND extraction.
|
// 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)
|
process_document(doc, page_count, options, start)
|
||||||
}
|
}
|
||||||
@@ -242,7 +267,8 @@ pub fn process_pdf_mem_with_options(
|
|||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
validate_pdf_bytes(buffer)?;
|
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)
|
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.
|
/// page count from it directly to avoid the metadata-only round-trip.
|
||||||
pub(crate) fn load_document_from_path<P: AsRef<Path>>(
|
pub(crate) fn load_document_from_path<P: AsRef<Path>>(
|
||||||
path: P,
|
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> {
|
) -> Result<(Document, u32), PdfError> {
|
||||||
let buffer = std::fs::read(&path)?;
|
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.
|
/// Load a PDF from a memory buffer.
|
||||||
pub(crate) fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), PdfError> {
|
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
|
// Fix malformed struct element names before parsing. Some PDF generators
|
||||||
// write bare names (/S Code) instead of proper PDF names (/S /Code), which
|
// write bare names (/S Code) instead of proper PDF names (/S /Code), which
|
||||||
// causes lopdf to silently drop the entire object.
|
// causes lopdf to silently drop the entire object.
|
||||||
let fixed = structure_tree::fix_bare_struct_names(buffer);
|
let fixed = structure_tree::fix_bare_struct_names(buffer);
|
||||||
let buf = fixed.as_ref();
|
let buf = fixed.as_ref();
|
||||||
|
|
||||||
let doc = match load_document_bytes(buf) {
|
let doc = match load_document_bytes(buf, password) {
|
||||||
Ok(doc) => doc,
|
Ok(doc) => doc,
|
||||||
Err(first_err) => {
|
Err(first_err) => {
|
||||||
for repaired in repair_pdf_container_candidates(buf) {
|
for repaired in repair_pdf_container_candidates(buf) {
|
||||||
match load_document_bytes(&repaired) {
|
match load_document_bytes(&repaired, password) {
|
||||||
Ok(doc) => {
|
Ok(doc) => {
|
||||||
log::debug!("loaded PDF after repairing malformed container bytes");
|
log::debug!("loaded PDF after repairing malformed container bytes");
|
||||||
let page_count = doc.get_pages().len() as u32;
|
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))
|
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) {
|
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),
|
Ok(doc) => Ok(doc),
|
||||||
Err(ref e) if is_encrypted_lopdf_error(e) => {
|
Err(ref e) if is_encrypted_lopdf_error(e) => decrypt_document_bytes(buf, password),
|
||||||
Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(""))
|
|
||||||
}
|
|
||||||
Err(e) => Err(e),
|
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>> {
|
fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
|
||||||
let mut candidates = Vec::new();
|
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();
|
let opts = MarkdownOptions::default();
|
||||||
assert!(!opts.include_images);
|
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