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