use crate::cache::{self, GraphCacheKey};
use crate::classifier::DocumentClassifier;
use crate::config::ParsingConfig;
use crate::graphs::builder::GraphBuilder;
use crate::graphs::NodeIdGenerator;
use crate::preprocessors::{Preprocessor, TikaPreprocessor};
use crate::rules::RuleEngine;
use crate::storage::{
calculate_config_hash, calculate_pdf_hash, CacheDefaults, CachePoint, DocumentStorage,
FileStorage, FreshFrom,
};
use crate::types::*;
use anyhow::Result;
use std::path::Path;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, serde::Serialize)]
pub struct PipelineStages {
pub xhtml: String,
pub text_elements: Vec<PdfTextElement>,
pub parsed_elements: Vec<ParsedPdfElement>,
pub graph: DocumentGraph,
}
pub struct StepProfiler {
enabled: bool,
timings: Vec<(String, Duration)>,
}
impl StepProfiler {
pub fn new(enabled: bool) -> Self {
Self {
enabled,
timings: Vec::new(),
}
}
pub fn time_step<F, R>(&mut self, step_name: &str, f: F) -> R
where
F: FnOnce() -> R,
{
if !self.enabled {
return f();
}
let start = Instant::now();
let result = f();
let elapsed = start.elapsed();
self.timings.push((step_name.to_string(), elapsed));
println!("⏱️ {}: {:.0}ms", step_name, elapsed.as_millis());
result
}
pub fn print_summary(&self) {
if !self.enabled || self.timings.is_empty() {
return;
}
println!("\n📊 Performance Summary:");
let total: Duration = self.timings.iter().map(|(_, d)| *d).sum();
for (step, duration) in &self.timings {
let percentage = (duration.as_secs_f64() / total.as_secs_f64()) * 100.0;
println!(
" {:.<35} {:.0}ms ({:.1}%)",
step,
duration.as_millis(),
percentage
);
}
println!(" {:.<35} {:.0}ms", "Total", total.as_millis());
}
}
pub struct DocumentProcessor {
preprocessor: Box<dyn Preprocessor>,
storage: Box<dyn DocumentStorage + Send + Sync>,
classifier: DocumentClassifier,
rule_engine: RuleEngine,
graph_builder: GraphBuilder,
}
impl DocumentProcessor {
pub fn new_with_dependencies(
preprocessor: Box<dyn Preprocessor>,
storage: Box<dyn DocumentStorage + Send + Sync>,
) -> Result<Self> {
Ok(Self {
preprocessor,
storage,
classifier: DocumentClassifier::new(),
rule_engine: RuleEngine::new()?,
graph_builder: GraphBuilder::new(),
})
}
#[cfg(feature = "jni-backend")]
pub fn new_cli_jni(jre_path: &std::path::Path, jar_path: &std::path::Path) -> Result<Self> {
let preprocessor = Box::new(TikaPreprocessor::new_with_jni(jre_path, jar_path)?);
let storage = Box::new(FileStorage::new("cache")?);
Self::new_with_dependencies(preprocessor, storage)
}
#[cfg(feature = "jni-backend")]
pub fn new_cli_jni_with_cache(
jre_path: &std::path::Path,
jar_path: &std::path::Path,
cache_dir: &str,
) -> Result<Self> {
let preprocessor = Box::new(TikaPreprocessor::new_with_jni(jre_path, jar_path)?);
let storage = Box::new(FileStorage::new(cache_dir)?);
Self::new_with_dependencies(preprocessor, storage)
}
pub fn process_document_with_cache(
&mut self,
input_path: &str,
config: &ParsingConfig,
fresh_from: FreshFrom,
cache_defaults: &CacheDefaults,
enable_profiling: bool,
) -> Result<DocumentGraph> {
let mut profiler = StepProfiler::new(enable_profiling);
let start_time = Instant::now();
let pdf_bytes = std::fs::read(input_path)?;
let pdf_hash = calculate_pdf_hash(&pdf_bytes);
println!("📄 Processing: {}", input_path);
if fresh_from.should_use_cache(CachePoint::C3) && cache_defaults.should_write(CachePoint::C3) {
let config_hash = calculate_config_hash(config)?;
let cache_key = GraphCacheKey::new(pdf_hash.clone(), config_hash);
if let Some(cached) = self.storage.get_graph_output(&cache_key)? {
println!(
"🎯 C3 graph cache hit ({:.3}s)",
start_time.elapsed().as_secs_f64()
);
return Ok(cached.graph);
}
}
let config_hash = calculate_config_hash(config)?;
let id_gen = NodeIdGenerator::new(
cache::versions::BLAZEGRAPH_VERSION,
&pdf_hash,
&config_hash,
);
let preprocessor_output = if fresh_from.should_use_cache(CachePoint::C2) {
if let Some(cached) = self.storage.get_preprocessor_output(&pdf_hash)? {
println!("🎯 C2 preprocessor cache hit — skipping extraction + parsing");
cached
} else {
self.extract_and_parse(input_path, &pdf_bytes, &pdf_hash, &fresh_from, cache_defaults, &mut profiler)?
}
} else {
self.extract_and_parse(input_path, &pdf_bytes, &pdf_hash, &fresh_from, cache_defaults, &mut profiler)?
};
let graph = self.rules_and_graph(&preprocessor_output, config, &id_gen, &mut profiler)?;
if enable_profiling {
profiler.print_summary();
}
println!(
"⏱️ Total: {:.0}ms",
start_time.elapsed().as_millis()
);
Ok(graph)
}
pub fn process_document(&mut self, input_path: &str) -> Result<DocumentGraph> {
let default_config = ParsingConfig::default();
self.process_document_with_cache(
input_path,
&default_config,
FreshFrom::None,
&CacheDefaults::default(),
false,
)
}
pub fn process_document_with_config_file(
&mut self,
input_path: &str,
config_path: &str,
) -> Result<DocumentGraph> {
let config = ParsingConfig::load_from_file(config_path)?;
self.process_document_with_cache(
input_path,
&config,
FreshFrom::None,
&CacheDefaults::default(),
false,
)
}
pub fn process_document_capture_stages(
&mut self,
input_path: &str,
config: &ParsingConfig,
) -> Result<PipelineStages> {
let input_path_ref = Path::new(input_path);
let pdf_bytes = std::fs::read(input_path_ref)?;
let xhtml = self.preprocessor.parse_pdf_to_markup_language(&pdf_bytes)?;
println!("📋 Stage 1a: XHTML captured ({} bytes)", xhtml.len());
let preprocessor_output = self
.preprocessor
.parse_markup_to_preprocessor_output(&xhtml)?;
let text_elements = preprocessor_output.text_elements.clone();
println!("📋 Stage 1b: {} TextElements captured", text_elements.len());
let classification = self.classifier.classify(&preprocessor_output)?;
let document_analysis =
DocumentAnalysis::analyze_text_elements(&preprocessor_output.text_elements);
let parsed_elements = if config.minimal_parse {
self.rule_engine
.convert_text_elements_to_parsed(&preprocessor_output.text_elements)
} else {
let font_size_analysis = self.rule_engine.analyze_font_sizes(
&preprocessor_output.text_elements,
&preprocessor_output.style_data,
);
self.rule_engine.apply_rules_with_config(
&preprocessor_output.text_elements,
&classification,
&document_analysis,
&font_size_analysis,
&preprocessor_output.style_data,
config,
)?
};
println!(
"📋 Stage 2: {} ParsedElements captured",
parsed_elements.len()
);
let inferred_title = infer_title(&parsed_elements);
let mut graph = self.graph_builder.build_graph(parsed_elements.clone())?;
if let Some(title) = inferred_title {
graph.document_info.document_metadata.title = Some(title);
}
graph
.document_info
.document_metadata
.merge_extracted(preprocessor_output.metadata);
graph.document_info.document_analysis = document_analysis;
graph.document_info.bookmark_data = preprocessor_output.bookmark_data;
graph.compute_structural_profile();
graph.compute_breadcrumbs();
crate::graphs::graph_sanity::apply(&mut graph, &config.graph_sanity);
println!(
"📋 Stage 3: Graph captured ({} nodes)",
graph.nodes.len()
);
Ok(PipelineStages {
xhtml,
text_elements,
parsed_elements,
graph,
})
}
fn extract_and_parse(
&mut self,
_input_path: &str,
pdf_bytes: &[u8],
pdf_hash: &str,
fresh_from: &FreshFrom,
cache_defaults: &CacheDefaults,
profiler: &mut StepProfiler,
) -> Result<PreprocessorOutput> {
let xhtml = if fresh_from.should_use_cache(CachePoint::C1) {
if let Some(cached) = self.storage.get_xhtml(pdf_hash)? {
println!("🎯 C1 XHTML cache hit — skipping Tika extraction");
cached
} else {
let markup = profiler.time_step("C1: PDF → XHTML (Tika)", || {
self.preprocessor.parse_pdf_to_markup_language(pdf_bytes)
})?;
if cache_defaults.should_write(CachePoint::C1) {
self.storage.store_xhtml(pdf_hash, &markup)?;
println!("💾 C1: XHTML cached ({} bytes)", markup.len());
}
markup
}
} else {
let markup = profiler.time_step("C1: PDF → XHTML (Tika, fresh)", || {
self.preprocessor.parse_pdf_to_markup_language(pdf_bytes)
})?;
if cache_defaults.should_write(CachePoint::C1) {
self.storage.store_xhtml(pdf_hash, &markup)?;
println!("💾 C1: XHTML cached ({} bytes, refreshed)", markup.len());
}
markup
};
let output = profiler.time_step("C2: XHTML → PreprocessorOutput", || {
self.preprocessor
.parse_markup_to_preprocessor_output(&xhtml)
})?;
if cache_defaults.should_write(CachePoint::C2) {
self.storage
.store_preprocessor_output(pdf_hash, &output)?;
println!("💾 C2: PreprocessorOutput cached");
}
Ok(output)
}
fn rules_and_graph(
&mut self,
preprocessor_output: &PreprocessorOutput,
config: &ParsingConfig,
id_gen: &NodeIdGenerator,
profiler: &mut StepProfiler,
) -> Result<DocumentGraph> {
let classification = profiler.time_step("Classification", || {
self.classifier.classify(preprocessor_output)
})?;
let document_analysis = profiler.time_step("Document Analysis", || {
DocumentAnalysis::analyze_text_elements(&preprocessor_output.text_elements)
});
let parsed_elements = if config.minimal_parse {
println!("🔄 Minimal parse mode — skipping rule processing");
self.rule_engine
.convert_text_elements_to_parsed(&preprocessor_output.text_elements)
} else {
let font_size_analysis = profiler.time_step("Font Analysis", || {
self.rule_engine.analyze_font_sizes(
&preprocessor_output.text_elements,
&preprocessor_output.style_data,
)
});
profiler.time_step("Rules Processing", || {
self.rule_engine.apply_rules_with_config(
&preprocessor_output.text_elements,
&classification,
&document_analysis,
&font_size_analysis,
&preprocessor_output.style_data,
config,
)
})?
};
let inferred_title = infer_title(&parsed_elements);
let mut graph = profiler.time_step("Graph Construction", || {
self.graph_builder.build_graph_deterministic(parsed_elements, id_gen)
})?;
if let Some(title) = inferred_title {
graph.document_info.document_metadata.title = Some(title);
}
graph
.document_info
.document_metadata
.merge_extracted(preprocessor_output.metadata.clone());
graph.document_info.document_analysis = document_analysis;
graph.document_info.bookmark_data = preprocessor_output.bookmark_data.clone();
graph.compute_structural_profile();
graph.compute_breadcrumbs();
crate::graphs::graph_sanity::apply(&mut graph, &config.graph_sanity);
Ok(graph)
}
}