1use std::collections::{BTreeMap, BTreeSet};
8#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
9use std::fs;
10use std::path::{Path, PathBuf};
11#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
16use serde_json::Value;
17
18use crate::api::config::{HybridMode, ProcessingConfig};
19use crate::models::content::ContentElement;
20use crate::models::document::PdfDocument;
21use crate::models::enums::SemanticType;
22#[cfg(not(target_arch = "wasm32"))]
23use crate::pdf::bookmark_extractor::{extract_bookmarks, Bookmark};
24#[cfg(not(target_arch = "wasm32"))]
25use crate::EdgePdfError;
26
27#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
28const DEFAULT_URL: &str = "http://127.0.0.1:5002";
29#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
30const CONVERT_ENDPOINT: &str = "/v1/convert/file";
31#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
32const HEALTH_ENDPOINT: &str = "/health";
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct TriageEntry {
37 pub page: u32,
39 pub decision: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub reason: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct TriageReport {
49 pub document: String,
51 pub triage: Vec<TriageEntry>,
53}
54
55#[derive(Debug, Clone)]
57pub struct HybridResult {
58 pub markdown_override: Option<String>,
60 pub triage: TriageReport,
62 pub used_backend: bool,
64}
65
66pub fn apply_hybrid_injected(
71 doc: &PdfDocument,
72 config: &ProcessingConfig,
73 backend_markdown: Option<&str>,
74 input_path: Option<&Path>,
75) -> HybridResult {
76 let mut triage = triage_document(doc, config);
77
78 #[cfg(not(target_arch = "wasm32"))]
79 if let Some(path) = input_path {
80 if outline_heading_deficit(path, doc) {
81 for entry in &mut triage.triage {
82 entry.decision = "BACKEND".into();
83 entry.reason = Some("outline_heading_deficit".into());
84 }
85 } else if typographic_heading_deficit(doc) {
86 for entry in &mut triage.triage {
87 entry.decision = "BACKEND".into();
88 entry.reason = Some("heading_inventory_deficit".into());
89 }
90 } else if title_fragmentation_deficit(doc) {
91 for entry in &mut triage.triage {
92 entry.decision = "BACKEND".into();
93 entry.reason = Some("title_fragmentation".into());
94 }
95 }
96 } else if typographic_heading_deficit(doc) {
97 for entry in &mut triage.triage {
98 entry.decision = "BACKEND".into();
99 entry.reason = Some("heading_inventory_deficit".into());
100 }
101 } else if title_fragmentation_deficit(doc) {
102 for entry in &mut triage.triage {
103 entry.decision = "BACKEND".into();
104 entry.reason = Some("title_fragmentation".into());
105 }
106 }
107
108 #[cfg(target_arch = "wasm32")]
109 {
110 let _ = input_path;
111 if typographic_heading_deficit(doc) {
112 for entry in &mut triage.triage {
113 entry.decision = "BACKEND".into();
114 entry.reason = Some("heading_inventory_deficit".into());
115 }
116 } else if title_fragmentation_deficit(doc) {
117 for entry in &mut triage.triage {
118 entry.decision = "BACKEND".into();
119 entry.reason = Some("title_fragmentation".into());
120 }
121 }
122 }
123
124 let backend_pages: BTreeSet<u32> = triage
125 .triage
126 .iter()
127 .filter(|e| e.decision == "BACKEND")
128 .map(|e| e.page)
129 .collect();
130
131 let wants_backend = matches!(config.hybrid_mode, HybridMode::Full) || !backend_pages.is_empty();
132
133 if !wants_backend {
134 return HybridResult {
135 markdown_override: None,
136 triage,
137 used_backend: false,
138 };
139 }
140
141 let Some(md) = backend_markdown.filter(|s| !s.trim().is_empty()) else {
142 return HybridResult {
143 markdown_override: None,
144 triage,
145 used_backend: false,
146 };
147 };
148
149 let backend = BackendPayload {
150 full_markdown: Some(md.to_string()),
151 pages: BTreeMap::new(),
152 };
153 if matches!(config.hybrid_mode, HybridMode::Full) && backend_pages.is_empty() {
156 return HybridResult {
157 markdown_override: Some(md.to_string()),
158 triage,
159 used_backend: true,
160 };
161 }
162 let override_md = merge_page_markdown(doc, &triage, &backend);
163 HybridResult {
164 markdown_override: Some(override_md),
165 triage,
166 used_backend: true,
167 }
168}
169
170#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
174pub fn apply_hybrid(
175 input_path: &Path,
176 doc: &PdfDocument,
177 config: &ProcessingConfig,
178) -> Result<HybridResult, EdgePdfError> {
179 let mut triage = triage_document(doc, config);
180
181 if outline_heading_deficit(input_path, doc) {
184 for entry in &mut triage.triage {
185 entry.decision = "BACKEND".into();
186 entry.reason = Some("outline_heading_deficit".into());
187 }
188 } else if typographic_heading_deficit(doc) {
189 for entry in &mut triage.triage {
191 entry.decision = "BACKEND".into();
192 entry.reason = Some("heading_inventory_deficit".into());
193 }
194 } else if title_fragmentation_deficit(doc) {
195 for entry in &mut triage.triage {
197 entry.decision = "BACKEND".into();
198 entry.reason = Some("title_fragmentation".into());
199 }
200 }
201
202 let backend_pages: BTreeSet<u32> = triage
203 .triage
204 .iter()
205 .filter(|e| e.decision == "BACKEND")
206 .map(|e| e.page)
207 .collect();
208
209 let wants_backend = matches!(config.hybrid_mode, HybridMode::Full) || !backend_pages.is_empty();
210
211 if !wants_backend {
212 return Ok(HybridResult {
213 markdown_override: None,
214 triage,
215 used_backend: false,
216 });
217 }
218
219 match fetch_backend_pages(input_path, config) {
220 Ok(mut backend) => {
221 let table_route = triage.triage.iter().any(|e| {
225 e.decision == "BACKEND"
226 && matches!(
227 e.reason.as_deref(),
228 Some(
229 "table"
230 | "sparse_table"
231 | "rulings_without_table"
232 | "orphan_column_grid"
233 )
234 )
235 });
236 let image_table_route = triage.triage.iter().any(|e| {
237 e.decision == "BACKEND"
238 && matches!(
239 e.reason.as_deref(),
240 Some("large_image_coverage" | "image_only")
241 )
242 });
243 let local_full = crate::output::markdown::to_markdown(doc).unwrap_or_default();
244 let backend_has_table = backend
245 .full_markdown
246 .as_deref()
247 .is_some_and(has_table_block);
248 let local_has_table = has_table_block(&local_full);
249 if table_route || image_table_route || backend_has_table || local_has_table {
250 let mut candidates: Vec<String> = Vec::new();
254 if local_has_table {
255 candidates.push(local_full.clone());
256 }
257 if let Some(fast) = backend.full_markdown.take() {
258 if has_table_block(&fast) {
259 candidates.push(fast);
260 }
261 }
262 let fast_cols = candidates
263 .iter()
264 .skip(if local_has_table { 1 } else { 0 })
265 .map(|md| table_mode_columns(md))
266 .max()
267 .unwrap_or(0);
268 let local_cols = if local_has_table {
269 table_mode_columns(&candidates[0])
270 } else {
271 0
272 };
273 let need_converter = table_route
276 || image_table_route
277 || fast_cols == 0
278 || local_cols > fast_cols
279 || fast_cols < 3
280 || candidates.iter().any(|md| table_filled_cells(md) < 8);
281 if need_converter {
282 if let Some(conv) = convert_pdf_via_document_converter(input_path) {
283 if has_table_block(&conv) {
284 candidates.push(conv);
285 }
286 }
287 }
288 if let Some(best) = candidates.into_iter().max_by(|a, b| {
289 table_mode_columns(a)
290 .cmp(&table_mode_columns(b))
291 .then_with(|| table_filled_cells(a).cmp(&table_filled_cells(b)))
292 }) {
293 backend.full_markdown = Some(best);
294 }
295 }
296 let override_md = merge_page_markdown(doc, &triage, &backend);
297 Ok(HybridResult {
298 markdown_override: Some(override_md),
299 triage,
300 used_backend: true,
301 })
302 }
303 Err(err) => {
304 if config.hybrid_fallback {
305 log::warn!(
306 "Hybrid backend failed for {}: {}; keeping local output",
307 input_path.display(),
308 err
309 );
310 Ok(HybridResult {
311 markdown_override: None,
312 triage,
313 used_backend: false,
314 })
315 } else {
316 Err(EdgePdfError::PipelineError {
317 stage: 21,
318 message: err,
319 })
320 }
321 }
322 }
323}
324
325#[cfg(not(target_arch = "wasm32"))]
331fn outline_heading_deficit(input_path: &Path, doc: &PdfDocument) -> bool {
332 let Ok(raw) = lopdf::Document::load(input_path) else {
333 return false;
334 };
335 let bookmarks = extract_bookmarks(&raw);
336 let outline_n = count_outline_entries(&bookmarks);
337 if outline_n < 2 {
338 return false;
339 }
340 let heading_n = doc
341 .kids
342 .iter()
343 .filter(|elem| match elem {
344 ContentElement::Heading(h) => h.base.base.semantic_type != SemanticType::Caption,
345 ContentElement::NumberHeading(_) => true,
346 _ => false,
347 })
348 .count();
349 heading_n * 2 < outline_n
350}
351
352#[cfg(not(target_arch = "wasm32"))]
353fn count_outline_entries(bookmarks: &[Bookmark]) -> usize {
354 bookmarks
355 .iter()
356 .map(|b| 1 + count_outline_entries(&b.children))
357 .sum()
358}
359
360fn typographic_heading_deficit(doc: &PdfDocument) -> bool {
368 let mut body_sizes: Vec<f64> = Vec::new();
369 let mut body_weights: Vec<f64> = Vec::new();
370 let mut recovered = 0usize;
371
372 for elem in &doc.kids {
373 match elem {
374 ContentElement::Paragraph(p) => {
375 if let Some(fs) = p.base.font_size.or(p.base.max_font_size) {
376 if fs > 0.0 {
377 body_sizes.push(fs);
378 }
379 }
380 if let Some(w) = p.base.font_weight {
381 body_weights.push(w);
382 }
383 }
384 ContentElement::Heading(h)
385 if h.base.base.semantic_type != SemanticType::Caption => {
386 recovered += 1;
387 }
388 ContentElement::NumberHeading(_) => {
389 recovered += 1;
390 }
391 _ => {}
392 }
393 }
394
395 if body_sizes.len() < 3 {
396 return false;
397 }
398 body_sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
399 let body_median = body_sizes[body_sizes.len() / 2];
400 if body_median <= 0.0 {
401 return false;
402 }
403 body_weights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
404 let weight_median = if body_weights.is_empty() {
405 400.0
406 } else {
407 body_weights[body_weights.len() / 2]
408 };
409
410 let size_floor = body_median * 1.15;
411 let mut candidates = 0usize;
412 for elem in &doc.kids {
413 let ContentElement::Paragraph(p) = elem else {
414 continue;
415 };
416 let text = p.base.value();
417 let trimmed = text.trim();
418 if trimmed.is_empty() || float_caption_prefix(trimmed) {
419 continue;
420 }
421 let lines = p.base.lines_number();
422 if !(1..=3).contains(&lines) {
423 continue;
424 }
425 if trimmed.chars().count() > 120 {
427 continue;
428 }
429 let fs = p.base.font_size.or(p.base.max_font_size).unwrap_or(0.0);
430 let weight = p.base.font_weight.unwrap_or(400.0);
431 let size_hit = fs >= size_floor;
432 let weight_hit = weight >= 600.0 && weight > weight_median;
433 if size_hit || weight_hit {
434 candidates += 1;
435 }
436 }
437
438 candidates >= 2 && recovered * 2 < candidates
439}
440
441fn float_caption_prefix(text: &str) -> bool {
442 let lower = text.to_lowercase();
443 for (prefix, skip) in [("figure ", 7), ("fig. ", 5), ("fig ", 4), ("table ", 6)] {
444 if lower.starts_with(prefix) {
445 let rest = text.get(skip..).unwrap_or("").trim_start();
446 return rest.chars().next().is_some_and(|c| c.is_ascii_digit());
447 }
448 }
449 false
450}
451
452fn title_fragmentation_deficit(doc: &PdfDocument) -> bool {
458 let mut run = 0usize;
459 let mut max_run = 0usize;
460 for elem in &doc.kids {
461 match elem {
462 ContentElement::Heading(h) => {
463 let text = h.base.base.value();
464 if float_caption_prefix(text.trim()) {
465 max_run = max_run.max(run);
466 run = 0;
467 continue;
468 }
469 run += 1;
470 max_run = max_run.max(run);
471 }
472 ContentElement::NumberHeading(_) => {
473 run += 1;
474 max_run = max_run.max(run);
475 }
476 ContentElement::Paragraph(p) => {
477 let n = p
478 .base
479 .value()
480 .chars()
481 .filter(|c| !c.is_whitespace())
482 .count();
483 if n >= 40 {
484 max_run = max_run.max(run);
485 run = 0;
486 }
487 }
488 ContentElement::Caption(_) | ContentElement::Image(_) | ContentElement::Figure(_) => {
489 }
491 _ => {
492 max_run = max_run.max(run);
493 run = 0;
494 }
495 }
496 }
497 max_run.max(run) >= 3
498}
499
500fn merge_page_markdown(
505 doc: &PdfDocument,
506 triage: &TriageReport,
507 backend: &BackendPayload,
508) -> String {
509 let local_full = crate::output::markdown::to_markdown(doc).unwrap_or_default();
510 let backend_pages: BTreeSet<u32> = triage
511 .triage
512 .iter()
513 .filter(|e| e.decision == "BACKEND")
514 .map(|e| e.page)
515 .collect();
516 let reasons: Vec<&str> = triage
517 .triage
518 .iter()
519 .filter(|e| e.decision == "BACKEND")
520 .filter_map(|e| e.reason.as_deref())
521 .collect();
522
523 let table_route = reasons.iter().any(|r| {
524 matches!(
525 *r,
526 "table" | "sparse_table" | "rulings_without_table" | "orphan_column_grid"
527 )
528 });
529 let heading_route = reasons.iter().any(|r| {
530 matches!(
531 *r,
532 "outline_heading_deficit" | "heading_inventory_deficit" | "title_fragmentation"
533 )
534 });
535 let image_layout_route = reasons.iter().any(|r| {
536 matches!(
537 *r,
538 "large_image_coverage" | "image_only" | "multi_column_layout"
539 )
540 });
541 let only_image_layout = image_layout_route && !table_route && !heading_route;
542
543 if !backend_pages.is_empty() {
544 if let Some(ref full) = backend.full_markdown {
545 if table_route || heading_route {
546 if backend_preferred_for_structure(full, &local_full, table_route) {
547 return full.clone();
548 }
549 } else if only_image_layout {
550 if non_whitespace_len(full) >= non_whitespace_len(&local_full) {
552 return full.clone();
553 }
554 } else if backend_document_preferred(full, &local_full) {
555 return full.clone();
556 }
557 }
558 }
559
560 let mut parts = Vec::with_capacity(doc.number_of_pages as usize);
562 for page in 1..=doc.number_of_pages {
563 let local = local_page_markdown(doc, page);
564 if backend_pages.contains(&page) {
565 let page_reason = triage
566 .triage
567 .iter()
568 .find(|e| e.page == page)
569 .and_then(|e| e.reason.as_deref());
570 let page_table = matches!(
571 page_reason,
572 Some("table" | "sparse_table" | "rulings_without_table" | "orphan_column_grid")
573 );
574 let page_image = matches!(
575 page_reason,
576 Some("large_image_coverage" | "image_only" | "multi_column_layout")
577 );
578 if let Some(md) = backend.pages.get(&page) {
579 if page_table {
580 if has_table_block(md) || backend_page_preferred(md, &local) {
581 parts.push(md.clone());
582 continue;
583 }
584 } else if page_image && !page_table {
585 if non_whitespace_len(md) >= non_whitespace_len(&local) {
586 parts.push(md.clone());
587 continue;
588 }
589 } else if backend_page_preferred(md, &local) {
590 parts.push(md.clone());
591 continue;
592 }
593 }
594 }
595 parts.push(local);
596 }
597 let stitched = parts
598 .into_iter()
599 .filter(|p| !p.trim().is_empty())
600 .collect::<Vec<_>>()
601 .join("\n\n");
602 if stitched.trim().is_empty() {
603 local_full
604 } else {
605 stitched
606 }
607}
608
609fn non_whitespace_len(s: &str) -> usize {
610 s.chars().filter(|c| !c.is_whitespace()).count()
611}
612
613fn has_table_block(md: &str) -> bool {
614 md.lines().any(|l| {
615 let t = l.trim_start();
616 t.starts_with('|') || t.starts_with("<table")
617 })
618}
619
620fn table_mode_columns(s: &str) -> usize {
625 let mut best = 0usize;
626 let lines: Vec<&str> = s.lines().collect();
627 let mut i = 0usize;
628 while i < lines.len() {
629 if !lines[i].trim_start().starts_with('|') {
630 i += 1;
631 continue;
632 }
633 let start = i;
634 while i < lines.len() && lines[i].trim_start().starts_with('|') {
635 i += 1;
636 }
637 best = best.max(mode_columns_in_block(&lines[start..i]));
638 }
639 if s.to_ascii_lowercase().contains("<table") {
641 best = best.max(2);
642 }
643 best
644}
645
646#[allow(dead_code)] fn table_filled_cells(s: &str) -> usize {
649 let mut filled = 0usize;
650 for line in s.lines() {
651 let t = line.trim_start();
652 if t.starts_with('|') {
653 let parts: Vec<&str> = t
654 .trim()
655 .trim_matches('|')
656 .split('|')
657 .map(str::trim)
658 .collect();
659 if parts
660 .iter()
661 .all(|p| !p.is_empty() && p.chars().all(|c| matches!(c, '-' | ':' | ' ')))
662 {
663 continue;
664 }
665 filled += parts.iter().filter(|p| !p.is_empty()).count();
666 } else if t.to_ascii_lowercase().contains("<td") || t.to_ascii_lowercase().contains("<th") {
667 if !t.contains("></") && t.contains('>') {
669 filled += 1;
670 }
671 }
672 }
673 filled
674}
675
676fn mode_columns_in_block(lines: &[&str]) -> usize {
677 let mut rows: Vec<Vec<String>> = Vec::new();
678 for line in lines {
679 let parts: Vec<String> = line
680 .trim()
681 .trim_matches('|')
682 .split('|')
683 .map(|p| p.trim().to_string())
684 .collect();
685 if parts.is_empty() {
686 continue;
687 }
688 if parts
689 .iter()
690 .all(|p| !p.is_empty() && p.chars().all(|c| matches!(c, '-' | ':' | ' ')))
691 {
692 continue;
693 }
694 rows.push(parts);
695 }
696 if rows.is_empty() {
697 return 0;
698 }
699 let max_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
700 if max_cols == 0 {
701 return 0;
702 }
703 let mut keep = vec![true; max_cols];
705 for c in 0..max_cols {
706 let mut empty = 0usize;
707 let mut seen = 0usize;
708 for row in &rows {
709 if c >= row.len() {
710 empty += 1;
711 seen += 1;
712 continue;
713 }
714 seen += 1;
715 if row[c].is_empty() {
716 empty += 1;
717 }
718 }
719 if seen > 0 && empty * 2 >= seen {
720 keep[c] = false;
721 }
722 }
723 let mut effective_counts: Vec<usize> = Vec::new();
724 for row in &rows {
725 let n = (0..row.len())
726 .filter(|&c| keep.get(c).copied().unwrap_or(false))
727 .count();
728 if n > 0 {
729 effective_counts.push(n);
730 }
731 }
732 if effective_counts.is_empty() {
733 return max_cols;
734 }
735 let mut freq: Vec<(usize, usize)> = Vec::new();
736 for &c in &effective_counts {
737 if let Some((_, n)) = freq.iter_mut().find(|(k, _)| *k == c) {
738 *n += 1;
739 } else {
740 freq.push((c, 1));
741 }
742 }
743 freq.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.0.cmp(&a.0)));
744 freq[0].0
745}
746
747#[allow(dead_code)] fn table_lattice_score(s: &str) -> usize {
754 let cols = table_mode_columns(s);
755 let mut rows = 0usize;
756 for line in s.lines() {
757 let t = line.trim_start();
758 if t.starts_with('|') || t.starts_with("<tr") || t.starts_with("<table") {
759 rows += 1;
760 }
761 }
762 cols.saturating_mul(cols).saturating_mul(10_000) + rows.saturating_mul(10)
763}
764
765fn backend_preferred_for_structure(backend_md: &str, local_md: &str, table_route: bool) -> bool {
767 let backend = backend_md.trim();
768 if backend.is_empty() {
769 return false;
770 }
771 if table_route {
772 let backend_table = has_table_block(backend);
773 let local_table = has_table_block(local_md);
774 if backend_table && local_table {
775 return table_mode_columns(backend) >= table_mode_columns(local_md);
776 }
777 if backend_table {
778 return true;
779 }
780 if local_table && !backend_table {
781 return false;
782 }
783 }
784 backend_document_preferred(backend_md, local_md)
785}
786
787fn backend_document_preferred(backend_md: &str, local_md: &str) -> bool {
789 let backend = backend_md.trim();
790 if backend.is_empty() {
791 return false;
792 }
793 let local = local_md.trim();
794 if local.is_empty() {
795 return true;
796 }
797 let b_len = non_whitespace_len(backend) as f64;
798 let l_len = non_whitespace_len(local) as f64;
799 if l_len <= 0.0 {
800 return true;
801 }
802 if b_len < l_len * 0.85 {
803 return false;
804 }
805 if has_table_block(local) && !has_table_block(backend) {
807 return false;
808 }
809 true
810}
811
812fn backend_page_preferred(backend_md: &str, local_md: &str) -> bool {
814 let backend = backend_md.trim();
815 if backend.is_empty() {
816 return false;
817 }
818 if has_table_block(backend) {
819 return true;
820 }
821 let b_len = non_whitespace_len(backend) as f64;
822 let l_len = non_whitespace_len(local_md) as f64;
823 if l_len <= 0.0 {
824 return true;
825 }
826 b_len >= l_len * 0.85
827}
828
829fn local_page_markdown(doc: &PdfDocument, page: u32) -> String {
830 let mut page_doc = doc.clone();
831 page_doc.kids.retain(|e| e.page_number() == Some(page));
832 page_doc.number_of_pages = 1;
833 page_doc.title = None; crate::output::markdown::to_markdown(&page_doc).unwrap_or_default()
835}
836
837#[derive(Debug, Default)]
838struct BackendPayload {
839 full_markdown: Option<String>,
840 pages: BTreeMap<u32, String>,
841}
842
843#[cfg(not(target_arch = "wasm32"))]
845pub fn write_triage_report(output_dir: &Path, report: &TriageReport) -> Result<(), EdgePdfError> {
846 use std::fs;
847 fs::create_dir_all(output_dir).map_err(EdgePdfError::IoError)?;
848 let path = output_dir.join("triage.json");
849 let json = serde_json::to_string_pretty(report)
850 .map_err(|e| EdgePdfError::OutputError(format!("Failed to serialize triage.json: {e}")))?;
851 fs::write(&path, json).map_err(EdgePdfError::IoError)?;
852 Ok(())
853}
854
855fn triage_document(doc: &PdfDocument, config: &ProcessingConfig) -> TriageReport {
856 let mut by_page: BTreeMap<u32, PageSignals> = BTreeMap::new();
857 for page in 1..=doc.number_of_pages {
858 by_page.insert(page, PageSignals::default());
859 }
860
861 for elem in &doc.kids {
862 let Some(page) = elem.page_number() else {
863 continue;
864 };
865 let signals = by_page.entry(page).or_default();
866 match elem {
867 ContentElement::TableBorder(t) => {
868 signals.table_count += 1;
869 let cells: usize = t.rows.iter().map(|r| r.cells.len()).sum();
870 let filled = t
871 .rows
872 .iter()
873 .flat_map(|r| r.cells.iter())
874 .filter(|c| {
875 c.content
876 .iter()
877 .any(|tok| !tok.base.value.trim().is_empty())
878 || !c.contents.is_empty()
879 })
880 .count();
881 if cells > 0 && (filled as f64 / cells as f64) < 0.35 {
882 signals.sparse_table = true;
883 }
884 if t.rows.len() >= 2 && t.rows[0].cells.len() >= 2 {
885 signals.likely_table = true;
886 }
887 }
888 ContentElement::Image(img) => {
889 signals.image_count += 1;
890 signals.image_area += img.bbox.width().max(0.0) * img.bbox.height().max(0.0);
891 expand_page_extent(signals, &img.bbox);
892 }
893 ContentElement::Figure(fig) => {
894 signals.image_count += 1;
895 signals.image_area += fig.bbox.width().max(0.0) * fig.bbox.height().max(0.0);
896 expand_page_extent(signals, &fig.bbox);
897 }
898 ContentElement::Picture(pic) => {
899 signals.image_count += 1;
900 signals.image_area += pic.bbox.width().max(0.0) * pic.bbox.height().max(0.0);
901 expand_page_extent(signals, &pic.bbox);
902 }
903 ContentElement::Table(t) => {
904 signals.table_count += 1;
905 signals.likely_table = true;
906 expand_page_extent(signals, &t.bbox);
907 }
908 ContentElement::Caption(c) => {
909 expand_page_extent(signals, &c.base.bbox);
910 }
911 ContentElement::Paragraph(p) => {
912 expand_page_extent(signals, &p.base.bbox);
913 record_token_candidate(signals, &p.base.bbox);
914 signals.block_xs.push(p.base.bbox.center_x());
915 signals.block_ys.push(p.base.bbox.center_y());
916 signals.block_widths.push(p.base.bbox.width().max(0.0));
917 }
918 ContentElement::Heading(h) => {
919 expand_page_extent(signals, &h.base.base.bbox);
920 }
921 ContentElement::TextChunk(t) => {
922 expand_page_extent(signals, &t.bbox);
923 record_token_candidate(signals, &t.bbox);
924 }
925 ContentElement::TextLine(tl) => {
926 expand_page_extent(signals, &tl.bbox);
927 record_token_candidate(signals, &tl.bbox);
928 }
929 ContentElement::Line(line) => {
930 signals.line_count += 1;
931 if line.is_horizontal_line {
932 signals.h_line_count += 1;
933 } else if line.is_vertical_line {
934 signals.v_line_count += 1;
935 }
936 expand_page_extent(signals, &line.bbox);
937 }
938 ContentElement::LineArt(art) => {
939 signals.line_count += 1;
940 expand_page_extent(signals, &art.bbox);
941 }
942 _ => {}
943 }
944 }
945
946 let mut entries = Vec::new();
947 for (page, mut signals) in by_page {
948 finalize_narrow_tokens(&mut signals);
949 let page_area = signals.page_area();
950 let orphan_grid = orphan_column_grid(&signals);
951 let multi_col = multi_column_block_layout(&signals);
952 let image_frac = if page_area > 0.0 {
953 signals.image_area / page_area
954 } else {
955 0.0
956 };
957 let rulings_without_table =
958 signals.h_line_count >= 3 && signals.v_line_count >= 3 && signals.table_count == 0;
959
960 let (decision, reason) = if matches!(config.hybrid_mode, HybridMode::Full) {
961 ("BACKEND".into(), Some("hybrid-mode=full".into()))
962 } else if signals.sparse_table {
963 ("BACKEND".into(), Some("sparse_table".into()))
964 } else if signals.table_count > 0 || signals.likely_table {
965 ("BACKEND".into(), Some("table".into()))
966 } else if rulings_without_table {
967 ("BACKEND".into(), Some("rulings_without_table".into()))
968 } else if orphan_grid {
969 ("BACKEND".into(), Some("orphan_column_grid".into()))
970 } else if multi_col && signals.table_count == 0 {
971 ("BACKEND".into(), Some("multi_column_layout".into()))
972 } else if image_frac >= 0.25 {
973 ("BACKEND".into(), Some("large_image_coverage".into()))
974 } else if signals.image_count > 0
975 && signals.block_xs.is_empty()
976 && signals.token_xs.is_empty()
977 {
978 ("BACKEND".into(), Some("image_only".into()))
979 } else {
980 ("JAVA".into(), None)
981 };
982 entries.push(TriageEntry {
983 page,
984 decision,
985 reason,
986 });
987 }
988
989 TriageReport {
990 document: doc.file_name.clone(),
991 triage: entries,
992 }
993}
994
995#[derive(Default)]
996struct PageSignals {
997 table_count: usize,
998 sparse_table: bool,
999 likely_table: bool,
1000 image_count: usize,
1001 image_area: f64,
1002 line_count: usize,
1003 h_line_count: usize,
1004 v_line_count: usize,
1005 token_xs: Vec<f64>,
1006 token_ys: Vec<f64>,
1007 token_widths: Vec<f64>,
1008 block_xs: Vec<f64>,
1009 block_ys: Vec<f64>,
1010 block_widths: Vec<f64>,
1011 page_min_x: f64,
1012 page_min_y: f64,
1013 page_max_x: f64,
1014 page_max_y: f64,
1015 extent_init: bool,
1016}
1017
1018impl PageSignals {
1019 fn page_area(&self) -> f64 {
1020 if !self.extent_init {
1021 return 0.0;
1022 }
1023 (self.page_max_x - self.page_min_x).max(0.0) * (self.page_max_y - self.page_min_y).max(0.0)
1024 }
1025}
1026
1027fn expand_page_extent(signals: &mut PageSignals, bbox: &crate::models::bbox::BoundingBox) {
1028 if !signals.extent_init {
1029 signals.page_min_x = bbox.left_x;
1030 signals.page_max_x = bbox.right_x;
1031 signals.page_min_y = bbox.bottom_y;
1032 signals.page_max_y = bbox.top_y;
1033 signals.extent_init = true;
1034 return;
1035 }
1036 signals.page_min_x = signals.page_min_x.min(bbox.left_x);
1037 signals.page_max_x = signals.page_max_x.max(bbox.right_x);
1038 signals.page_min_y = signals.page_min_y.min(bbox.bottom_y);
1039 signals.page_max_y = signals.page_max_y.max(bbox.top_y);
1040}
1041
1042fn record_token_candidate(signals: &mut PageSignals, bbox: &crate::models::bbox::BoundingBox) {
1043 signals.token_xs.push(bbox.center_x());
1044 signals.token_ys.push(bbox.center_y());
1045 signals.token_widths.push(bbox.width().max(0.0));
1046}
1047
1048fn finalize_narrow_tokens(signals: &mut PageSignals) {
1050 let page_w = if signals.extent_init {
1051 (signals.page_max_x - signals.page_min_x).max(1.0)
1052 } else {
1053 return;
1054 };
1055 let threshold = page_w * 0.15;
1056 let mut xs = Vec::new();
1057 let mut ys = Vec::new();
1058 let mut ws = Vec::new();
1059 for i in 0..signals.token_xs.len() {
1060 if signals.token_widths[i] <= threshold {
1061 xs.push(signals.token_xs[i]);
1062 ys.push(signals.token_ys[i]);
1063 ws.push(signals.token_widths[i]);
1064 }
1065 }
1066 signals.token_xs = xs;
1067 signals.token_ys = ys;
1068 signals.token_widths = ws;
1069}
1070
1071fn orphan_column_grid(signals: &PageSignals) -> bool {
1072 if signals.table_count > 0 || signals.token_xs.len() < 8 {
1073 return false;
1074 }
1075 let x_eps = adaptive_cluster_epsilon(&signals.token_xs);
1076 let y_eps = adaptive_cluster_epsilon(&signals.token_ys);
1077 let col_clusters = cluster_1d(&signals.token_xs, x_eps);
1078 let significant_cols = col_clusters.iter().filter(|c| c.len() >= 3).count();
1079 if significant_cols < 2 {
1080 return false;
1081 }
1082 let row_clusters = cluster_1d(&signals.token_ys, y_eps);
1083 let significant_rows = row_clusters.iter().filter(|c| c.len() >= 2).count();
1084 significant_rows >= 3
1085}
1086
1087fn multi_column_block_layout(signals: &PageSignals) -> bool {
1088 if signals.table_count > 0 || signals.block_xs.len() < 4 {
1089 return false;
1090 }
1091 let xs = &signals.block_xs;
1092 let min_x = xs.iter().cloned().fold(f64::INFINITY, f64::min);
1093 let max_x = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1094 let span = max_x - min_x;
1095 if span < 1.0 {
1096 return false;
1097 }
1098 let mut widths = signals.block_widths.clone();
1099 widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1100 let median_w = widths[widths.len() / 2];
1101 if median_w > span * 0.45 {
1102 return false;
1103 }
1104 let clusters = cluster_1d(xs, span * 0.20);
1105 let significant: Vec<&Vec<f64>> = clusters.iter().filter(|c| c.len() >= 2).collect();
1106 if significant.len() < 2 {
1107 return false;
1108 }
1109 let y_eps = adaptive_cluster_epsilon(&signals.block_ys);
1110 let y_bands = cluster_1d(&signals.block_ys, y_eps);
1111 for band in &y_bands {
1112 if band.len() < 2 {
1113 continue;
1114 }
1115 let mut cluster_hits = std::collections::HashSet::new();
1116 for (i, &y) in signals.block_ys.iter().enumerate() {
1117 if band.iter().any(|by| (by - y).abs() <= y_eps) {
1118 let x = signals.block_xs[i];
1119 for (ci, cluster) in significant.iter().enumerate() {
1120 if cluster.iter().any(|cx| (cx - x).abs() <= span * 0.20) {
1121 cluster_hits.insert(ci);
1122 }
1123 }
1124 }
1125 }
1126 if cluster_hits.len() >= 2 {
1127 return true;
1128 }
1129 }
1130 false
1131}
1132
1133fn adaptive_cluster_epsilon(values: &[f64]) -> f64 {
1134 if values.len() < 2 {
1135 return 12.0;
1136 }
1137 let mut sorted = values.to_vec();
1138 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1139 let mut gaps: Vec<f64> = sorted.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
1140 gaps.retain(|g| *g > 1e-6);
1141 if gaps.is_empty() {
1142 return 12.0;
1143 }
1144 gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1145 (gaps[gaps.len() / 2] * 0.5).max(4.0)
1146}
1147
1148fn cluster_1d(values: &[f64], epsilon: f64) -> Vec<Vec<f64>> {
1150 if values.is_empty() {
1151 return Vec::new();
1152 }
1153 let mut sorted = values.to_vec();
1154 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1155 let mut clusters: Vec<Vec<f64>> = vec![vec![sorted[0]]];
1156 for &v in &sorted[1..] {
1157 let last = clusters.last_mut().expect("non-empty");
1158 if (v - last[last.len() - 1]).abs() <= epsilon {
1159 last.push(v);
1160 } else {
1161 clusters.push(vec![v]);
1162 }
1163 }
1164 clusters
1165}
1166
1167#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1168fn backend_url(config: &ProcessingConfig) -> String {
1169 config
1170 .hybrid_url
1171 .clone()
1172 .unwrap_or_else(|| DEFAULT_URL.to_string())
1173 .trim_end_matches('/')
1174 .to_string()
1175}
1176
1177#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1178fn fetch_backend_pages(
1179 input_path: &Path,
1180 config: &ProcessingConfig,
1181) -> Result<BackendPayload, String> {
1182 let base = backend_url(config);
1183 let timeout = Duration::from_millis(config.hybrid_timeout.max(1_000));
1184 let client = reqwest::blocking::Client::builder()
1185 .timeout(timeout)
1186 .build()
1187 .map_err(|e| format!("HTTP client: {e}"))?;
1188
1189 let health = client
1190 .get(format!("{base}{HEALTH_ENDPOINT}"))
1191 .timeout(Duration::from_secs(3))
1192 .send()
1193 .map_err(|e| {
1194 format!(
1195 "Hybrid server unavailable at {base} ({e}). \
1196 Start with: opendataloader-pdf-hybrid --port 5002"
1197 )
1198 })?;
1199 if !health.status().is_success() {
1200 return Err(format!(
1201 "Hybrid health check failed at {base}: HTTP {}",
1202 health.status()
1203 ));
1204 }
1205
1206 let pdf_bytes = fs::read(input_path).map_err(|e| format!("Read PDF: {e}"))?;
1207 let filename = input_path
1208 .file_name()
1209 .and_then(|s| s.to_str())
1210 .unwrap_or("document.pdf")
1211 .to_string();
1212
1213 let part = reqwest::blocking::multipart::Part::bytes(pdf_bytes)
1214 .file_name(filename)
1215 .mime_str("application/pdf")
1216 .map_err(|e| format!("multipart: {e}"))?;
1217
1218 let form = reqwest::blocking::multipart::Form::new()
1219 .part("files", part)
1220 .text("to_formats", "md")
1221 .text("to_formats", "json");
1222
1223 let response = client
1224 .post(format!("{base}{CONVERT_ENDPOINT}"))
1225 .multipart(form)
1226 .send()
1227 .map_err(|e| format!("convert request failed: {e}"))?;
1228
1229 if !response.status().is_success() {
1230 let status = response.status();
1231 let body = response.text().unwrap_or_default();
1232 return Err(format!("convert HTTP {status}: {body}"));
1233 }
1234
1235 let body: Value = response
1236 .json()
1237 .map_err(|e| format!("invalid JSON response: {e}"))?;
1238
1239 let status = body.get("status").and_then(|s| s.as_str()).unwrap_or("");
1240 if status == "failure" {
1241 return Err(format!(
1242 "backend failure: {}",
1243 body.get("errors")
1244 .map(|e| e.to_string())
1245 .unwrap_or_default()
1246 ));
1247 }
1248
1249 let mut payload = BackendPayload::default();
1250
1251 if let Some(json_content) = body.pointer("/document/json_content") {
1252 if let Some(md) = export_docling_json_canonical(json_content) {
1254 payload.full_markdown = Some(md);
1255 }
1256 let (pages, joined) = markdown_pages_from_docling_json(json_content);
1257 payload.pages = pages;
1258 if payload.full_markdown.is_none() {
1259 payload.full_markdown = joined;
1260 }
1261 }
1262
1263 if let Some(md) = body
1264 .pointer("/document/md_content")
1265 .and_then(|v| v.as_str())
1266 .map(str::to_string)
1267 {
1268 if !md.trim().is_empty() && payload.full_markdown.is_none() {
1269 payload.full_markdown = Some(md);
1270 }
1271 }
1272
1273 if payload.full_markdown.is_none() && payload.pages.is_empty() {
1274 return Err("backend response missing markdown content".into());
1275 }
1276 Ok(payload)
1277}
1278
1279#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1286fn export_docling_json_canonical(json_content: &Value) -> Option<String> {
1287 use std::sync::atomic::{AtomicU64, Ordering};
1288 static SEQ: AtomicU64 = AtomicU64::new(0);
1289 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
1290 let tmp_dir = std::env::temp_dir();
1291 let json_path = tmp_dir.join(format!(
1292 "edgeparse-docling-{}-{seq}.json",
1293 std::process::id()
1294 ));
1295 let out_path = tmp_dir.join(format!("edgeparse-docling-{}-{seq}.md", std::process::id()));
1296 let json_str = serde_json::to_string(json_content).ok()?;
1297 fs::write(&json_path, json_str).ok()?;
1298
1299 let pythons = candidate_docling_pythons();
1300 let script = r#"
1301import json, sys
1302from docling_core.types.doc import DoclingDocument
1303src, dst = sys.argv[1], sys.argv[2]
1304doc = DoclingDocument.model_validate(json.load(open(src, encoding="utf-8")))
1305open(dst, "w", encoding="utf-8").write(doc.export_to_markdown())
1306"#;
1307
1308 let mut out = None;
1309 for python in pythons {
1310 let result = std::process::Command::new(&python)
1311 .args(["-c", script, json_path.to_str()?, out_path.to_str()?])
1312 .output();
1313 match result {
1314 Ok(output) if output.status.success() => {
1315 if let Ok(md) = fs::read_to_string(&out_path) {
1316 if !md.trim().is_empty() {
1317 out = Some(md);
1318 break;
1319 }
1320 }
1321 }
1322 _ => continue,
1323 }
1324 }
1325 let _ = fs::remove_file(&json_path);
1326 let _ = fs::remove_file(&out_path);
1327 out
1328}
1329
1330#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1331fn candidate_docling_pythons() -> Vec<String> {
1332 let mut out = Vec::new();
1333 if let Ok(p) = std::env::var("EDGEPARSE_DOCLING_PYTHON") {
1334 if !p.is_empty() {
1335 out.push(p);
1336 }
1337 }
1338 let mut dir = std::env::current_dir().ok();
1340 for _ in 0..6 {
1341 let Some(cwd) = dir else { break };
1342 for rel in [
1343 "benchmark/.venvs/hybrid/bin/python",
1344 "odl-bench/.venv/bin/python",
1345 ".venv/bin/python",
1346 ] {
1347 let path = cwd.join(rel);
1348 if path.exists() {
1349 out.push(path.to_string_lossy().into_owned());
1350 }
1351 }
1352 dir = cwd.parent().map(Path::to_path_buf);
1353 }
1354 out.push("python3".into());
1355 out
1356}
1357
1358#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1363fn convert_pdf_via_document_converter(pdf_path: &Path) -> Option<String> {
1364 use std::sync::atomic::{AtomicU64, Ordering};
1365 static SEQ: AtomicU64 = AtomicU64::new(0);
1366 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
1367 let out_path = std::env::temp_dir().join(format!(
1368 "edgeparse-docling-conv-{}-{seq}.md",
1369 std::process::id()
1370 ));
1371 let script = r#"
1372import sys
1373from docling.document_converter import DocumentConverter
1374pdf, dst = sys.argv[1], sys.argv[2]
1375md = DocumentConverter().convert(pdf).document.export_to_markdown()
1376open(dst, "w", encoding="utf-8").write(md)
1377"#;
1378 let mut out = None;
1379 for python in candidate_docling_pythons() {
1380 let result = std::process::Command::new(&python)
1381 .args(["-c", script, pdf_path.to_str()?, out_path.to_str()?])
1382 .output();
1383 match result {
1384 Ok(output) if output.status.success() => {
1385 if let Ok(md) = fs::read_to_string(&out_path) {
1386 if !md.trim().is_empty() {
1387 out = Some(md);
1388 break;
1389 }
1390 }
1391 }
1392 _ => continue,
1393 }
1394 }
1395 let _ = fs::remove_file(&out_path);
1396 out
1397}
1398
1399#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1405fn markdown_pages_from_docling_json(doc: &Value) -> (BTreeMap<u32, String>, Option<String>) {
1406 let mut page_parts: BTreeMap<u32, Vec<String>> = BTreeMap::new();
1407 let mut full_parts: Vec<String> = Vec::new();
1408
1409 if let Some(children) = doc.pointer("/body/children").and_then(|c| c.as_array()) {
1410 for child in children {
1411 emit_docling_node(doc, child, &mut page_parts, &mut full_parts);
1412 }
1413 }
1414
1415 if full_parts.is_empty() {
1417 if let Some(texts) = doc.get("texts").and_then(|t| t.as_array()) {
1418 for item in texts {
1419 if let Some((page, rendered)) = render_docling_text(item) {
1420 page_parts.entry(page).or_default().push(rendered.clone());
1421 full_parts.push(rendered);
1422 }
1423 }
1424 }
1425 if let Some(tables) = doc.get("tables").and_then(|t| t.as_array()) {
1426 for table in tables {
1427 let page = item_page(table);
1428 if let Some(grid) = table_to_markdown(table) {
1429 page_parts.entry(page).or_default().push(grid.clone());
1430 full_parts.push(grid);
1431 }
1432 }
1433 }
1434 }
1435
1436 let pages: BTreeMap<u32, String> = page_parts
1437 .into_iter()
1438 .map(|(p, parts)| (p, parts.join("\n\n")))
1439 .collect();
1440 let joined = if full_parts.is_empty() {
1441 None
1442 } else {
1443 Some(full_parts.join("\n\n"))
1444 };
1445 (pages, joined)
1446}
1447
1448#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1449fn emit_docling_node(
1450 doc: &Value,
1451 node: &Value,
1452 page_parts: &mut BTreeMap<u32, Vec<String>>,
1453 full_parts: &mut Vec<String>,
1454) {
1455 let Some(resolved) = resolve_docling_ref(doc, node) else {
1456 return;
1457 };
1458 let label = resolved.get("label").and_then(|l| l.as_str()).unwrap_or("");
1459
1460 match label {
1461 "group" | "key_value_area" | "list" | "ordered_list" | "section" | "unspecified" => {
1462 if let Some(children) = resolved.get("children").and_then(|c| c.as_array()) {
1463 for child in children {
1464 emit_docling_node(doc, child, page_parts, full_parts);
1465 }
1466 }
1467 }
1468 "table" | "document_index" => {
1469 let page = item_page(resolved);
1470 if let Some(captions) = resolved.get("captions").and_then(|c| c.as_array()) {
1472 for cap in captions {
1473 emit_docling_node(doc, cap, page_parts, full_parts);
1474 }
1475 }
1476 if let Some(grid) = table_to_markdown(resolved) {
1477 page_parts.entry(page).or_default().push(grid.clone());
1478 full_parts.push(grid);
1479 }
1480 }
1481 "picture" | "chart" => {
1482 if let Some(captions) = resolved.get("captions").and_then(|c| c.as_array()) {
1484 for cap in captions {
1485 emit_docling_node(doc, cap, page_parts, full_parts);
1486 }
1487 }
1488 if let Some(children) = resolved.get("children").and_then(|c| c.as_array()) {
1489 for child in children {
1490 emit_docling_node(doc, child, page_parts, full_parts);
1491 }
1492 }
1493 }
1494 "formula" => {
1495 if let Some(text) = resolved.get("text").and_then(|t| t.as_str()) {
1496 let t = text.trim();
1497 if !t.is_empty() {
1498 let page = item_page(resolved);
1499 page_parts.entry(page).or_default().push(t.to_string());
1500 full_parts.push(t.to_string());
1501 }
1502 }
1503 }
1504 _ => {
1505 if let Some((page, rendered)) = render_docling_text(resolved) {
1506 page_parts.entry(page).or_default().push(rendered.clone());
1507 full_parts.push(rendered);
1508 }
1509 }
1510 }
1511}
1512
1513#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1514fn resolve_docling_ref<'a>(doc: &'a Value, node: &'a Value) -> Option<&'a Value> {
1515 if let Some(r) = node.get("$ref").and_then(|v| v.as_str()) {
1516 let path = r.trim_start_matches('#');
1517 return doc.pointer(path);
1518 }
1519 Some(node)
1521}
1522
1523#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1524fn item_page(item: &Value) -> u32 {
1525 item.pointer("/prov/0/page_no")
1526 .and_then(|p| p.as_u64())
1527 .unwrap_or(1) as u32
1528}
1529
1530#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1531fn render_docling_text(item: &Value) -> Option<(u32, String)> {
1532 let layer = item
1533 .get("content_layer")
1534 .and_then(|l| l.as_str())
1535 .unwrap_or("body");
1536 if layer == "furniture" {
1537 return None;
1538 }
1539 let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("text");
1540 if matches!(label, "page_header" | "page_footer" | "footnote") {
1541 return None;
1542 }
1543 let text = item
1544 .get("text")
1545 .and_then(|t| t.as_str())
1546 .unwrap_or("")
1547 .trim();
1548 if text.is_empty() {
1549 return None;
1550 }
1551 let page = item_page(item);
1552 let rendered = match label {
1553 "title" => format!("# {text}"),
1554 "section_header" => {
1555 let level = item
1557 .get("level")
1558 .and_then(|l| l.as_u64())
1559 .unwrap_or(1)
1560 .saturating_add(1)
1561 .clamp(1, 6) as usize;
1562 format!("{} {text}", "#".repeat(level))
1563 }
1564 _ => text.to_string(),
1565 };
1566 Some((page, rendered))
1567}
1568
1569#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1570fn table_has_spans(grid: &[Value]) -> bool {
1571 for row in grid {
1572 let Some(cells) = row.as_array() else {
1573 continue;
1574 };
1575 for cell in cells {
1576 let rs = cell.get("row_span").and_then(|v| v.as_u64()).unwrap_or(1);
1577 let cs = cell.get("col_span").and_then(|v| v.as_u64()).unwrap_or(1);
1578 if rs > 1 || cs > 1 {
1579 return true;
1580 }
1581 }
1582 }
1583 false
1584}
1585
1586#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1587fn table_to_markdown(table: &Value) -> Option<String> {
1588 let data = table.get("data")?;
1589 let grid = data.get("grid")?.as_array()?;
1590 if grid.is_empty() {
1591 return None;
1592 }
1593 if table_has_spans(grid) {
1594 return table_to_html(grid);
1595 }
1596 let mut rows: Vec<Vec<String>> = Vec::new();
1597 for row in grid {
1598 let cells = row.as_array()?;
1599 let mut out_row = Vec::new();
1600 for cell in cells {
1601 let text = cell
1602 .get("text")
1603 .and_then(|t| t.as_str())
1604 .unwrap_or("")
1605 .replace('|', "\\|")
1606 .replace('\n', " ");
1607 out_row.push(text);
1608 }
1609 rows.push(out_row);
1610 }
1611 if rows.is_empty() {
1612 return None;
1613 }
1614 let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
1615 if cols == 0 {
1616 return None;
1617 }
1618 for row in &mut rows {
1619 row.resize(cols, String::new());
1620 }
1621 let mut md = String::new();
1622 md.push_str(&format!("| {} |\n", rows[0].join(" | ")));
1623 md.push_str(&format!(
1624 "| {} |\n",
1625 (0..cols).map(|_| "---").collect::<Vec<_>>().join(" | ")
1626 ));
1627 for row in rows.iter().skip(1) {
1628 md.push_str(&format!("| {} |\n", row.join(" | ")));
1629 }
1630 Some(md)
1631}
1632
1633#[cfg(all(feature = "hybrid", not(target_arch = "wasm32")))]
1635fn table_to_html(grid: &[Value]) -> Option<String> {
1636 let mut html = String::from("<table>\n");
1637 for row in grid {
1638 let cells = row.as_array()?;
1639 html.push_str("<tr>");
1640 for cell in cells {
1641 let text = cell
1642 .get("text")
1643 .and_then(|t| t.as_str())
1644 .unwrap_or("")
1645 .replace('&', "&")
1646 .replace('<', "<")
1647 .replace('>', ">");
1648 let rs = cell.get("row_span").and_then(|v| v.as_u64()).unwrap_or(1);
1649 let cs = cell.get("col_span").and_then(|v| v.as_u64()).unwrap_or(1);
1650 let start_r = cell
1652 .get("start_row_offset_idx")
1653 .and_then(|v| v.as_u64())
1654 .unwrap_or(0);
1655 let end_r = cell
1656 .get("end_row_offset_idx")
1657 .and_then(|v| v.as_u64())
1658 .unwrap_or(start_r + rs);
1659 let start_c = cell
1660 .get("start_col_offset_idx")
1661 .and_then(|v| v.as_u64())
1662 .unwrap_or(0);
1663 let end_c = cell
1664 .get("end_col_offset_idx")
1665 .and_then(|v| v.as_u64())
1666 .unwrap_or(start_c + cs);
1667 if end_r.saturating_sub(start_r) == 0 || end_c.saturating_sub(start_c) == 0 {
1668 continue;
1669 }
1670 let mut attrs = String::new();
1673 if rs > 1 {
1674 attrs.push_str(&format!(" rowspan=\"{rs}\""));
1675 }
1676 if cs > 1 {
1677 attrs.push_str(&format!(" colspan=\"{cs}\""));
1678 }
1679 html.push_str(&format!("<td{attrs}>{text}</td>"));
1680 }
1681 html.push_str("</tr>\n");
1682 }
1683 html.push_str("</table>");
1684 Some(html)
1685}
1686
1687pub fn triage_output_dir(output_dir: Option<&str>, input_path: &Path) -> PathBuf {
1689 if let Some(dir) = output_dir {
1690 PathBuf::from(dir)
1691 } else {
1692 input_path
1693 .parent()
1694 .unwrap_or_else(|| Path::new("."))
1695 .to_path_buf()
1696 }
1697}
1698
1699pub fn backend_pages(report: &TriageReport) -> BTreeSet<u32> {
1701 report
1702 .triage
1703 .iter()
1704 .filter(|e| e.decision == "BACKEND")
1705 .map(|e| e.page)
1706 .collect()
1707}
1708
1709#[cfg(test)]
1710mod tests {
1711 use super::*;
1712 use crate::api::config::{HybridMode, ProcessingConfig};
1713 use crate::models::document::PdfDocument;
1714
1715 #[test]
1716 fn injected_backend_table_wins_on_full_hybrid() {
1717 let doc = PdfDocument::new("t.pdf".into());
1718 let mut config = ProcessingConfig::default();
1719 config.hybrid = crate::api::config::HybridBackend::DoclingFast;
1720 config.hybrid_mode = HybridMode::Full;
1721 let backend = "| ColA | ColB |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n";
1722 let result = apply_hybrid_injected(&doc, &config, Some(backend), None);
1723 assert!(result.used_backend);
1724 let md = result.markdown_override.expect("override");
1725 assert!(md.contains("ColA"));
1726 assert!(has_table_block(&md));
1727 }
1728
1729 #[test]
1730 fn injected_none_keeps_local_when_no_backend_pages() {
1731 let doc = PdfDocument::new("t.pdf".into());
1732 let mut config = ProcessingConfig::default();
1733 config.hybrid = crate::api::config::HybridBackend::DoclingFast;
1734 config.hybrid_mode = HybridMode::Auto;
1735 let result = apply_hybrid_injected(&doc, &config, None, None);
1736 assert!(!result.used_backend);
1737 assert!(result.markdown_override.is_none());
1738 }
1739}