Skip to main content

edgeparse_core/
hybrid.rs

1//! Hybrid extraction mode — geometric triage + completeness-aware merge.
2//!
3//! Native CLI (`feature = "hybrid"`): also fetches Docling Fast over HTTP.
4//! WASM / injected path: call [`apply_hybrid_injected`] with host-supplied
5//! backend markdown (or `None` for local-only + Rust OCR).
6
7use 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/// Per-page triage decision written to triage.json.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct TriageEntry {
37    /// 1-indexed page number.
38    pub page: u32,
39    /// `JAVA` (local) or `BACKEND`.
40    pub decision: String,
41    /// Optional human-readable reason.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub reason: Option<String>,
44}
45
46/// Document-level triage report.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct TriageReport {
49    /// Source PDF file name.
50    pub document: String,
51    /// Per-page triage decisions.
52    pub triage: Vec<TriageEntry>,
53}
54
55/// Result of applying hybrid post-processing.
56#[derive(Debug, Clone)]
57pub struct HybridResult {
58    /// When Some, use this Markdown instead of local `to_markdown`.
59    pub markdown_override: Option<String>,
60    /// Per-page triage decisions for this document.
61    pub triage: TriageReport,
62    /// True when the Docling Fast backend supplied the Markdown override.
63    pub used_backend: bool,
64}
65
66/// Apply hybrid routing after the local pipeline has produced `doc`.
67///
68/// Host-injected backend markdown (WASM / tests). When `backend_markdown` is
69/// `None`, triage still runs and local markdown is kept.
70pub 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    // Full mode with an injected document: prefer the backend tree when local
154    // has no pages yet or when structure heuristics accept it.
155    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/// Apply hybrid routing after the local pipeline has produced `doc`.
171///
172/// Native path: fetches Docling Fast when triage requests BACKEND pages.
173#[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    // PDF /Outlines declares the author hierarchy (ISO 32000). If local recovery
182    // finds far fewer section headings than outline entries, MHS cannot match GT.
183    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        // No outline: use relative font-size / weight vs body median (layout cue).
190        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        // Many consecutive heading nodes with little body between = fractured title.
196        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            // Table structure (TEDS): choose the richest lattice among local,
222            // Docling Fast, and full DocumentConverter — local occasionally
223            // beats Docling on borderless grids.
224            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                // Order: local first, backends last. Compare by effective column
251                // arity only — equal columns keep Docling (safer TEDS). Local
252                // wins only when it recovers strictly more real columns.
253                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                // Image-embedded tables need full DocumentConverter (Fast OCR
274                // often under-fills cells). Also convert when Fast is sparse.
275                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/// Compare recovered headings to the PDF outline (ISO 32000 `/Outlines`).
326///
327/// The outline is author-declared document structure. When its leaf count is
328/// at least 2 and local headings recover fewer than half of those entries,
329/// hierarchical similarity (MHS) will fail — route to the backend.
330#[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
360/// Typographic heading inventory vs recovered headings (no PDF outline required).
361///
362/// Body font size = median of paragraph `font_size`. Candidates are short
363/// non-caption paragraphs whose size ≥ 1.15× body median or whose weight is
364/// above the body weight median and ≥ 600 (PDF font metrics only). When
365/// candidates ≥ 2 and recovered headings cover fewer than half, MHS fails —
366/// route to the backend.
367fn 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        // Heading candidates are short labels, not long prose blocks.
427        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
453/// Fractured paper titles: ≥3 consecutive headings before substantial body.
454///
455/// PDF text objects often split a display title across lines; Stage-12 promotes
456/// each fragment. Docling reassembles them — route when local inventory is
457/// clearly fragmented.
458fn 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                // media between title lines does not break a title run
491            }
492            _ => {
493                max_run = max_run.max(run);
494                run = 0;
495            }
496        }
497    }
498    max_run.max(run) >= 3
499}
500
501/// Merge local EdgeParse pages with Docling pages for BACKEND decisions.
502///
503/// Always **replaces** triaged pages in reading order. Never appends a second
504/// copy of a page — that duplicates content and tanks NID on the official board.
505fn 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                // NID-first: Docling image pages must not truncate local text.
552                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    // Fallback: per-page stitch if full markdown missing or incomplete.
562    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
621/// Effective column count for the densest markdown table in `s`.
622///
623/// Leading empty stub columns (common local false splits) are discounted so a
624/// `| | A | B | C |` grid does not beat Docling's `| A | B | C |`.
625fn 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    // HTML tables: count <th>/<td> on first row roughly via colspan-aware bonus.
641    if s.to_ascii_lowercase().contains("<table") {
642        best = best.max(2);
643    }
644    best
645}
646
647/// Count non-empty markdown/HTML table cells — tie-break when column arity matches.
648#[allow(dead_code)] // used under hybrid table-merge paths; keep for scoring
649fn 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            // Rough HTML cell occupancy
669            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    // Drop columns that are empty in most data rows (false stub / spacer cols).
705    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/// Score table lattice topology for TEDS-aware merge.
749///
750/// Primary signal is column arity (mode columns²): local sometimes emits more
751/// *rows* by absorbing prose, which must not beat a narrower-but-correct Docling
752/// grid. On equal scores, caller keeps the later candidate (backend).
753#[allow(dead_code)] // reserved for column-arity tie-breaks in hybrid merge
754fn 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
766/// Prefer backend when it preserves table structure (TEDS) or content mass.
767fn 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
788/// Prefer backend document markdown when it preserves content mass and structure.
789fn 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    // Reject only when local clearly has tables and backend has none.
807    if has_table_block(local) && !has_table_block(backend) {
808        return false;
809    }
810    true
811}
812
813/// Prefer backend page markdown when it carries tables or preserves content mass.
814fn 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; // avoid duplicating doc title on every page
835    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/// Write triage.json next to the engine output directory (or beside a file).
845#[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
1049/// Keep tokens whose width is ≤15% of page width (cell-like vs full-line prose).
1050fn 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
1149/// Greedy 1D clustering: sort values, start a new cluster when gap > epsilon.
1150fn 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        // Prefer Docling's canonical serializer (matches board Docling).
1254        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/// Serialize DoclingDocument JSON with docling_core's official exporter.
1281///
1282/// The hybrid server returns JSON only; ODL Java uses this same schema path.
1283/// Invoking `DoclingDocument.export_to_markdown` preserves reading order,
1284/// captions, document_index tables, and heading levels identically to the
1285/// board Docling engine.
1286#[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    // Walk cwd + parents for the hybrid venv used by odl-bench / benchmark.
1340    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/// Full Docling `DocumentConverter` path (matches board Docling TSR quality).
1360///
1361/// Used for table-routed pages where Docling Fast's JSON table export trails
1362/// the converter used by the official Docling engine adapter.
1363#[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/// Serialize DoclingDocument JSON to Markdown in body reading order.
1401///
1402/// The hybrid server returns JSON only (DoclingDocument). ODL's Java side
1403/// walks `#/body` `$ref` children; we must do the same. Appending `tables`
1404/// after `texts` breaks reading order and tanks NID.
1405#[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    // Fallback when body refs are missing: texts then tables (legacy shape).
1417    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            // Captions are linked on the table node, not always in body order.
1472            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            // Emit linked captions; skip binary image payloads.
1484            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    // Inline node (rare).
1521    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            // Docling export: section level N → markdown heading N+1.
1557            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/// HTML tables preserve rowspan/colspan required by official TEDS.
1635#[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('&', "&amp;")
1647                .replace('<', "&lt;")
1648                .replace('>', "&gt;");
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            // Docling grid repeats covered cells; skip non-origin stubs.
1652            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            // Only emit the origin cell of a span (when grid duplicates).
1672            // Heuristic: empty text with span already emitted — keep simple emit.
1673            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
1688/// Resolve the directory that should hold triage.json for a given output path.
1689pub 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
1700/// Pages marked BACKEND in a triage report.
1701pub 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}