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