use rayon::prelude::*;
use regex::Regex;
use std::collections::HashMap;
use std::fmt;
use std::sync::OnceLock;
mod document;
pub mod geom;
mod markdown;
mod meta;
mod positioned;
mod scorer;
pub mod search;
mod structure;
mod tables;
#[doc(hidden)]
pub mod test_only_markdown_helpers {
pub use crate::markdown::{
build_heading_size_map, detect_numbered_prefix_level, detect_structural_heading,
merge_adjacent_heading_blocks, normalize_heading_lookup, render_styled_line,
};
}
pub(crate) use scorer::score_text_impl;
pub use document::{
DecodedImage, Document, FormField, FormFieldType, ImageContainer, Widget,
};
pub use positioned::{PositionedPage, TextBlock, TextLine, TextSpan, Word};
pub use search::{SearchHit, SearchOptions};
pub use structure::{Annotation, DocumentInfo, ImageInfo, Link, PageInfo, TocEntry};
#[cfg(feature = "python")]
mod python;
pub const MAX_PAGES: usize = 10_000;
pub const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024;
pub const MAX_TABLES: usize = 10_000;
#[derive(Debug)]
#[non_exhaustive]
pub enum ExtractError {
LoadFailed(String),
ParseFailed(String),
PageExtractFailed {
page: u32,
source: String,
},
LimitExceeded {
limit: &'static str,
actual: usize,
max: usize,
},
Encrypted,
}
impl fmt::Display for ExtractError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LoadFailed(msg) => write!(f, "Failed to load PDF: {msg}"),
Self::ParseFailed(msg) => write!(f, "Extraction failed: {msg}"),
Self::PageExtractFailed { page, source } => {
write!(f, "Failed to extract page {page}: {source}")
}
Self::LimitExceeded { limit, actual, max } => {
write!(f, "{limit} limit exceeded: {actual} > {max}")
}
Self::Encrypted => write!(
f,
"PDF is encrypted and the supplied password did not authenticate"
),
}
}
}
impl std::error::Error for ExtractError {}
pub fn extract_text(pdf_bytes: &[u8]) -> Result<String, ExtractError> {
let pages = extract_pages(pdf_bytes)?;
let join_overhead = pages.len().saturating_mul(2);
let projected = pages
.iter()
.map(|p| p.len())
.sum::<usize>()
.saturating_add(join_overhead);
if projected > MAX_OUTPUT_BYTES {
return Err(ExtractError::LimitExceeded {
limit: "output bytes",
actual: projected,
max: MAX_OUTPUT_BYTES,
});
}
Ok(normalize_text(&pages.join("\n\n")))
}
pub fn extract_pages(pdf_bytes: &[u8]) -> Result<Vec<String>, ExtractError> {
let (doc, pages) = open_strict(pdf_bytes)?;
pages
.iter()
.map(|n| {
doc.extract_text(&[*n])
.map_err(|e| ExtractError::PageExtractFailed {
page: *n,
source: e.to_string(),
})
})
.collect()
}
pub fn extract_pages_lenient(pdf_bytes: &[u8]) -> Result<Vec<String>, ExtractError> {
let (doc, pages) = open_strict(pdf_bytes)?;
Ok(pages
.iter()
.map(|n| doc.extract_text(&[*n]).unwrap_or_default())
.collect())
}
fn open_strict(pdf_bytes: &[u8]) -> Result<(spectre_parse::Document, Vec<u32>), ExtractError> {
let doc = crate::document::open_sp_with_password(pdf_bytes, b"")?;
let mut pages: Vec<u32> = doc.get_pages().keys().copied().collect();
if pages.len() > MAX_PAGES {
return Err(ExtractError::LimitExceeded {
limit: "pages",
actual: pages.len(),
max: MAX_PAGES,
});
}
pages.sort();
Ok((doc, pages))
}
pub fn extract_text_lenient(pdf_bytes: &[u8]) -> Result<String, ExtractError> {
let pages = extract_pages_lenient(pdf_bytes)?;
let join_overhead = pages.len().saturating_mul(2);
let projected = pages
.iter()
.map(|p| p.len())
.sum::<usize>()
.saturating_add(join_overhead);
if projected > MAX_OUTPUT_BYTES {
return Err(ExtractError::LimitExceeded {
limit: "output bytes",
actual: projected,
max: MAX_OUTPUT_BYTES,
});
}
Ok(normalize_text(&pages.join("\n\n")))
}
pub fn extract_tables(
pdf_bytes: &[u8],
page: Option<u32>,
) -> Result<Vec<Vec<Vec<String>>>, ExtractError> {
let page_num = page.map(|p| p.saturating_sub(1) as usize);
let tables = tables::extract_tables_impl(pdf_bytes, page_num)?;
if tables.len() > MAX_TABLES {
return Err(ExtractError::LimitExceeded {
limit: "tables",
actual: tables.len(),
max: MAX_TABLES,
});
}
Ok(tables)
}
pub fn extract_metadata(pdf_bytes: &[u8]) -> Result<HashMap<String, String>, ExtractError> {
meta::extract_metadata_impl(pdf_bytes).map_err(ExtractError::ParseFailed)
}
pub fn score_text(text: &str) -> f64 {
score_text_impl(text)
}
pub fn score_batch(texts: &[String]) -> Vec<f64> {
texts.par_iter().map(|t| score_text_impl(t)).collect()
}
pub fn extract_text_positioned(
pdf_bytes: &[u8],
page: Option<u32>,
) -> Result<Vec<PositionedPage>, ExtractError> {
positioned::extract_text_positioned_impl(pdf_bytes, page)
}
pub fn extract_words(
pdf_bytes: &[u8],
page: Option<u32>,
) -> Result<Vec<Word>, ExtractError> {
positioned::extract_words_impl(pdf_bytes, page)
}
pub fn extract_blocks(
pdf_bytes: &[u8],
page: Option<u32>,
) -> Result<Vec<TextBlock>, ExtractError> {
positioned::extract_blocks_impl(pdf_bytes, page)
}
pub fn search(
pdf_bytes: &[u8],
query: &str,
page: Option<u32>,
options: Option<SearchOptions>,
) -> Result<Vec<SearchHit>, ExtractError> {
search::search_impl(pdf_bytes, query, page, options.unwrap_or_default())
}
pub fn extract_toc(pdf_bytes: &[u8]) -> Result<Vec<TocEntry>, ExtractError> {
structure::extract_toc_impl(pdf_bytes)
}
pub fn extract_links(
pdf_bytes: &[u8],
page: Option<u32>,
) -> Result<Vec<Link>, ExtractError> {
structure::extract_links_impl(pdf_bytes, page)
}
pub fn extract_annotations(
pdf_bytes: &[u8],
page: Option<u32>,
) -> Result<Vec<Annotation>, ExtractError> {
structure::extract_annotations_impl(pdf_bytes, page)
}
pub fn extract_images(
pdf_bytes: &[u8],
page: Option<u32>,
) -> Result<Vec<ImageInfo>, ExtractError> {
structure::extract_images_impl(pdf_bytes, page)
}
pub fn extract_page_info(
pdf_bytes: &[u8],
page: Option<u32>,
) -> Result<Vec<PageInfo>, ExtractError> {
structure::extract_page_info_impl(pdf_bytes, page)
}
pub fn extract_document_info(pdf_bytes: &[u8]) -> Result<DocumentInfo, ExtractError> {
structure::extract_document_info_impl(pdf_bytes)
}
fn normalize_text(text: &str) -> String {
static BLANK_LINES_RE: OnceLock<Regex> = OnceLock::new();
let re = BLANK_LINES_RE.get_or_init(|| {
Regex::new(r"\n{3,}")
.expect("blank-line regex literal compiles — change requires updating this expect")
});
re.replace_all(text, "\n\n").trim().to_string()
}