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