use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::config::{CropBox, ImageMode, PageOrientationCorrection};
use crate::error::LiteParseError;
use crate::extract;
use crate::markdown_layout;
use crate::ocr::OcrEngine;
use crate::ocr_merge;
use crate::projection;
use crate::render;
use crate::types::{
DocumentMetadata, ExtractedImage, OutlineTarget, Page, ParsedPage, PdfInput, XfaPacket,
};
pub use crate::extract::{ExtractedPages, ExtractionOutputOptions};
pub use crate::layout::{LayoutBlock, LayoutCell};
pub use crate::markdown_layout::{Block, Cell, PositionedBlock, SpanCell, render_blocks};
pub use crate::ocr::OcrResult;
pub use crate::ocr_merge::{
ComplexityReason, LayoutComplexityReason, LayoutComplexityStats, OcrRaster, OcrRenderOptions,
PageComplexityStats, PageOcrOutcome,
};
pub use crate::parser::ScreenshotResult;
pub use pdfium::{Document, Library};
pub fn open<'lib>(
lib: &'lib Library,
input: &PdfInput,
password: Option<&str>,
corrections: &[PageOrientationCorrection],
) -> Result<Document<'lib>, LiteParseError> {
let document = extract::load_document_from_input(lib, input, password)?;
apply_orientation(&document, corrections)?;
Ok(document)
}
pub fn apply_orientation(
document: &Document,
corrections: &[PageOrientationCorrection],
) -> Result<(), LiteParseError> {
extract::apply_page_orientation_corrections(document, corrections)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn repair_acroform(
lib: &Library,
input: &PdfInput,
password: Option<&str>,
) -> Option<PdfInput> {
crate::acroform_repair::repair_orphaned_widgets(lib, input, password)
}
pub fn outline(document: &Document) -> Vec<OutlineTarget> {
extract::extract_outline(document)
}
pub fn xfa_packets(document: &Document) -> Vec<XfaPacket> {
document
.xfa_packets()
.into_iter()
.map(|packet| XfaPacket {
index: packet.index.max(0) as u32,
name: packet.name,
content_length: packet
.content
.as_ref()
.map_or(0, |content| content.len() as u32),
content: packet
.content
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned()),
})
.collect()
}
pub fn document_metadata(input: &PdfInput, document: &Document) -> DocumentMetadata {
crate::document_metadata::extract(input, document)
}
#[derive(Clone, Copy)]
pub struct ExtractRequest<'a> {
pub target_pages: Option<&'a [u32]>,
pub max_pages: usize,
pub extract_links: bool,
pub glyph_resolver: Option<&'a dyn crate::GlyphResolver>,
pub output: ExtractionOutputOptions,
}
impl Default for ExtractRequest<'_> {
fn default() -> Self {
Self {
target_pages: None,
max_pages: usize::MAX,
extract_links: false,
glyph_resolver: None,
output: ExtractionOutputOptions::default(),
}
}
}
pub fn extract(
document: &Document,
request: &ExtractRequest<'_>,
) -> Result<ExtractedPages, LiteParseError> {
extract::extract_pages_and_images(
document,
request.target_pages,
request.max_pages,
request.extract_links,
request.glyph_resolver,
request.output,
)
}
pub fn page_complexity(
document: &Document,
page: &Page,
) -> Result<PageComplexityStats, LiteParseError> {
let page_obj = document.page((page.page_number - 1) as i32)?;
ocr_merge::calculate_page_complexity(page, &page_obj)
}
pub fn layout_complexity(page: &ParsedPage) -> LayoutComplexityStats {
ocr_merge::calculate_layout_complexity(page)
}
pub fn render_for_ocr(
document: &Document,
pages: &[Page],
start: usize,
options: &OcrRenderOptions,
) -> Result<(Vec<OcrRaster>, usize), LiteParseError> {
ocr_merge::render_pages_for_ocr(document, pages, start, options)
}
pub async fn recognize(
rasters: Vec<OcrRaster>,
engine: Arc<dyn OcrEngine>,
language: &str,
num_workers: usize,
) -> Vec<PageOcrOutcome> {
ocr_merge::recognize_rasters(rasters, engine, language, num_workers).await
}
pub fn merge_ocr(
pages: &mut [Page],
outcomes: Vec<PageOcrOutcome>,
ocr_failure_fatal: bool,
) -> Result<(), LiteParseError> {
ocr_merge::merge_ocr_results(pages, outcomes, ocr_failure_fatal)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ContentFilters<'a> {
pub crop_box: Option<&'a CropBox>,
pub skip_diagonal_text: bool,
}
pub fn apply_content_filters(pages: &mut [Page], filters: &ContentFilters<'_>) {
extract::apply_content_filters(pages, filters.crop_box, filters.skip_diagonal_text);
}
pub fn project(pages: Vec<Page>) -> Vec<ParsedPage> {
projection::project_pages_to_grid(pages)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DocumentSignals {
pub body_size: f32,
pub heading_map: Vec<(f32, u8)>,
pub header_footer: HashSet<String>,
}
pub fn document_signals(pages: &[ParsedPage], keep_headers_footers: bool) -> DocumentSignals {
if pages.is_empty() {
return DocumentSignals::default();
}
let body_size = markdown_layout::compute_body_size(pages);
let heading_map = markdown_layout::build_heading_map(pages, body_size);
let header_footer = if keep_headers_footers {
HashSet::new()
} else {
markdown_layout::compute_header_footer_set(pages)
};
DocumentSignals {
body_size,
heading_map,
header_footer,
}
}
#[derive(Clone, Copy)]
pub struct BlockOptions<'a> {
pub outline: &'a [OutlineTarget],
pub image_mode: ImageMode,
pub keep_headers_footers: bool,
}
pub fn extract_blocks(
page: &ParsedPage,
signals: &DocumentSignals,
options: &BlockOptions<'_>,
) -> Option<Vec<PositionedBlock>> {
if page.projected_lines.is_empty() {
return None;
}
let target_index = (page.page_number as i32).saturating_sub(1);
let page_outline: Vec<OutlineTarget> = options
.outline
.iter()
.filter(|e| e.page_index == target_index)
.cloned()
.collect();
let chrome_indices = if options.keep_headers_footers {
HashSet::new()
} else {
markdown_layout::detect_single_page_chrome(page, signals.body_size)
};
let mut blocks = markdown_layout::classify_page_with_filters(
page,
&signals.heading_map,
&signals.header_footer,
&page_outline,
options.image_mode,
&chrome_indices,
);
let has_content = blocks
.iter()
.any(|b| !matches!(b.block, Block::HorizontalRule | Block::Figure { .. }));
if !has_content {
blocks.retain(|b| !matches!(b.block, Block::HorizontalRule));
}
dedupe_rules(&mut blocks);
Some(markdown_layout::splice_soft_hyphens(blocks))
}
pub fn render_page_markdown(page: &ParsedPage, blocks: Option<&[PositionedBlock]>) -> String {
match blocks {
Some(blocks) => render_blocks(blocks),
None => {
let mut out = String::from("```text\n");
out.push_str(&page.text);
if !page.text.ends_with('\n') {
out.push('\n');
}
out.push_str("```");
out
}
}
}
pub fn layout_blocks(page: &ParsedPage, blocks: &[PositionedBlock]) -> Vec<LayoutBlock> {
crate::layout::blocks_for_page(page, blocks)
}
pub fn canonicalize_image_refs(
pages: &mut [ParsedPage],
full_text: &mut String,
images: &[ExtractedImage],
) {
let by_id: HashMap<&str, &ExtractedImage> = images
.iter()
.map(|image| (image.id.as_str(), image))
.collect();
let renames: Vec<(String, String)> = images
.iter()
.filter_map(|image| {
let canonical = by_id.get(image.duplicate_of.as_ref()?.as_str())?;
Some((
format!("", image.id, image.format),
format!("", canonical.name),
))
})
.collect();
if renames.is_empty() {
return;
}
for markdown in pages
.iter_mut()
.map(|page| &mut page.markdown)
.chain(std::iter::once(full_text))
{
for (from, to) in &renames {
if markdown.contains(from.as_str()) {
*markdown = markdown.replace(from.as_str(), to);
}
}
}
}
fn dedupe_rules(blocks: &mut Vec<PositionedBlock>) {
use Block::HorizontalRule;
while matches!(blocks.first().map(|b| &b.block), Some(HorizontalRule)) {
blocks.remove(0);
}
while matches!(blocks.last().map(|b| &b.block), Some(HorizontalRule)) {
blocks.pop();
}
blocks.dedup_by(|a, b| matches!((&a.block, &b.block), (HorizontalRule, HorizontalRule)));
}
#[derive(Debug, Clone, Copy)]
pub struct ScreenshotOptions {
pub dpi: f32,
pub detect_rects: bool,
pub render_form_fields: bool,
pub continue_on_page_error: bool,
}
pub fn screenshots_need_pristine_document(
extracted: &ExtractedPages,
options: &ScreenshotOptions,
) -> bool {
extracted.flattened_form_widgets && options.render_form_fields
}
pub fn screenshots(
document: &Document,
page_numbers: Option<&[u32]>,
options: &ScreenshotOptions,
) -> Result<Vec<ScreenshotResult>, LiteParseError> {
Ok(render::render_document_pages(
document,
page_numbers,
options.dpi,
options.detect_rects,
options.render_form_fields,
options.continue_on_page_error,
)?
.into_iter()
.map(|page| ScreenshotResult {
page_num: page.page_num,
width: page.width,
height: page.height,
image_bytes: page.png_bytes,
is_solid_fill: page.is_solid_fill,
rects: page.rects,
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Anchor, ProjectedLine, Rect, TextItem};
fn line(text: &str, x: f32, y: f32, h: f32, size: f32) -> ProjectedLine {
ProjectedLine {
text: text.into(),
rtl: crate::bidi::is_rtl_text(text),
bbox: Rect {
x,
y,
width: text.chars().count() as f32 * (size * 0.5),
height: h,
},
anchor: Anchor::Left,
indent_x: x,
dominant_font_size: size,
font_size_is_estimated: false,
heading_font_size: None,
dominant_font_name: Some("Arial".into()),
all_bold: false,
all_italic: false,
all_mono: false,
all_strike: false,
spans: vec![TextItem::default()],
region_path: Vec::new(),
mcid: None,
in_figure: false,
}
}
fn page_with(n: usize, lines: Vec<ProjectedLine>) -> ParsedPage {
ParsedPage {
page_number: n,
page_label: None,
page_width: 612.0,
page_height: 792.0,
content_bounds: None,
text: "fallback".into(),
markdown: String::new(),
text_items: vec![],
projected_lines: lines,
regions: crate::types::Region::default(),
graphics: vec![],
vector_graphics: None,
figures: vec![],
projected_item_frames: vec![],
struct_nodes: vec![],
image_refs: vec![],
complexity: None,
annotations: None,
form_fields: None,
structure_tree: None,
blocks: None,
}
}
fn render_pages(pages: &[ParsedPage]) -> Vec<String> {
let signals = document_signals(pages, false);
let options = BlockOptions {
outline: &[],
image_mode: ImageMode::Placeholder,
keep_headers_footers: false,
};
pages
.iter()
.map(|page| {
let blocks = extract_blocks(page, &signals, &options);
render_page_markdown(page, blocks.as_deref())
})
.collect()
}
fn render_document(pages: &[ParsedPage]) -> String {
render_pages(pages).join("\n\n-----\n\n")
}
#[test]
fn empty_document_renders_empty() {
assert_eq!(render_document(&[]), "");
}
#[test]
fn dedupe_rules_drops_edges_and_collapses_runs() {
use Block::{HorizontalRule, Paragraph};
let p = |t: &str| Paragraph {
text: t.into(),
bold: false,
italic: false,
};
let mut blocks: Vec<PositionedBlock> = [
HorizontalRule,
p("a"),
HorizontalRule,
HorizontalRule,
p("b"),
HorizontalRule,
]
.into_iter()
.map(PositionedBlock::unlocated)
.collect();
dedupe_rules(&mut blocks);
let kinds: Vec<bool> = blocks
.iter()
.map(|b| matches!(b.block, Block::HorizontalRule))
.collect();
assert_eq!(kinds, vec![false, true, false]);
}
#[test]
fn page_without_projected_lines_falls_back_to_fenced_text() {
let mut p = page_with(1, vec![]);
p.text = "hello".into();
let out = render_document(&[p]);
assert!(out.contains("```text"));
assert!(out.contains("hello"));
}
#[test]
fn heading_and_paragraph() {
let p = page_with(
1,
vec![
line("My Title For This Test Document", 50.0, 50.0, 18.0, 18.0),
line("First sentence of body prose here.", 50.0, 80.0, 10.0, 10.0),
line(
"Second sentence of body prose here.",
50.0,
92.0,
10.0,
10.0,
),
line(
"Third sentence of body prose here.",
50.0,
104.0,
10.0,
10.0,
),
],
);
let out = render_document(&[p]);
assert!(out.contains("# My Title For This Test Document"));
assert!(out.contains("First sentence of body prose here."));
}
#[test]
fn per_page_markdown_joins_with_separator() {
let a = page_with(1, vec![line("A page.", 50.0, 80.0, 10.0, 10.0)]);
let b = page_with(2, vec![line("B page.", 50.0, 80.0, 10.0, 10.0)]);
let pages = [a, b];
let per_page = render_pages(&pages);
assert_eq!(per_page.len(), 2);
assert!(per_page[0].contains("A page."));
assert!(per_page[1].contains("B page."));
assert!(!per_page[0].contains("-----"));
let out = render_document(&pages);
assert!(out.contains("-----"));
assert!(out.find("A page.").unwrap() < out.find("B page.").unwrap());
}
#[test]
fn canonicalize_image_refs_points_duplicates_at_canonical_file() {
let image = |id: &str, dup: Option<&str>| ExtractedImage {
id: id.into(),
name: format!("img_{id}.jpg"),
page: 1,
bbox: Rect::default(),
width: 1,
height: 1,
rotation: 0.0,
format: "jpg".into(),
path: None,
duplicate_of: dup.map(Into::into),
bytes: Arc::new(Vec::new()),
};
let mut pages = vec![page_with(1, vec![])];
pages[0].markdown = "intro\n\n\n\noutro".into();
let mut full_text = pages[0].markdown.clone();
let images = vec![image("p1_1", None), image("p2_1", Some("p1_1"))];
canonicalize_image_refs(&mut pages, &mut full_text, &images);
assert_eq!(pages[0].markdown, "intro\n\n\n\noutro");
assert_eq!(full_text, pages[0].markdown);
}
}