1use crate::analytics::{
2 AnalysisBuilder, DocumentAnalysis, FontStatsBuilder, GeometryStatsBuilder, PageStatsBuilder,
3 RegionStatsBuilder, Statistic,
4};
5use crate::cache::{self, GraphCacheKey};
6use crate::classifier::DocumentClassifier;
7use crate::config::ParsingConfig;
8use crate::graphs::builder::GraphBuilder;
9use crate::graphs::NodeIdGenerator;
10use crate::preprocessors::{Preprocessor, TikaPreprocessor};
11use crate::rules::RuleEngine;
12use crate::storage::{
13 calculate_config_hash, calculate_pdf_hash, CacheDefaults, CachePoint, DocumentStorage,
14 FileStorage, FreshFrom,
15};
16use crate::types::*;
17use anyhow::Result;
18use std::path::Path;
19use std::time::{Duration, Instant};
20
21#[derive(Debug, Clone, serde::Serialize)]
24pub struct PipelineStages {
25 pub xhtml: String,
26 pub text_elements: Vec<PdfTextElement>,
27 pub parsed_elements: Vec<ParsedPdfElement>,
28 pub graph: DocumentGraph,
29}
30
31pub struct StepProfiler {
33 enabled: bool,
34 timings: Vec<(String, Duration)>,
35}
36
37impl StepProfiler {
38 pub fn new(enabled: bool) -> Self {
39 Self {
40 enabled,
41 timings: Vec::new(),
42 }
43 }
44
45 pub fn time_step<F, R>(&mut self, step_name: &str, f: F) -> R
46 where
47 F: FnOnce() -> R,
48 {
49 if !self.enabled {
50 return f();
51 }
52
53 let start = Instant::now();
54 let result = f();
55 let elapsed = start.elapsed();
56
57 self.timings.push((step_name.to_string(), elapsed));
58 println!("ā±ļø {}: {:.0}ms", step_name, elapsed.as_millis());
59
60 result
61 }
62
63 pub fn print_summary(&self) {
64 if !self.enabled || self.timings.is_empty() {
65 return;
66 }
67
68 println!("\nš Performance Summary:");
69 let total: Duration = self.timings.iter().map(|(_, d)| *d).sum();
70
71 for (step, duration) in &self.timings {
72 let percentage = (duration.as_secs_f64() / total.as_secs_f64()) * 100.0;
73 println!(
74 " {:.<35} {:.0}ms ({:.1}%)",
75 step,
76 duration.as_millis(),
77 percentage
78 );
79 }
80 println!(" {:.<35} {:.0}ms", "Total", total.as_millis());
81 }
82}
83
84pub struct DocumentProcessor {
85 preprocessor: Box<dyn Preprocessor>,
86 storage: Box<dyn DocumentStorage + Send + Sync>,
87 classifier: DocumentClassifier,
88 rule_engine: RuleEngine,
89 graph_builder: GraphBuilder,
90}
91
92impl DocumentProcessor {
93 pub fn new_with_dependencies(
95 preprocessor: Box<dyn Preprocessor>,
96 storage: Box<dyn DocumentStorage + Send + Sync>,
97 ) -> Result<Self> {
98 Ok(Self {
99 preprocessor,
100 storage,
101 classifier: DocumentClassifier::new(),
102 rule_engine: RuleEngine::new()?,
103 graph_builder: GraphBuilder::new(),
104 })
105 }
106
107 #[cfg(feature = "jni-backend")]
109 pub fn new_cli_jni(jre_path: &std::path::Path, jar_path: &std::path::Path) -> Result<Self> {
110 let preprocessor = Box::new(TikaPreprocessor::new_with_jni(jre_path, jar_path)?);
111 let storage = Box::new(FileStorage::new("cache")?);
112 Self::new_with_dependencies(preprocessor, storage)
113 }
114
115 #[cfg(feature = "jni-backend")]
117 pub fn new_cli_jni_with_cache(
118 jre_path: &std::path::Path,
119 jar_path: &std::path::Path,
120 cache_dir: &str,
121 ) -> Result<Self> {
122 let preprocessor = Box::new(TikaPreprocessor::new_with_jni(jre_path, jar_path)?);
123 let storage = Box::new(FileStorage::new(cache_dir)?);
124 Self::new_with_dependencies(preprocessor, storage)
125 }
126
127 pub fn process_document_with_cache(
134 &mut self,
135 input_path: &str,
136 config: &ParsingConfig,
137 fresh_from: FreshFrom,
138 cache_defaults: &CacheDefaults,
139 enable_profiling: bool,
140 ) -> Result<DocumentGraph> {
141 let mut profiler = StepProfiler::new(enable_profiling);
142 let start_time = Instant::now();
143
144 let pdf_bytes = std::fs::read(input_path)?;
146 let pdf_hash = calculate_pdf_hash(&pdf_bytes);
147
148 println!("š Processing: {}", input_path);
149
150 if fresh_from.should_use_cache(CachePoint::C3)
152 && cache_defaults.should_write(CachePoint::C3)
153 {
154 let config_hash = calculate_config_hash(config)?;
155 let cache_key = GraphCacheKey::new(pdf_hash.clone(), config_hash);
156 if let Some(cached) = self.storage.get_graph_output(&cache_key)? {
157 println!(
158 "šÆ C3 graph cache hit ({:.3}s)",
159 start_time.elapsed().as_secs_f64()
160 );
161 return Ok(cached.graph);
162 }
163 }
164
165 let config_hash = calculate_config_hash(config)?;
167 let id_gen =
168 NodeIdGenerator::new(cache::versions::BLAZEGRAPH_VERSION, &pdf_hash, &config_hash);
169
170 let preprocessor_output = if fresh_from.should_use_cache(CachePoint::C2) {
172 if let Some(cached) = self.storage.get_preprocessor_output(&pdf_hash)? {
173 println!("šÆ C2 preprocessor cache hit ā skipping extraction + parsing");
174 cached
175 } else {
176 self.extract_and_parse(
177 input_path,
178 &pdf_bytes,
179 &pdf_hash,
180 &fresh_from,
181 cache_defaults,
182 &mut profiler,
183 )?
184 }
185 } else {
186 self.extract_and_parse(
187 input_path,
188 &pdf_bytes,
189 &pdf_hash,
190 &fresh_from,
191 cache_defaults,
192 &mut profiler,
193 )?
194 };
195
196 let graph = self.rules_and_graph(
198 &preprocessor_output,
199 config,
200 &id_gen,
201 &pdf_hash,
202 &mut profiler,
203 )?;
204
205 if enable_profiling {
206 profiler.print_summary();
207 }
208 println!("ā±ļø Total: {:.0}ms", start_time.elapsed().as_millis());
209
210 Ok(graph)
211 }
212
213 pub fn process_document(&mut self, input_path: &str) -> Result<DocumentGraph> {
215 let default_config = ParsingConfig::default();
216 self.process_document_with_cache(
217 input_path,
218 &default_config,
219 FreshFrom::None,
220 &CacheDefaults::default(),
221 false,
222 )
223 }
224
225 pub fn process_document_with_config_file(
227 &mut self,
228 input_path: &str,
229 config_path: &str,
230 ) -> Result<DocumentGraph> {
231 let config = ParsingConfig::load_from_file(config_path)?;
232 self.process_document_with_cache(
233 input_path,
234 &config,
235 FreshFrom::None,
236 &CacheDefaults::default(),
237 false,
238 )
239 }
240
241 pub fn process_document_capture_stages(
244 &mut self,
245 input_path: &str,
246 config: &ParsingConfig,
247 ) -> Result<PipelineStages> {
248 let input_path_ref = Path::new(input_path);
249 let pdf_bytes = std::fs::read(input_path_ref)?;
250 let pdf_hash = calculate_pdf_hash(&pdf_bytes);
251
252 let xhtml = self.preprocessor.parse_pdf_to_markup_language(&pdf_bytes)?;
254 println!("š Stage 1a: XHTML captured ({} bytes)", xhtml.len());
255
256 let preprocessor_output = self
258 .preprocessor
259 .parse_markup_to_preprocessor_output(&xhtml)?;
260 println!(
261 "š Stage 1b: {} TextElements captured",
262 preprocessor_output.text_elements.len()
263 );
264
265 let classification = self.classifier.classify(&preprocessor_output)?;
267 let document_analysis = run_analytics(&preprocessor_output.text_elements);
268 if config.dump_analytics {
269 dump_stats(&*self.storage, &pdf_hash, &document_analysis)?;
270 }
271
272 let text_elements = crate::analytics::tag_and_resort(
276 preprocessor_output.text_elements.clone(),
277 &document_analysis,
278 );
279 let resorted_elements = text_elements.clone();
280
281 let parsed_elements = if config.minimal_parse {
282 self.rule_engine
283 .convert_text_elements_to_parsed(&resorted_elements)
284 } else {
285 let font_size_analysis = self
286 .rule_engine
287 .analyze_font_sizes(&resorted_elements, &preprocessor_output.style_data);
288 self.rule_engine.apply_rules_with_config(
289 &resorted_elements,
290 &classification,
291 &document_analysis,
292 &font_size_analysis,
293 &preprocessor_output.style_data,
294 config,
295 )?
296 };
297 println!(
298 "š Stage 2: {} ParsedElements captured",
299 parsed_elements.len()
300 );
301
302 let inferred_title = infer_title(&parsed_elements);
304
305 let mut graph = self.graph_builder.build_graph(parsed_elements.clone())?;
307
308 if let Some(title) = inferred_title {
310 graph.document_info.document_metadata.title = Some(title);
311 }
312 graph
313 .document_info
314 .document_metadata
315 .merge_extracted(preprocessor_output.metadata);
316 graph.document_info.bookmark_data = preprocessor_output.bookmark_data;
317 graph.compute_structural_profile();
318 graph.compute_breadcrumbs();
319 crate::graphs::graph_sanity::apply(&mut graph, &config.graph_sanity);
320
321 println!("š Stage 3: Graph captured ({} nodes)", graph.nodes.len());
322
323 Ok(PipelineStages {
324 xhtml,
325 text_elements,
326 parsed_elements,
327 graph,
328 })
329 }
330
331 fn extract_and_parse(
337 &mut self,
338 _input_path: &str,
339 pdf_bytes: &[u8],
340 pdf_hash: &str,
341 fresh_from: &FreshFrom,
342 cache_defaults: &CacheDefaults,
343 profiler: &mut StepProfiler,
344 ) -> Result<PreprocessorOutput> {
345 let xhtml = if fresh_from.should_use_cache(CachePoint::C1) {
347 if let Some(cached) = self.storage.get_xhtml(pdf_hash)? {
348 println!("šÆ C1 XHTML cache hit ā skipping Tika extraction");
349 cached
350 } else {
351 let markup = profiler.time_step("C1: PDF ā XHTML (Tika)", || {
352 self.preprocessor.parse_pdf_to_markup_language(pdf_bytes)
353 })?;
354 if cache_defaults.should_write(CachePoint::C1) {
355 self.storage.store_xhtml(pdf_hash, &markup)?;
356 println!("š¾ C1: XHTML cached ({} bytes)", markup.len());
357 }
358 markup
359 }
360 } else {
361 let markup = profiler.time_step("C1: PDF ā XHTML (Tika, fresh)", || {
363 self.preprocessor.parse_pdf_to_markup_language(pdf_bytes)
364 })?;
365 if cache_defaults.should_write(CachePoint::C1) {
366 self.storage.store_xhtml(pdf_hash, &markup)?;
367 println!("š¾ C1: XHTML cached ({} bytes, refreshed)", markup.len());
368 }
369 markup
370 };
371
372 let output = profiler.time_step("C2: XHTML ā PreprocessorOutput", || {
374 self.preprocessor
375 .parse_markup_to_preprocessor_output(&xhtml)
376 })?;
377
378 if cache_defaults.should_write(CachePoint::C2) {
379 self.storage.store_preprocessor_output(pdf_hash, &output)?;
380 println!("š¾ C2: PreprocessorOutput cached");
381 }
382
383 Ok(output)
384 }
385
386 fn rules_and_graph(
392 &mut self,
393 preprocessor_output: &PreprocessorOutput,
394 config: &ParsingConfig,
395 id_gen: &NodeIdGenerator,
396 pdf_hash: &str,
397 profiler: &mut StepProfiler,
398 ) -> Result<DocumentGraph> {
399 let classification = profiler.time_step("Classification", || {
401 self.classifier.classify(preprocessor_output)
402 })?;
403
404 let document_analysis = profiler.time_step("Document Analytics", || {
408 run_analytics(&preprocessor_output.text_elements)
409 });
410 if config.dump_analytics {
411 dump_stats(&*self.storage, pdf_hash, &document_analysis)?;
412 }
413
414 let text_elements = profiler.time_step("Reading-Order Resort", || {
421 crate::analytics::tag_and_resort(
422 preprocessor_output.text_elements.clone(),
423 &document_analysis,
424 )
425 });
426
427 let parsed_elements = if config.minimal_parse {
429 println!("š Minimal parse mode ā skipping rule processing");
430 self.rule_engine
431 .convert_text_elements_to_parsed(&text_elements)
432 } else {
433 let font_size_analysis = profiler.time_step("Font Analysis", || {
434 self.rule_engine
435 .analyze_font_sizes(&text_elements, &preprocessor_output.style_data)
436 });
437
438 profiler.time_step("Rules Processing", || {
439 self.rule_engine.apply_rules_with_config(
440 &text_elements,
441 &classification,
442 &document_analysis,
443 &font_size_analysis,
444 &preprocessor_output.style_data,
445 config,
446 )
447 })?
448 };
449
450 let inferred_title = infer_title(&parsed_elements);
452
453 let mut graph = profiler.time_step("Graph Construction", || {
455 self.graph_builder
456 .build_graph_deterministic(parsed_elements, id_gen)
457 })?;
458
459 if let Some(title) = inferred_title {
461 graph.document_info.document_metadata.title = Some(title);
462 }
463 graph
464 .document_info
465 .document_metadata
466 .merge_extracted(preprocessor_output.metadata.clone());
467 graph.document_info.bookmark_data = preprocessor_output.bookmark_data.clone();
468 graph.compute_structural_profile();
469 graph.compute_breadcrumbs();
470 crate::graphs::graph_sanity::apply(&mut graph, &config.graph_sanity);
471
472 Ok(graph)
473 }
474}
475
476fn run_analytics(text_elements: &[PdfTextElement]) -> DocumentAnalysis {
483 let mut builder = AnalysisBuilder::new();
484 for element in text_elements {
485 builder.observe(element);
486 }
487 builder.finalize()
488}
489
490fn dump_stats(
497 storage: &dyn DocumentStorage,
498 pdf_hash: &str,
499 analysis: &DocumentAnalysis,
500) -> Result<()> {
501 let font_json = serde_json::to_string_pretty(&analysis.font)?;
502 storage.store_stat(pdf_hash, FontStatsBuilder::NAME, &font_json)?;
503
504 let geometry_json = serde_json::to_string_pretty(&analysis.geometry)?;
505 storage.store_stat(pdf_hash, GeometryStatsBuilder::NAME, &geometry_json)?;
506
507 let page_stats_json = serde_json::to_string_pretty(&analysis.page_stats)?;
508 storage.store_stat(pdf_hash, PageStatsBuilder::NAME, &page_stats_json)?;
509
510 let region_json = serde_json::to_string_pretty(&analysis.region)?;
511 storage.store_stat(pdf_hash, RegionStatsBuilder::NAME, ®ion_json)?;
512
513 Ok(())
514}