Skip to main content

edgeparse_core/
lib.rs

1//! EdgeParse Core Library
2//!
3//! High-performance PDF-to-structured-data extraction engine.
4//! Implements a 20-stage processing pipeline for extracting text, tables,
5//! images, and semantic structure from PDF documents.
6
7#![warn(missing_docs)]
8
9pub mod api;
10pub mod models;
11pub mod output;
12pub mod pdf;
13pub mod pipeline;
14pub mod utils;
15
16/// Geometric hybrid triage + merge (Docling HTTP behind `feature = "hybrid"`).
17pub mod hybrid;
18
19pub mod tagged;
20
21use crate::api::config::ProcessingConfig;
22use crate::models::content::ContentElement;
23use crate::models::document::PdfDocument;
24use crate::pdf::bookmark_extractor::extract_bookmarks;
25use crate::pdf::chunk_parser::extract_page_chunks;
26use crate::pdf::page_info;
27#[cfg(not(target_arch = "wasm32"))]
28use crate::pdf::raster_table_ocr::{
29    recover_dominant_image_text_chunks, recover_page_raster_table_cell_text,
30    recover_raster_table_borders,
31};
32use crate::pipeline::orchestrator::{run_pipeline, PipelineState};
33use crate::pipeline::stages::heading_detector::refine_heading_hierarchy;
34use crate::tagged::struct_tree::build_mcid_map;
35use std::time::Instant;
36
37/// Main entry point: convert a PDF file to structured data.
38///
39/// # Arguments
40/// * `input_path` - Path to the input PDF file
41/// * `config` - Processing configuration
42///
43/// # Returns
44/// * `Result<PdfDocument>` - The extracted structured document
45///
46/// # Errors
47/// Returns an error if the PDF cannot be loaded or processed.
48#[cfg(not(target_arch = "wasm32"))]
49pub fn convert(
50    input_path: &std::path::Path,
51    config: &ProcessingConfig,
52) -> Result<PdfDocument, EdgePdfError> {
53    let timing_enabled = timing_enabled();
54    let total_start = Instant::now();
55
56    let phase_start = Instant::now();
57    let raw_doc = pdf::loader::load_pdf(input_path, config.password.as_deref())?;
58    log_phase_duration(timing_enabled, "load_pdf", phase_start);
59
60    // Extract per-page geometry (MediaBox, CropBox, rotation) for use throughout the pipeline.
61    let phase_start = Instant::now();
62    let page_info_list = page_info::extract_page_info(&raw_doc.document);
63    log_phase_duration(timing_enabled, "extract_page_info", phase_start);
64
65    // Extract text chunks from each page
66    let pages_map = raw_doc.document.get_pages();
67    // Index by 1-based page number for fast lookup during optional OCR recovery.
68    // Keep this out of the default fast path when OCR is disabled.
69    let page_info_by_number: Vec<Option<&page_info::PageInfo>> =
70        if config.raster_table_ocr_enabled() {
71            let mut index = vec![None; pages_map.len().saturating_add(1)];
72            for info in &page_info_list {
73                if let Some(slot) = index.get_mut(info.page_number as usize) {
74                    *slot = Some(info);
75                }
76            }
77            index
78        } else {
79            Vec::new()
80        };
81    let mut page_contents = Vec::with_capacity(pages_map.len());
82
83    let phase_start = Instant::now();
84    for (&page_num, &page_id) in &pages_map {
85        let page_chunks = extract_page_chunks(&raw_doc.document, page_num, page_id)?;
86        let mut recovered_text_chunks = Vec::new();
87        let mut recovered_tables = Vec::new();
88        if config.raster_table_ocr_enabled() {
89            if let Some(Some(page_info)) = page_info_by_number.get(page_num as usize) {
90                recovered_text_chunks = recover_dominant_image_text_chunks(
91                    input_path,
92                    &page_info.crop_box,
93                    page_num,
94                    &page_chunks.text_chunks,
95                    &page_chunks.image_chunks,
96                );
97                recovered_tables = recover_raster_table_borders(
98                    input_path,
99                    &page_info.crop_box,
100                    page_num,
101                    &page_chunks.text_chunks,
102                    &page_chunks.image_chunks,
103                );
104            }
105        }
106        let mut elements: Vec<ContentElement> = page_chunks
107            .text_chunks
108            .into_iter()
109            .map(ContentElement::TextChunk)
110            .collect();
111        elements.extend(
112            recovered_text_chunks
113                .into_iter()
114                .map(ContentElement::TextChunk),
115        );
116
117        elements.extend(
118            page_chunks
119                .image_chunks
120                .into_iter()
121                .map(ContentElement::Image),
122        );
123        elements.extend(
124            page_chunks
125                .line_chunks
126                .into_iter()
127                .map(ContentElement::Line),
128        );
129        elements.extend(
130            page_chunks
131                .line_art_chunks
132                .into_iter()
133                .map(ContentElement::LineArt),
134        );
135        elements.extend(
136            recovered_tables
137                .into_iter()
138                .map(ContentElement::TableBorder),
139        );
140
141        page_contents.push(elements);
142    }
143    log_phase_duration(timing_enabled, "extract_page_chunks", phase_start);
144
145    // Run the processing pipeline
146    let phase_start = Instant::now();
147    let mcid_map = build_mcid_map(&raw_doc.document);
148    let mut pipeline_state = PipelineState::with_mcid_map(page_contents, config.clone(), mcid_map)
149        .with_page_info(page_info_list);
150    run_pipeline(&mut pipeline_state)?;
151    // Outline + section-number heading hierarchy (after style-based levels).
152    let bookmarks = extract_bookmarks(&raw_doc.document);
153    if !bookmarks.is_empty() {
154        refine_heading_hierarchy(&mut pipeline_state.pages, &bookmarks);
155    } else {
156        refine_heading_hierarchy(&mut pipeline_state.pages, &[]);
157    }
158    log_phase_duration(timing_enabled, "run_pipeline", phase_start);
159
160    // Build the output document
161    let file_name = input_path
162        .file_name()
163        .and_then(|n| n.to_str())
164        .unwrap_or("unknown.pdf")
165        .to_string();
166
167    let mut doc = PdfDocument::new(file_name);
168    doc.source_path = Some(input_path.display().to_string());
169    doc.number_of_pages = pages_map.len() as u32;
170    doc.author = raw_doc.metadata.author;
171    doc.title = raw_doc.metadata.title;
172    doc.creation_date = raw_doc.metadata.creation_date;
173    doc.modification_date = raw_doc.metadata.modification_date;
174
175    let phase_start = Instant::now();
176    if config.raster_table_ocr_enabled() {
177        for (page_idx, page) in pipeline_state.pages.iter_mut().enumerate() {
178            if let Some(page_info) = pipeline_state.page_info.get(page_idx) {
179                recover_page_raster_table_cell_text(
180                    input_path,
181                    &page_info.crop_box,
182                    page_info.page_number,
183                    page,
184                );
185            }
186        }
187    }
188    log_phase_duration(
189        timing_enabled,
190        "recover_page_raster_table_cell_text",
191        phase_start,
192    );
193
194    // Flatten pipeline output into document kids
195    let phase_start = Instant::now();
196    for page in pipeline_state.pages {
197        doc.kids.extend(page);
198    }
199    log_phase_duration(timing_enabled, "flatten_document", phase_start);
200    log_phase_duration(timing_enabled, "convert_total", total_start);
201
202    Ok(doc)
203}
204
205/// Convert a PDF from an in-memory byte slice to structured data.
206///
207/// This is the WASM-compatible entry point. It replaces filesystem I/O with
208/// in-memory equivalents. Embedded Image XObject tables are recovered via
209/// [`pdf::inmem_raster`] when `raster_table_ocr` is enabled (no pdfimages /
210/// pdftoppm). Heading hierarchy matches native [`convert`].
211///
212/// Implemented as [`assemble`]([`extract_session`](..)) so the sync path stays
213/// identical to the two-phase WASM session.
214///
215/// # Arguments
216/// * `data` — raw PDF bytes (e.g., from a `Uint8Array` in JavaScript)
217/// * `file_name` — display name (used in `PdfDocument.file_name`)
218/// * `config` — processing configuration
219///
220/// # Returns
221/// Structured document or error.
222///
223/// # Errors
224/// Returns an error if the PDF cannot be parsed or processed.
225pub fn convert_bytes(
226    data: &[u8],
227    file_name: &str,
228    config: &ProcessingConfig,
229) -> Result<PdfDocument, EdgePdfError> {
230    let session = extract_session(data, file_name, config, None)?;
231    assemble(session, OcrAssembleMode::SyncEngine)
232}
233
234/// Progress callback: `(phase, done, total)`.
235pub type ProgressFn<'a> = dyn FnMut(&str, u32, u32) + 'a;
236
237/// Intermediate state between extract (plan) and assemble (finish).
238pub struct ExtractSession {
239    /// Display / document name.
240    pub file_name: String,
241    /// Processing config snapshot.
242    pub config: ProcessingConfig,
243    /// Author metadata.
244    pub author: Option<String>,
245    /// Title metadata.
246    pub title: Option<String>,
247    /// Creation date metadata.
248    pub creation_date: Option<String>,
249    /// Modification date metadata.
250    pub modification_date: Option<String>,
251    /// Producer metadata.
252    pub producer: Option<String>,
253    /// Creator metadata.
254    pub creator: Option<String>,
255    /// Page count.
256    pub number_of_pages: u32,
257    /// Per-page content elements (tables not yet recovered from OCR candidates).
258    pub page_contents: Vec<Vec<ContentElement>>,
259    /// Page geometry.
260    pub page_info_list: Vec<page_info::PageInfo>,
261    /// Tagged PDF MCID map.
262    pub mcid_map: crate::tagged::struct_tree::McidMap,
263    /// Bookmarks for heading refine.
264    pub bookmarks: Vec<pdf::bookmark_extractor::Bookmark>,
265    /// OCR candidates (image feature).
266    #[cfg(feature = "image")]
267    pub candidates: Vec<pdf::inmem_raster::RasterCandidate>,
268    /// OCR words provided by the host, keyed by candidate id.
269    #[cfg(feature = "image")]
270    pub ocr_words: std::collections::HashMap<u32, Vec<pdf::ocr::OcrWord>>,
271}
272
273/// How assemble should obtain OCR for candidates.
274pub enum OcrAssembleMode {
275    /// Call the default sync [`pdf::ocr`] engine per candidate (legacy path).
276    SyncEngine,
277    /// Use words already stored on the session via [`ExtractSession::provide_ocr`].
278    Provided,
279    /// Call a caller-supplied engine (tests / custom hosts).
280    #[cfg(feature = "image")]
281    Engine(std::sync::Arc<dyn pdf::ocr::OcrEngine>),
282}
283
284/// Extract pages, chunks, and OCR candidates without recognizing text.
285///
286/// `on_progress` is invoked between pages as `("planning", page_done, page_total)`.
287pub fn extract_session(
288    data: &[u8],
289    file_name: &str,
290    config: &ProcessingConfig,
291    mut on_progress: Option<&mut ProgressFn<'_>>,
292) -> Result<ExtractSession, EdgePdfError> {
293    let raw_doc = pdf::loader::load_pdf_from_bytes(data, config.password.as_deref())?;
294    let page_info_list = page_info::extract_page_info(&raw_doc.document);
295    let pages_map = raw_doc.document.get_pages();
296    let page_total = pages_map.len() as u32;
297    let mut page_contents = Vec::with_capacity(pages_map.len());
298    #[cfg(feature = "image")]
299    let mut candidates = Vec::new();
300    #[cfg(feature = "image")]
301    let mut next_id = 0u32;
302
303    let mut page_done = 0u32;
304    for (&page_num, &page_id) in &pages_map {
305        let page_chunks = extract_page_chunks(&raw_doc.document, page_num, page_id)?;
306
307        #[cfg(feature = "image")]
308        if config.raster_table_ocr_enabled() {
309            let (page_w, page_h) = page_info_list
310                .iter()
311                .find(|p| p.page_number == page_num)
312                .map(|p| (p.width, p.height))
313                .unwrap_or((612.0, 792.0));
314            let page_cands = pdf::inmem_raster::collect_raster_candidates(
315                &raw_doc.document,
316                page_id,
317                &page_chunks.image_chunks,
318                &page_chunks.text_chunks,
319                page_w,
320                page_h,
321                &mut next_id,
322            );
323            candidates.extend(page_cands);
324        }
325
326        let mut elements: Vec<ContentElement> = page_chunks
327            .text_chunks
328            .into_iter()
329            .map(ContentElement::TextChunk)
330            .collect();
331        elements.extend(
332            page_chunks
333                .image_chunks
334                .into_iter()
335                .map(ContentElement::Image),
336        );
337        elements.extend(
338            page_chunks
339                .line_chunks
340                .into_iter()
341                .map(ContentElement::Line),
342        );
343        elements.extend(
344            page_chunks
345                .line_art_chunks
346                .into_iter()
347                .map(ContentElement::LineArt),
348        );
349        // OCR tables are injected in assemble from candidates.
350        page_contents.push(elements);
351
352        page_done += 1;
353        if let Some(cb) = on_progress.as_mut() {
354            cb("planning", page_done, page_total);
355        }
356    }
357
358    let mcid_map = build_mcid_map(&raw_doc.document);
359    let bookmarks = extract_bookmarks(&raw_doc.document);
360
361    Ok(ExtractSession {
362        file_name: file_name.to_string(),
363        config: config.clone(),
364        author: raw_doc.metadata.author,
365        title: raw_doc.metadata.title,
366        creation_date: raw_doc.metadata.creation_date,
367        modification_date: raw_doc.metadata.modification_date,
368        producer: raw_doc.metadata.producer,
369        creator: raw_doc.metadata.creator,
370        number_of_pages: pages_map.len() as u32,
371        page_contents,
372        page_info_list,
373        mcid_map,
374        bookmarks,
375        #[cfg(feature = "image")]
376        candidates,
377        #[cfg(feature = "image")]
378        ocr_words: std::collections::HashMap::new(),
379    })
380}
381
382impl ExtractSession {
383    /// Store host OCR words for a candidate (two-phase finish path).
384    #[cfg(feature = "image")]
385    pub fn provide_ocr(&mut self, id: u32, words: Vec<pdf::ocr::OcrWord>) {
386        self.ocr_words.insert(id, words);
387    }
388
389    /// Candidate metadata list (no pixels).
390    #[cfg(feature = "image")]
391    pub fn candidate_metas(&self) -> Vec<pdf::inmem_raster::RasterCandidateMeta> {
392        self.candidates.iter().map(|c| c.meta()).collect()
393    }
394
395    /// Gray pixels for a candidate id.
396    #[cfg(feature = "image")]
397    pub fn candidate_gray(&self, id: u32) -> Option<&[u8]> {
398        self.candidates
399            .iter()
400            .find(|c| c.id == id)
401            .map(|c| c.gray.as_slice())
402    }
403}
404
405/// Drop the source Image (by XObject index) and inject OCR words as text chunks.
406#[cfg(feature = "image")]
407fn inject_ocr_text_blocks(
408    session: &mut ExtractSession,
409    page_idx: usize,
410    cand: &pdf::inmem_raster::RasterCandidate,
411    words: &[pdf::ocr::OcrWord],
412) {
413    if let Some(page) = session.page_contents.get_mut(page_idx) {
414        if let Some(idx) = cand.image_index {
415            page.retain(|el| match el {
416                ContentElement::Image(img) => img.index != Some(idx),
417                _ => true,
418            });
419        }
420        for chunk in pdf::inmem_raster::ocr_words_to_text_chunks(cand, words) {
421            page.push(ContentElement::TextChunk(chunk));
422        }
423    }
424}
425
426/// Run the pipeline after injecting OCR-derived tables from session candidates.
427pub fn assemble(
428    mut session: ExtractSession,
429    mode: OcrAssembleMode,
430) -> Result<PdfDocument, EdgePdfError> {
431    #[cfg(feature = "image")]
432    if session.config.raster_table_ocr_enabled() {
433        use image::GrayImage;
434        use pdf::inmem_raster::RasterCandidateKind;
435        use pdf::ocr::{default_engine, OcrEngine};
436
437        let candidates = std::mem::take(&mut session.candidates);
438        for cand in &candidates {
439            let words = match &mode {
440                OcrAssembleMode::SyncEngine => {
441                    if let Some(gray) =
442                        GrayImage::from_raw(cand.width, cand.height, cand.gray.clone())
443                    {
444                        OcrEngine::recognize(&*default_engine(), &gray)
445                    } else {
446                        Vec::new()
447                    }
448                }
449                OcrAssembleMode::Provided => {
450                    session.ocr_words.get(&cand.id).cloned().unwrap_or_default()
451                }
452                OcrAssembleMode::Engine(engine) => {
453                    if let Some(gray) =
454                        GrayImage::from_raw(cand.width, cand.height, cand.gray.clone())
455                    {
456                        engine.recognize(&gray)
457                    } else {
458                        Vec::new()
459                    }
460                }
461            };
462            let page_idx = cand.page.saturating_sub(1) as usize;
463            match cand.kind {
464                RasterCandidateKind::Table => {
465                    let table = pdf::inmem_raster::build_table_from_candidate(cand, &words)
466                        .filter(|t| pdf::inmem_raster::bordered_table_is_plausible(t));
467                    if let Some(table) = table {
468                        if let Some(page) = session.page_contents.get_mut(page_idx) {
469                            page.push(ContentElement::TableBorder(table));
470                        }
471                    } else if !words.is_empty() {
472                        // OCR found glyphs but no lattice — assemble as text,
473                        // not discard (right assembly from evidence).
474                        inject_ocr_text_blocks(&mut session, page_idx, cand, &words);
475                    }
476                }
477                RasterCandidateKind::TextBlocks => {
478                    inject_ocr_text_blocks(&mut session, page_idx, cand, &words);
479                }
480            }
481        }
482        session.candidates = candidates;
483    }
484
485    #[cfg(not(feature = "image"))]
486    let _ = mode;
487
488    let mut pipeline_state = PipelineState::with_mcid_map(
489        session.page_contents,
490        session.config.clone(),
491        session.mcid_map,
492    )
493    .with_page_info(session.page_info_list);
494    run_pipeline(&mut pipeline_state)?;
495    refine_heading_hierarchy(&mut pipeline_state.pages, &session.bookmarks);
496
497    let mut doc = PdfDocument::new(session.file_name);
498    doc.number_of_pages = session.number_of_pages;
499    doc.author = session.author;
500    doc.title = session.title;
501    doc.creation_date = session.creation_date;
502    doc.modification_date = session.modification_date;
503    doc.producer = session.producer;
504    doc.creator = session.creator;
505
506    for page in pipeline_state.pages {
507        doc.kids.extend(page);
508    }
509
510    Ok(doc)
511}
512
513/// Top-level error type for EdgeParse operations.
514#[derive(Debug, thiserror::Error)]
515pub enum EdgePdfError {
516    /// PDF loading error
517    #[error("PDF loading error: {0}")]
518    LoadError(String),
519
520    /// Pipeline processing error
521    #[error("Pipeline error at stage {stage}: {message}")]
522    PipelineError {
523        /// Pipeline stage number (1-20)
524        stage: u32,
525        /// Error description
526        message: String,
527    },
528
529    /// Output generation error
530    #[error("Output error: {0}")]
531    OutputError(String),
532
533    /// I/O error
534    #[error("I/O error: {0}")]
535    IoError(#[from] std::io::Error),
536
537    /// Configuration error
538    #[error("Configuration error: {0}")]
539    ConfigError(String),
540
541    /// lopdf error
542    #[error("PDF parse error: {0}")]
543    LopdfError(String),
544}
545
546impl From<lopdf::Error> for EdgePdfError {
547    fn from(e: lopdf::Error) -> Self {
548        EdgePdfError::LopdfError(e.to_string())
549    }
550}
551
552fn timing_enabled() -> bool {
553    std::env::var("EDGEPARSE_TIMING")
554        .map(|value| {
555            matches!(
556                value.to_ascii_lowercase().as_str(),
557                "1" | "true" | "yes" | "on"
558            )
559        })
560        .unwrap_or(false)
561}
562
563fn log_phase_duration(enabled: bool, phase: &str, start: Instant) {
564    if enabled {
565        log::info!(
566            "Timing {}: {:.2} ms",
567            phase,
568            start.elapsed().as_secs_f64() * 1000.0
569        );
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use lopdf::{
577        content::{Content, Operation},
578        dictionary, Object, Stream,
579    };
580    use std::io::Write;
581
582    /// Create a synthetic PDF file for integration testing.
583    fn create_test_pdf_file(path: &std::path::Path) {
584        let mut doc = lopdf::Document::with_version("1.5");
585        let pages_id = doc.new_object_id();
586
587        let font_id = doc.add_object(dictionary! {
588            "Type" => "Font",
589            "Subtype" => "Type1",
590            "BaseFont" => "Helvetica",
591        });
592
593        let resources_id = doc.add_object(dictionary! {
594            "Font" => dictionary! {
595                "F1" => font_id,
596            },
597        });
598
599        let content = Content {
600            operations: vec![
601                Operation::new("BT", vec![]),
602                Operation::new("Tf", vec!["F1".into(), 12.into()]),
603                Operation::new("Td", vec![72.into(), 700.into()]),
604                Operation::new("Tj", vec![Object::string_literal("Hello EdgeParse!")]),
605                Operation::new("Td", vec![0.into(), Object::Real(-20.0)]),
606                Operation::new("Tj", vec![Object::string_literal("Second line of text.")]),
607                Operation::new("ET", vec![]),
608            ],
609        };
610
611        let encoded = content.encode().unwrap();
612        let content_id = doc.add_object(Stream::new(dictionary! {}, encoded));
613
614        let page_id = doc.add_object(dictionary! {
615            "Type" => "Page",
616            "Parent" => pages_id,
617            "Contents" => content_id,
618            "Resources" => resources_id,
619            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
620        });
621
622        let pages = dictionary! {
623            "Type" => "Pages",
624            "Kids" => vec![page_id.into()],
625            "Count" => 1,
626        };
627        doc.objects.insert(pages_id, Object::Dictionary(pages));
628
629        let catalog_id = doc.add_object(dictionary! {
630            "Type" => "Catalog",
631            "Pages" => pages_id,
632        });
633        doc.trailer.set("Root", catalog_id);
634
635        let mut file = std::fs::File::create(path).unwrap();
636        doc.save_to(&mut file).unwrap();
637        file.flush().unwrap();
638    }
639
640    #[test]
641    fn test_convert_end_to_end() {
642        let dir = std::env::temp_dir().join("edgeparse_test");
643        std::fs::create_dir_all(&dir).unwrap();
644        let pdf_path = dir.join("test_convert.pdf");
645
646        create_test_pdf_file(&pdf_path);
647
648        let config = ProcessingConfig::default();
649        let result = convert(&pdf_path, &config);
650        assert!(result.is_ok(), "convert() failed: {:?}", result.err());
651
652        let doc = result.unwrap();
653        assert_eq!(doc.number_of_pages, 1);
654        assert!(
655            !doc.kids.is_empty(),
656            "Expected content elements in document"
657        );
658
659        // Check that we extracted content (may be TextChunks, TextLines, or TextBlocks after pipeline)
660        let mut all_text = String::new();
661        for element in &doc.kids {
662            match element {
663                models::content::ContentElement::TextChunk(tc) => {
664                    all_text.push_str(&tc.value);
665                    all_text.push(' ');
666                }
667                models::content::ContentElement::TextLine(tl) => {
668                    all_text.push_str(&tl.value());
669                    all_text.push(' ');
670                }
671                models::content::ContentElement::TextBlock(tb) => {
672                    all_text.push_str(&tb.value());
673                    all_text.push(' ');
674                }
675                models::content::ContentElement::Paragraph(p) => {
676                    all_text.push_str(&p.base.value());
677                    all_text.push(' ');
678                }
679                models::content::ContentElement::Heading(h) => {
680                    all_text.push_str(&h.base.base.value());
681                    all_text.push(' ');
682                }
683                _ => {}
684            }
685        }
686
687        assert!(
688            all_text.contains("Hello"),
689            "Expected 'Hello' in extracted text, got: {}",
690            all_text
691        );
692        assert!(
693            all_text.contains("Second"),
694            "Expected 'Second' in extracted text, got: {}",
695            all_text
696        );
697
698        // Cleanup
699        let _ = std::fs::remove_file(&pdf_path);
700    }
701
702    #[cfg(feature = "image")]
703    struct FakeOcrEngine;
704
705    #[cfg(feature = "image")]
706    impl pdf::ocr::OcrEngine for FakeOcrEngine {
707        fn recognize(&self, _gray: &image::GrayImage) -> Vec<pdf::ocr::OcrWord> {
708            vec![
709                pdf::ocr::OcrWord {
710                    line_key: (1, 1, 1),
711                    left: 10,
712                    top: 10,
713                    width: 40,
714                    height: 12,
715                    text: "A".into(),
716                    confidence: 95.0,
717                },
718                pdf::ocr::OcrWord {
719                    line_key: (1, 1, 1),
720                    left: 80,
721                    top: 10,
722                    width: 40,
723                    height: 12,
724                    text: "B".into(),
725                    confidence: 95.0,
726                },
727                pdf::ocr::OcrWord {
728                    line_key: (1, 1, 2),
729                    left: 10,
730                    top: 40,
731                    width: 40,
732                    height: 12,
733                    text: "C".into(),
734                    confidence: 95.0,
735                },
736                pdf::ocr::OcrWord {
737                    line_key: (1, 1, 2),
738                    left: 80,
739                    top: 40,
740                    width: 40,
741                    height: 12,
742                    text: "D".into(),
743                    confidence: 95.0,
744                },
745            ]
746        }
747    }
748
749    #[cfg(feature = "image")]
750    fn count_table_borders(doc: &PdfDocument) -> usize {
751        doc.kids
752            .iter()
753            .filter(|e| matches!(e, models::content::ContentElement::TableBorder(_)))
754            .count()
755    }
756
757    /// Two-phase (provide_ocr + Provided) equals assemble with the same Fake engine.
758    #[cfg(feature = "image")]
759    #[test]
760    fn two_phase_equals_sync_fake_engine() {
761        let dir = std::env::temp_dir().join("edgeparse_two_phase");
762        std::fs::create_dir_all(&dir).unwrap();
763        let pdf_path = dir.join("hello.pdf");
764        create_test_pdf_file(&pdf_path);
765        let data = std::fs::read(&pdf_path).unwrap();
766        let config = ProcessingConfig::default();
767        let engine: std::sync::Arc<dyn pdf::ocr::OcrEngine> = std::sync::Arc::new(FakeOcrEngine);
768
769        let sync_doc = {
770            let session = extract_session(&data, "hello.pdf", &config, None).unwrap();
771            assemble(session, OcrAssembleMode::Engine(engine.clone())).unwrap()
772        };
773
774        let two_phase_doc = {
775            let mut session = extract_session(&data, "hello.pdf", &config, None).unwrap();
776            let ids: Vec<_> = session.candidates.iter().map(|c| c.id).collect();
777            for id in ids {
778                let gray = session.candidate_gray(id).unwrap().to_vec();
779                let meta = session.candidates.iter().find(|c| c.id == id).unwrap();
780                let img = image::GrayImage::from_raw(meta.width, meta.height, gray).unwrap();
781                let words = engine.recognize(&img);
782                session.provide_ocr(id, words);
783            }
784            assemble(session, OcrAssembleMode::Provided).unwrap()
785        };
786
787        assert_eq!(sync_doc.number_of_pages, two_phase_doc.number_of_pages);
788        assert_eq!(
789            count_table_borders(&sync_doc),
790            count_table_borders(&two_phase_doc)
791        );
792        let sync_md = output::markdown::to_markdown(&sync_doc).unwrap();
793        let phase_md = output::markdown::to_markdown(&two_phase_doc).unwrap();
794        assert_eq!(sync_md, phase_md);
795        let _ = std::fs::remove_file(&pdf_path);
796    }
797
798    /// Candidate pre-filter must keep table-like XObjects on docs 110 and 122.
799    #[cfg(feature = "image")]
800    #[test]
801    fn candidate_prefilter_keeps_docs_110_and_122() {
802        let roots = [
803            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs"),
804            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../odl-bench/pdfs"),
805        ];
806        for id in ["01030000000110", "01030000000122"] {
807            let pdf = roots
808                .iter()
809                .map(|r| r.join(format!("{id}.pdf")))
810                .find(|p| p.exists());
811            let Some(pdf) = pdf else {
812                eprintln!("skip {id}: PDF not found");
813                continue;
814            };
815            let data = std::fs::read(&pdf).unwrap();
816            let config = ProcessingConfig::default();
817            let session = extract_session(&data, &format!("{id}.pdf"), &config, None).unwrap();
818            assert!(
819                !session.candidates.is_empty(),
820                "{id}: expected ≥1 OCR candidate, got 0 (pre-filter dropped tables)"
821            );
822            // Sync assemble should still recover at least one table border.
823            let doc = assemble(session, OcrAssembleMode::SyncEngine).unwrap();
824            // Doc 110/122 are image-table heavy; allow zero only if OCR backend missing
825            // but candidates must exist (assertion above). Soft-check tables when OCR works.
826            let _ = count_table_borders(&doc);
827        }
828    }
829
830    /// Pattern-painted page raster (Skia/Penpot style) must emit an OCR
831    /// candidate, skip page-edge lines, and not render artifact metadata titles.
832    #[cfg(feature = "image")]
833    #[test]
834    fn pattern_form_fixture_emits_text_blocks_candidate() {
835        let pdf =
836            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/pattern_form.pdf");
837        assert!(pdf.exists(), "missing fixture {}", pdf.display());
838        let data = std::fs::read(&pdf).unwrap();
839        let config = ProcessingConfig::default();
840        let session = extract_session(&data, "pattern_form.pdf", &config, None).unwrap();
841        assert!(
842            !session.candidates.is_empty(),
843            "expected ≥1 OCR candidate from pattern-painted image"
844        );
845        assert!(
846            session
847                .candidates
848                .iter()
849                .any(|c| c.kind == pdf::inmem_raster::RasterCandidateKind::TextBlocks),
850            "expected TextBlocks candidate for page-sized raster"
851        );
852
853        // No OCR words → still assemble; markdown must skip "Penpot - Render".
854        let doc = assemble(session, OcrAssembleMode::Provided).unwrap();
855        assert_eq!(doc.producer.as_deref(), Some("Skia/PDF m151"));
856        let md = output::markdown::to_markdown(&doc).unwrap();
857        assert!(
858            !md.contains("# Penpot - Render"),
859            "artifact metadata title must not become H1, got:\n{md}"
860        );
861        // Native text from the fixture should still appear.
862        assert!(
863            md.contains("NAME VALUE") || md.contains("ACME"),
864            "expected native text in markdown, got:\n{md}"
865        );
866        // Reading order: title (higher on page) before value text.
867        if let (Some(a), Some(b)) = (md.find("ACME"), md.find("NAME VALUE")) {
868            assert!(a < b, "expected ACME before NAME VALUE in:\n{md}");
869        }
870
871        // Page-edge lines should not dominate kids.
872        let edge_lines = doc
873            .kids
874            .iter()
875            .filter(|e| {
876                matches!(
877                    e,
878                    models::content::ContentElement::Line(l)
879                        if l.bbox.width() < 2.0 || l.bbox.height() < 2.0
880                )
881            })
882            .count();
883        assert_eq!(edge_lines, 0, "pattern fill must not emit page-edge lines");
884        assert!(
885            !doc.kids.iter().any(|e| matches!(
886                e,
887                models::content::ContentElement::Image(_)
888                    | models::content::ContentElement::Figure(_)
889            )),
890            "page-sized TextBlocks source image should be dropped from layout"
891        );
892    }
893
894    #[test]
895    fn artifact_metadata_title_skipped_in_markdown() {
896        let mut doc = PdfDocument::new("x.pdf".into());
897        doc.title = Some("Penpot - Render".into());
898        doc.producer = Some("Skia/PDF m151".into());
899        doc.creator = Some("Chromium".into());
900        doc.number_of_pages = 1;
901        // Body text present but does not corroborate Info.Title → skip H1.
902        doc.kids.push(models::content::ContentElement::TextChunk(
903            models::chunks::TextChunk {
904                value: "MANSUY RAPHAEL".into(),
905                bbox: models::bbox::BoundingBox::new(Some(1), 10.0, 100.0, 200.0, 120.0),
906                font_name: "Helvetica".into(),
907                font_size: 12.0,
908                font_weight: 400.0,
909                italic_angle: 0.0,
910                font_color: "#000000".into(),
911                contrast_ratio: 21.0,
912                symbol_ends: vec![200.0],
913                text_format: models::enums::TextFormat::Normal,
914                text_type: models::enums::TextType::Regular,
915                pdf_layer: models::enums::PdfLayer::Main,
916                ocg_visible: true,
917                index: Some(0),
918                page_number: Some(1),
919                level: None,
920                mcid: None,
921            },
922        ));
923        let md = output::markdown::to_markdown(&doc).unwrap();
924        assert!(
925            !md.contains("# Penpot - Render"),
926            "uncorroborated metadata title must be skipped when body text exists, got:\n{md}"
927        );
928        assert!(md.contains("MANSUY RAPHAEL"), "got:\n{md}");
929    }
930}