1use std::path::{Component, Path, PathBuf};
22
23use gaze::Manifest;
24use serde::{Deserialize, Serialize};
25
26use crate::ocr::{
27 detect_image_format, ImageFormat, ImageInput, OcrBackend, OcrError, OcrHints, OcrResult,
28};
29
30#[cfg(feature = "ocr-tesseract")]
31use std::collections::BTreeMap;
32#[cfg(feature = "ocr-tesseract")]
33use std::fs;
34
35#[cfg(any(feature = "ocr-tesseract", feature = "mcp"))]
36use gaze::{
37 Action, ClassRule, CleanDocument, DefaultRule, LocaleTag, Pipeline as GazePipeline,
38 RawDocument, Scope, Session,
39};
40#[cfg(any(feature = "ocr-tesseract", feature = "mcp"))]
41use gaze_recognizers::{
42 AnchoredBoundary, AnchoredMatchRecognizer, CuePosition, NameShape, RegexDetector,
43};
44#[cfg(any(feature = "ocr-tesseract", feature = "mcp"))]
45use gaze_types::{EmittedTokenSpan, PiiClass};
46
47#[cfg(feature = "ocr-tesseract")]
48use crate::extract::InputKind;
49#[cfg(feature = "ocr-tesseract")]
50use crate::{BundleLayoutInvalidReason, DocumentError};
51
52pub const BUNDLE_VERSION: u32 = 2;
54const DEFAULT_LOW_CONFIDENCE_THRESHOLD: f32 = 0.65;
55
56pub const CLEAN_MARKDOWN_FILE: &str = "clean.md";
58pub const MANIFEST_FILE: &str = "manifest.json";
60pub const REPORT_FILE: &str = "report.json";
62
63#[non_exhaustive]
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct AgentBundleDir(PathBuf);
67
68impl AgentBundleDir {
69 pub fn new(path: impl Into<PathBuf>) -> Result<Self, DocumentError> {
74 let path = path.into();
75 validate_non_empty_path(&path)?;
76 Ok(Self(path))
77 }
78
79 pub fn as_path(&self) -> &Path {
81 &self.0
82 }
83}
84
85#[non_exhaustive]
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct OwnerBundleDir(PathBuf);
89
90impl OwnerBundleDir {
91 pub fn new(path: impl Into<PathBuf>) -> Result<Self, DocumentError> {
96 let path = path.into();
97 validate_non_empty_path(&path)?;
98 Ok(Self(path))
99 }
100
101 pub fn as_path(&self) -> &Path {
103 &self.0
104 }
105}
106
107#[non_exhaustive]
109#[derive(Debug, Clone)]
110pub struct SafeBundle {
111 pub clean_markdown: String,
113 pub manifest: Manifest,
115 pub layout: LayoutSummary,
117 pub preview_png: Option<Vec<u8>>,
119 pub report: BundleReport,
121 pub source_path: PathBuf,
123 pub agent_out_dir: PathBuf,
125 pub owner_out_dir: PathBuf,
127}
128
129impl SafeBundle {
130 #[allow(clippy::too_many_arguments)]
132 pub fn new(
133 clean_markdown: String,
134 manifest: Manifest,
135 layout: LayoutSummary,
136 preview_png: Option<Vec<u8>>,
137 report: BundleReport,
138 source_path: PathBuf,
139 agent_out_dir: PathBuf,
140 owner_out_dir: PathBuf,
141 ) -> Self {
142 Self {
143 clean_markdown,
144 manifest,
145 layout,
146 preview_png,
147 report,
148 source_path,
149 agent_out_dir,
150 owner_out_dir,
151 }
152 }
153}
154
155#[non_exhaustive]
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158pub struct ClassCount {
159 pub class: String,
161 pub count: u32,
163}
164
165impl ClassCount {
166 pub fn new(class: impl Into<String>, count: u32) -> Self {
168 Self {
169 class: class.into(),
170 count,
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
177#[serde(rename_all = "snake_case")]
178pub enum OcrSource {
179 VectorPdf,
181 Ocr,
183}
184
185#[non_exhaustive]
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
188pub struct PageReport {
189 pub page_index: i32,
191 pub ocr_source: OcrSource,
193 pub ocr_backend: Option<String>,
195 pub confidence: Option<f32>,
197 pub low_confidence: bool,
199 pub column_count: u32,
201 pub ocr_word_count: usize,
203 pub ocr_mean_confidence: Option<f32>,
205}
206
207impl PageReport {
208 fn new(
209 page_index: i32,
210 ocr_source: OcrSource,
211 ocr_backend: Option<String>,
212 ocr: &OcrResult,
213 column_count: u32,
214 low_confidence_threshold: f32,
215 ) -> Self {
216 let confidence = ocr.mean_confidence_unit();
217 Self {
218 page_index,
219 ocr_source,
220 ocr_backend,
221 confidence,
222 low_confidence: confidence
223 .map(|confidence| confidence < low_confidence_threshold)
224 .unwrap_or(false),
225 column_count,
226 ocr_word_count: ocr.word_count,
227 ocr_mean_confidence: ocr.mean_confidence,
228 }
229 }
230}
231
232#[non_exhaustive]
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct BundleReport {
240 pub bundle_version: u32,
242 pub input_kind: String,
244 pub ocr_mean_confidence: Option<f32>,
246 pub ocr_word_count: usize,
248 pub ocr_lang: String,
250 pub clean_char_count: usize,
252 pub pii_token_count: u32,
254 pub pii_tokens_by_class: Vec<ClassCount>,
256 pub pdf_page_count: Option<i32>,
258 pub pdf_page_index: Option<i32>,
260 #[serde(default)]
262 pub pages: Vec<PageReport>,
263 #[serde(default = "default_low_confidence_threshold")]
265 pub low_confidence_threshold: f32,
266}
267
268impl BundleReport {
269 #[allow(clippy::too_many_arguments)]
271 pub fn new(
272 input_kind: impl Into<String>,
273 ocr: &OcrResult,
274 clean_char_count: usize,
275 pii_token_count: u32,
276 pii_tokens_by_class: Vec<ClassCount>,
277 pdf_page_count: Option<i32>,
278 pdf_page_index: Option<i32>,
279 pages: Vec<PageReport>,
280 low_confidence_threshold: f32,
281 ) -> Self {
282 Self {
283 bundle_version: BUNDLE_VERSION,
284 input_kind: input_kind.into(),
285 ocr_mean_confidence: ocr.mean_confidence,
286 ocr_word_count: ocr.word_count,
287 ocr_lang: ocr.lang.clone(),
288 clean_char_count,
289 pii_token_count,
290 pii_tokens_by_class,
291 pdf_page_count,
292 pdf_page_index,
293 pages,
294 low_confidence_threshold,
295 }
296 }
297}
298
299fn default_low_confidence_threshold() -> f32 {
300 DEFAULT_LOW_CONFIDENCE_THRESHOLD
301}
302
303#[non_exhaustive]
305#[derive(Debug, Clone, Copy)]
306pub struct Pipeline {
307 low_confidence_threshold: f32,
308 column_detection: bool,
309}
310
311impl Pipeline {
312 pub fn new() -> Self {
314 Self {
315 low_confidence_threshold: DEFAULT_LOW_CONFIDENCE_THRESHOLD,
316 column_detection: true,
317 }
318 }
319
320 pub fn with_low_confidence_threshold(mut self, threshold: f32) -> Self {
322 self.low_confidence_threshold = threshold.clamp(0.0, 1.0);
323 self
324 }
325
326 pub fn with_column_detection(mut self, enabled: bool) -> Self {
328 self.column_detection = enabled;
329 self
330 }
331
332 #[cfg(feature = "ocr-tesseract")]
337 #[cfg_attr(docsrs, doc(cfg(feature = "ocr-tesseract")))]
338 pub fn clean_with_ocr_backend(
339 &self,
340 input: &Path,
341 agent_out: AgentBundleDir,
342 owner_out: OwnerBundleDir,
343 ocr_backend: &dyn OcrBackend,
344 ) -> Result<SafeBundle, DocumentError> {
345 clean_with_options(input, agent_out, owner_out, ocr_backend, *self)
346 }
347}
348
349impl Default for Pipeline {
350 fn default() -> Self {
351 Self::new()
352 }
353}
354
355#[non_exhaustive]
360#[derive(Debug, Clone)]
361pub struct LayoutSummary {
362 pub page_count: u32,
364}
365
366impl LayoutSummary {
367 pub fn single_page() -> Self {
369 Self { page_count: 1 }
370 }
371
372 pub fn new(page_count: u32) -> Self {
374 Self { page_count }
375 }
376}
377
378#[cfg(feature = "ocr-tesseract")]
390#[cfg_attr(docsrs, doc(cfg(feature = "ocr-tesseract")))]
391pub fn clean(
392 input: &Path,
393 agent_out: AgentBundleDir,
394 owner_out: OwnerBundleDir,
395) -> Result<SafeBundle, DocumentError> {
396 let backend = crate::ocr::TesseractBackend::new();
397 Pipeline::new().clean_with_ocr_backend(input, agent_out, owner_out, &backend)
398}
399
400#[cfg(feature = "ocr-tesseract")]
411#[cfg_attr(docsrs, doc(cfg(feature = "ocr-tesseract")))]
412pub fn clean_with_ocr_backend(
413 input: &Path,
414 agent_out: AgentBundleDir,
415 owner_out: OwnerBundleDir,
416 ocr_backend: &dyn OcrBackend,
417) -> Result<SafeBundle, DocumentError> {
418 Pipeline::new().clean_with_ocr_backend(input, agent_out, owner_out, ocr_backend)
419}
420
421#[cfg(feature = "ocr-tesseract")]
422fn clean_with_options(
423 input: &Path,
424 agent_out: AgentBundleDir,
425 owner_out: OwnerBundleDir,
426 ocr_backend: &dyn OcrBackend,
427 options: Pipeline,
428) -> Result<SafeBundle, DocumentError> {
429 let kind = InputKind::detect(input)?;
430 let absolute_input = absolutize(input);
431 let (absolute_agent_out, absolute_owner_out) = prepare_bundle_dirs(&agent_out, &owner_out)?;
432
433 let extraction = run_document_extraction(input, kind, ocr_backend, options)?;
434 let normalized_text = crate::ocr::normalize_ocr_artifacts(&extraction.ocr_result.text);
441 let pipeline = build_document_pipeline()?;
442 let session = Session::new(Scope::Ephemeral).map_err(|err| pipeline_err("session", err))?;
443 let locale_chain = [LocaleTag::Global];
444 let (clean_doc, spans, _leak_report) = pipeline
445 .clean_with_safety_net(&session, RawDocument::Text(normalized_text), &locale_chain)
446 .map_err(|err| pipeline_err("redact", err))?;
447
448 let clean_text = match clean_doc {
449 CleanDocument::Text(text) => text,
450 _ => {
451 return Err(DocumentError::Pipeline(
452 "pipeline returned non-text variant for text input".to_string(),
453 ));
454 }
455 };
456
457 let manifest = Manifest::from_spans(spans.clone());
458 let counts = count_pii_by_class(&spans);
459 let pii_token_count: u32 = counts.iter().map(|c| c.count).sum();
460
461 let report = BundleReport::new(
462 kind_label(kind),
463 &extraction.ocr_result,
464 clean_text.chars().count(),
465 pii_token_count,
466 counts,
467 extraction.pdf_page_count,
468 extraction.pdf_page_index,
469 extraction.pages,
470 options.low_confidence_threshold,
471 );
472
473 let clean_markdown = format_clean_markdown(&clean_text, kind);
474 write_bundle(&agent_out, &owner_out, &clean_markdown, &manifest, &report)?;
475
476 Ok(SafeBundle::new(
477 clean_markdown,
478 manifest,
479 LayoutSummary::new(extraction.page_count),
480 None,
481 report,
482 absolute_input,
483 absolute_agent_out,
484 absolute_owner_out,
485 ))
486}
487
488#[cfg(feature = "ocr-tesseract")]
489struct DocumentExtraction {
490 ocr_result: OcrResult,
491 pdf_page_count: Option<i32>,
492 pdf_page_index: Option<i32>,
493 pages: Vec<PageReport>,
494 page_count: u32,
495}
496
497#[cfg(feature = "ocr-tesseract")]
498#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
499pub(crate) fn run_ocr(
500 input: &Path,
501 kind: InputKind,
502 ocr_backend: &dyn OcrBackend,
503) -> Result<(OcrResult, Option<i32>, Option<i32>), DocumentError> {
504 let extraction = run_document_extraction(input, kind, ocr_backend, Pipeline::new())?;
505 Ok((
506 extraction.ocr_result,
507 extraction.pdf_page_count,
508 extraction.pdf_page_index,
509 ))
510}
511
512#[cfg(feature = "ocr-tesseract")]
513fn run_document_extraction(
514 input: &Path,
515 kind: InputKind,
516 ocr_backend: &dyn OcrBackend,
517 options: Pipeline,
518) -> Result<DocumentExtraction, DocumentError> {
519 match kind {
520 InputKind::Png | InputKind::Jpeg => {
521 let bytes = fs::read(input)?;
522 let format = detect_image_format(&bytes)?;
523 let (result, column_count) = recognize_image(
524 ocr_backend,
525 ImageInput {
526 bytes,
527 format,
528 dpi: None,
529 },
530 options.column_detection,
531 )?;
532 let page_report = PageReport::new(
533 0,
534 OcrSource::Ocr,
535 Some(ocr_backend.name().to_string()),
536 &result,
537 column_count,
538 options.low_confidence_threshold,
539 );
540 Ok(DocumentExtraction {
541 ocr_result: result,
542 pdf_page_count: None,
543 pdf_page_index: None,
544 pages: vec![page_report],
545 page_count: 1,
546 })
547 }
548 InputKind::Pdf => {
549 #[cfg(feature = "pdf-input")]
550 {
551 use crate::extract::pdf::{extract_pages, PdfPagePayload, PdfRasterConfig};
552 let payloads = extract_pages(input, PdfRasterConfig::new())?;
553 let mut page_results = Vec::with_capacity(payloads.len());
554 let mut pages = Vec::with_capacity(payloads.len());
555 let mut pdf_page_count = None;
556 let mut first_page_index = None;
557
558 for payload in payloads {
559 pdf_page_count = Some(payload.page_count());
560 if first_page_index.is_none() {
561 first_page_index = Some(payload.page_index());
562 }
563 match payload {
564 PdfPagePayload::VectorText {
565 text, page_index, ..
566 } => {
567 let result = OcrResult::new(text, None, 0, "vector-pdf".to_string());
568 pages.push(PageReport::new(
569 page_index,
570 OcrSource::VectorPdf,
571 None,
572 &result,
573 1,
574 options.low_confidence_threshold,
575 ));
576 page_results.push(result);
577 }
578 PdfPagePayload::Raster(raster) => {
579 let (result, column_count) = recognize_image(
580 ocr_backend,
581 ImageInput {
582 bytes: raster.png_bytes,
583 format: ImageFormat::Png,
584 dpi: None,
585 },
586 options.column_detection,
587 )?;
588 pages.push(PageReport::new(
589 raster.page_index,
590 OcrSource::Ocr,
591 Some(ocr_backend.name().to_string()),
592 &result,
593 column_count,
594 options.low_confidence_threshold,
595 ));
596 page_results.push(result);
597 }
598 }
599 }
600
601 Ok(DocumentExtraction {
602 ocr_result: merge_page_results(&page_results),
603 pdf_page_count,
604 pdf_page_index: first_page_index,
605 page_count: pages.len() as u32,
606 pages,
607 })
608 }
609 #[cfg(not(feature = "pdf-input"))]
610 {
611 Err(DocumentError::UnsupportedInput {
612 path: input.to_path_buf(),
613 reason: "rebuild gaze-document with `--features pdf-input` for PDF support",
614 })
615 }
616 }
617 }
618}
619
620#[cfg(feature = "ocr-tesseract")]
621fn recognize_image(
622 ocr_backend: &dyn OcrBackend,
623 image: ImageInput,
624 column_detection: bool,
625) -> Result<(OcrResult, u32), DocumentError> {
626 let hints = OcrHints::default();
627 let lang = hints.primary_language().to_string();
628 let image = crate::preprocess::preprocess_image(image);
629 let spans = ocr_backend
630 .recognize(image, hints)
631 .map_err(map_ocr_error_to_document_error)?;
632 Ok(OcrResult::from_spans_with_column_detection(
633 &spans,
634 lang,
635 column_detection,
636 ))
637}
638
639#[cfg(feature = "ocr-tesseract")]
640fn merge_page_results(results: &[OcrResult]) -> OcrResult {
641 let text = results
642 .iter()
643 .map(|result| result.text.as_str())
644 .collect::<Vec<_>>()
645 .join("\n\n");
646 let mut conf_sum = 0.0f64;
647 let mut conf_count = 0usize;
648 for result in results {
649 if let Some(confidence) = result.mean_confidence {
650 conf_sum += confidence as f64 * result.word_count as f64;
651 conf_count += result.word_count;
652 }
653 }
654 let mean_confidence = if conf_count == 0 {
655 None
656 } else {
657 Some((conf_sum / conf_count as f64) as f32)
658 };
659 OcrResult::new(text, mean_confidence, conf_count, "mixed".to_string())
660}
661
662#[cfg(feature = "ocr-tesseract")]
663fn map_ocr_error_to_document_error(err: OcrError) -> DocumentError {
664 match err {
665 OcrError::InitFailed(hint) => DocumentError::TesseractNotFound(hint),
666 OcrError::RecognizeFailed(detail) => DocumentError::TesseractFailed {
667 status: -1,
668 stderr: detail,
669 },
670 OcrError::UnsupportedFormat(format) => DocumentError::UnsupportedInput {
671 path: PathBuf::new(),
672 reason: match format {
673 ImageFormat::Png => "png image format is not supported by the OCR backend",
674 ImageFormat::Jpeg => "jpeg image format is not supported by the OCR backend",
675 ImageFormat::Tiff => "tiff image format is not supported by the OCR backend",
676 },
677 },
678 OcrError::Internal(detail) => DocumentError::Pipeline(format!("ocr: {detail}")),
679 }
680}
681
682#[cfg(feature = "ocr-tesseract")]
683#[cfg(any(feature = "ocr-tesseract", feature = "mcp"))]
684pub(crate) fn build_document_pipeline() -> Result<GazePipeline, DocumentError> {
685 let email = RegexDetector::emails().map_err(|err| pipeline_err("email-regex", err))?;
686 let phone = RegexDetector::new(
690 r"\+?\d{1,3}[-.\s]\(?\d{3}\)?[-.\s]?\d{3,4}[-.\s]?\d{0,4}",
691 PiiClass::custom("phone"),
692 )
693 .map_err(|err| pipeline_err("phone-regex", err))?;
694 let recipient_name = AnchoredMatchRecognizer::new(
702 "gaze_document.name.recipient".to_string(),
703 vec![
704 "Bill to".to_string(),
705 "Invoice to".to_string(),
706 "Ship to".to_string(),
707 "Attention".to_string(),
708 "Attn".to_string(),
709 ],
710 AnchoredBoundary::LineEnd,
711 48,
712 NameShape::PersonName,
713 CuePosition::Before,
714 "invoice_recipient".to_string(),
715 2,
716 0.88,
717 110,
718 );
719 GazePipeline::builder()
720 .detector(email)
721 .detector(phone)
722 .recognizer(recipient_name)
723 .rule(ClassRule::new(PiiClass::Email, Action::Tokenize))
724 .rule(ClassRule::new(PiiClass::custom("phone"), Action::Tokenize))
725 .rule(ClassRule::new(PiiClass::Name, Action::Tokenize))
726 .rule(DefaultRule::new(Action::Preserve))
727 .build()
728 .map_err(|err| pipeline_err("build", err))
729}
730
731#[cfg(feature = "ocr-tesseract")]
732fn count_pii_by_class(spans: &[EmittedTokenSpan]) -> Vec<ClassCount> {
733 let mut by_class: BTreeMap<String, u32> = BTreeMap::new();
734 for span in spans {
735 *by_class.entry(span.class.to_canonical_str()).or_insert(0) += 1;
736 }
737 by_class
738 .into_iter()
739 .map(|(class, count)| ClassCount::new(class, count))
740 .collect()
741}
742
743#[cfg(feature = "ocr-tesseract")]
744fn write_bundle(
745 agent_out: &AgentBundleDir,
746 owner_out: &OwnerBundleDir,
747 clean_markdown: &str,
748 manifest: &Manifest,
749 report: &BundleReport,
750) -> Result<(), DocumentError> {
751 fs::write(
752 agent_out.as_path().join(CLEAN_MARKDOWN_FILE),
753 clean_markdown,
754 )?;
755 let manifest_json = serde_json::to_vec_pretty(manifest)?;
756 fs::write(owner_out.as_path().join(MANIFEST_FILE), manifest_json)?;
757 let report_json = serde_json::to_vec_pretty(report)?;
758 fs::write(agent_out.as_path().join(REPORT_FILE), report_json)?;
759 Ok(())
760}
761
762#[cfg(feature = "ocr-tesseract")]
763fn prepare_bundle_dirs(
764 agent_out: &AgentBundleDir,
765 owner_out: &OwnerBundleDir,
766) -> Result<(PathBuf, PathBuf), DocumentError> {
767 let agent = normalize_for_layout(agent_out.as_path());
768 let owner = normalize_for_layout(owner_out.as_path());
769 validate_bundle_layout(&agent, &owner)?;
770
771 fs::create_dir_all(agent_out.as_path())
772 .map_err(|err| DocumentError::OutputDir(agent.clone(), err))?;
773 fs::create_dir_all(owner_out.as_path())
774 .map_err(|err| DocumentError::OutputDir(owner.clone(), err))?;
775
776 let agent = fs::canonicalize(agent_out.as_path()).unwrap_or(agent);
777 let owner = fs::canonicalize(owner_out.as_path()).unwrap_or(owner);
778 validate_bundle_layout(&agent, &owner)?;
779 Ok((agent, owner))
780}
781
782fn validate_non_empty_path(path: &Path) -> Result<(), DocumentError> {
783 if path.as_os_str().is_empty() {
784 return Err(DocumentError::BundleLayoutInvalid {
785 reason: BundleLayoutInvalidReason::EmptyPath,
786 });
787 }
788 Ok(())
789}
790
791fn validate_bundle_layout(agent: &Path, owner: &Path) -> Result<(), DocumentError> {
792 if agent == owner {
793 return Err(DocumentError::BundleLayoutInvalid {
794 reason: BundleLayoutInvalidReason::AgentEqualsOwner,
795 });
796 }
797 if agent.starts_with(owner) {
798 return Err(DocumentError::BundleLayoutInvalid {
799 reason: BundleLayoutInvalidReason::AgentNestedInOwner,
800 });
801 }
802 if owner.starts_with(agent) {
803 return Err(DocumentError::BundleLayoutInvalid {
804 reason: BundleLayoutInvalidReason::OwnerNestedInAgent,
805 });
806 }
807 Ok(())
808}
809
810fn normalize_for_layout(path: &Path) -> PathBuf {
811 let absolute = absolutize(path);
812 let mut normalized = PathBuf::new();
813 for component in absolute.components() {
814 match component {
815 Component::CurDir => {}
816 Component::ParentDir => {
817 normalized.pop();
818 }
819 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
820 Component::RootDir => normalized.push(component.as_os_str()),
821 Component::Normal(part) => normalized.push(part),
822 }
823 }
824 normalized
825}
826
827#[cfg(feature = "ocr-tesseract")]
828pub(crate) fn format_clean_markdown(text: &str, kind: InputKind) -> String {
829 let mut out = String::new();
830 out.push_str("# gaze-document safe bundle\n\n");
831 out.push_str(&format!("Source kind: `{}`\n\n", kind_label(kind)));
832 out.push_str("---\n\n");
833 out.push_str(text);
834 if !text.ends_with('\n') {
835 out.push('\n');
836 }
837 out
838}
839
840#[cfg(feature = "ocr-tesseract")]
841pub(crate) fn kind_label(kind: InputKind) -> &'static str {
842 match kind {
843 InputKind::Png => "png",
844 InputKind::Jpeg => "jpeg",
845 InputKind::Pdf => "pdf",
846 }
847}
848
849fn absolutize(path: &Path) -> PathBuf {
850 if path.is_absolute() {
851 path.to_path_buf()
852 } else {
853 std::env::current_dir()
854 .map(|cwd| cwd.join(path))
855 .unwrap_or_else(|_| path.to_path_buf())
856 }
857}
858
859#[cfg(feature = "ocr-tesseract")]
860fn pipeline_err(stage: &'static str, err: impl std::fmt::Display) -> DocumentError {
861 DocumentError::Pipeline(format!("{stage}: {err}"))
862}
863
864#[cfg(all(test, feature = "ocr-tesseract"))]
865mod tests {
866 use super::*;
867 use crate::ocr::{BBox, OcrSpan};
868
869 #[derive(Debug)]
870 struct MockBackend {
871 spans: Vec<OcrSpan>,
872 }
873
874 impl OcrBackend for MockBackend {
875 fn name(&self) -> &str {
876 "mock-ocr"
877 }
878
879 fn recognize(
880 &self,
881 _image: ImageInput,
882 _hints: OcrHints,
883 ) -> Result<Vec<OcrSpan>, OcrError> {
884 Ok(self.spans.clone())
885 }
886 }
887
888 fn span(text: &str, x: u32, y: u32, confidence: f32) -> OcrSpan {
889 OcrSpan {
890 text: text.to_string(),
891 bbox: BBox { x, y, w: 90, h: 16 },
892 confidence: Some(confidence),
893 }
894 }
895
896 fn bundle_dirs(tmp: &tempfile::TempDir) -> (AgentBundleDir, OwnerBundleDir) {
897 (
898 AgentBundleDir::new(tmp.path().join("agent")).expect("agent dir"),
899 OwnerBundleDir::new(tmp.path().join("owner")).expect("owner dir"),
900 )
901 }
902
903 #[test]
904 fn count_pii_by_class_groups_email_and_phone() {
905 let spans = vec![
906 EmittedTokenSpan::new(0..10, 0..10, PiiClass::Email),
907 EmittedTokenSpan::new(20..28, 20..28, PiiClass::Email),
908 EmittedTokenSpan::new(40..50, 40..50, PiiClass::custom("phone")),
909 ];
910 let counts = count_pii_by_class(&spans);
911 assert_eq!(counts.len(), 2);
912 let by_class: BTreeMap<_, _> = counts.iter().map(|c| (c.class.as_str(), c.count)).collect();
913 assert_eq!(by_class.get("email"), Some(&2));
914 assert_eq!(by_class.get("custom:phone"), Some(&1));
915 }
916
917 #[test]
918 fn report_serializes_with_bundle_version() {
919 let ocr = OcrResult::new("body".into(), Some(91.5), 2, "eng".into());
920 let report = BundleReport::new(
921 "png",
922 &ocr,
923 42,
924 3,
925 vec![
926 ClassCount::new("email", 2),
927 ClassCount::new("custom:phone", 1),
928 ],
929 None,
930 None,
931 vec![PageReport::new(
932 0,
933 OcrSource::Ocr,
934 Some("tesseract".to_string()),
935 &ocr,
936 1,
937 DEFAULT_LOW_CONFIDENCE_THRESHOLD,
938 )],
939 DEFAULT_LOW_CONFIDENCE_THRESHOLD,
940 );
941 let json = serde_json::to_value(&report).expect("serialize");
942 assert_eq!(json["bundle_version"], BUNDLE_VERSION);
943 assert_eq!(json["input_kind"], "png");
944 assert_eq!(json["pii_token_count"], 3);
945 assert_eq!(json["pages"][0]["ocr_source"], "ocr");
946 assert_eq!(
947 json["low_confidence_threshold"],
948 DEFAULT_LOW_CONFIDENCE_THRESHOLD
949 );
950 }
951
952 #[test]
953 fn v1_report_without_page_fields_still_deserializes() {
954 let json = serde_json::json!({
955 "bundle_version": 1,
956 "input_kind": "png",
957 "ocr_mean_confidence": 90.0,
958 "ocr_word_count": 2,
959 "ocr_lang": "eng",
960 "clean_char_count": 12,
961 "pii_token_count": 1,
962 "pii_tokens_by_class": [{ "class": "email", "count": 1 }],
963 "pdf_page_count": null,
964 "pdf_page_index": null
965 });
966
967 let report: BundleReport = serde_json::from_value(json).expect("v1 parses");
968
969 assert_eq!(report.bundle_version, 1);
970 assert!(report.pages.is_empty());
971 assert_eq!(
972 report.low_confidence_threshold,
973 DEFAULT_LOW_CONFIDENCE_THRESHOLD
974 );
975 }
976
977 #[test]
978 fn clean_with_mock_backend_flags_low_confidence_and_columns() {
979 let backend = MockBackend {
980 spans: vec![
981 span("Bill", 20, 10, 0.50),
982 span("to:", 116, 10, 0.50),
983 span("Jane", 20, 36, 0.50),
984 span("Doe", 116, 36, 0.50),
985 span("Email:", 360, 10, 0.50),
986 span("alice@example.invalid", 360, 36, 0.50),
988 ],
989 };
990 let tmp = tempfile::tempdir().expect("tempdir");
991 let input = tmp.path().join("input.png");
992 fs::write(&input, b"\x89PNG\r\n\x1A\nnot-real-image").expect("write input");
993 let (agent_out, owner_out) = bundle_dirs(&tmp);
994
995 let bundle = Pipeline::new()
996 .with_low_confidence_threshold(0.65)
997 .clean_with_ocr_backend(&input, agent_out, owner_out, &backend)
998 .expect("clean succeeds");
999
1000 assert_eq!(bundle.report.bundle_version, 2);
1001 assert_eq!(bundle.report.pages.len(), 1);
1002 let page = &bundle.report.pages[0];
1003 assert_eq!(page.ocr_backend.as_deref(), Some("mock-ocr"));
1004 assert_eq!(page.column_count, 2);
1005 assert_eq!(page.confidence, Some(0.5));
1006 assert!(page.low_confidence);
1007 assert!(
1008 bundle.clean_markdown.contains(":Email_"),
1009 "{}",
1010 bundle.clean_markdown
1011 );
1012 assert!(
1013 !bundle.clean_markdown.contains("alice@example.invalid"),
1015 "{}",
1016 bundle.clean_markdown
1017 );
1018 }
1019
1020 #[test]
1021 fn clean_with_mock_backend_preserves_table_cell_context() {
1022 let backend = MockBackend {
1023 spans: vec![
1024 span("Field", 20, 10, 0.92),
1025 span("Value", 160, 10, 0.92),
1026 span("Bill", 20, 40, 0.92),
1027 span("Jane", 160, 40, 0.92),
1028 span("Email", 20, 70, 0.92),
1029 span("alice@example.invalid", 160, 70, 0.92),
1031 ],
1032 };
1033 let tmp = tempfile::tempdir().expect("tempdir");
1034 let input = tmp.path().join("input.png");
1035 fs::write(&input, b"\x89PNG\r\n\x1A\nnot-real-image").expect("write input");
1036 let (agent_out, owner_out) = bundle_dirs(&tmp);
1037
1038 let bundle = Pipeline::new()
1039 .clean_with_ocr_backend(&input, agent_out, owner_out, &backend)
1040 .expect("clean succeeds");
1041
1042 assert_eq!(bundle.report.pages[0].column_count, 1);
1043 assert!(
1044 bundle.clean_markdown.contains("Field\nValue\n\nBill\nJane"),
1045 "{}",
1046 bundle.clean_markdown
1047 );
1048 assert!(
1049 bundle.clean_markdown.contains(":Email_"),
1050 "{}",
1051 bundle.clean_markdown
1052 );
1053 assert!(
1054 !bundle.clean_markdown.contains("alice@example.invalid"),
1056 "{}",
1057 bundle.clean_markdown
1058 );
1059 }
1060
1061 #[cfg(feature = "pdf-input")]
1062 #[test]
1063 fn clean_preprocesses_rotated_image_before_backend_ocr() {
1064 use image::{GrayImage, ImageFormat as EncodedImageFormat, Luma};
1065
1066 #[derive(Debug)]
1067 struct OrientationSensitiveBackend;
1068
1069 impl OcrBackend for OrientationSensitiveBackend {
1070 fn name(&self) -> &str {
1071 "orientation-sensitive"
1072 }
1073
1074 fn recognize(
1075 &self,
1076 image: ImageInput,
1077 _hints: OcrHints,
1078 ) -> Result<Vec<OcrSpan>, OcrError> {
1079 let decoded = image::load_from_memory(&image.bytes)
1080 .map_err(|err| OcrError::Internal(err.to_string()))?;
1081 if decoded.width() <= decoded.height() {
1082 return Ok(Vec::new());
1083 }
1084 Ok(vec![span("alice@example.invalid", 20, 20, 0.91)])
1086 }
1087 }
1088
1089 let mut image = GrayImage::from_pixel(120, 80, Luma([255]));
1090 for y in 38..42 {
1091 for x in 16..104 {
1092 image.put_pixel(x, y, Luma([0]));
1093 }
1094 }
1095 let sideways = image::imageops::rotate90(&image);
1096 let mut bytes = Vec::new();
1097 sideways
1098 .write_to(
1099 &mut std::io::Cursor::new(&mut bytes),
1100 EncodedImageFormat::Png,
1101 )
1102 .expect("encode png");
1103 let tmp = tempfile::tempdir().expect("tempdir");
1104 let input = tmp.path().join("input.png");
1105 fs::write(&input, bytes).expect("write input");
1106 let (agent_out, owner_out) = bundle_dirs(&tmp);
1107
1108 let bundle = Pipeline::new()
1109 .clean_with_ocr_backend(&input, agent_out, owner_out, &OrientationSensitiveBackend)
1110 .expect("clean succeeds");
1111
1112 assert!(
1113 bundle.clean_markdown.contains(":Email_"),
1114 "{}",
1115 bundle.clean_markdown
1116 );
1117 assert!(
1118 !bundle.clean_markdown.contains("alice@example.invalid"),
1120 "{}",
1121 bundle.clean_markdown
1122 );
1123 }
1124
1125 #[cfg(feature = "pdf-input")]
1126 #[test]
1127 fn clean_deskews_image_before_backend_ocr() {
1128 use image::{GrayImage, ImageFormat as EncodedImageFormat, Luma};
1129 use imageproc::geometric_transformations::{rotate_about_center, Interpolation};
1130
1131 fn horizontal_score(bytes: &[u8]) -> Result<u64, OcrError> {
1132 let decoded = image::load_from_memory(bytes)
1133 .map_err(|err| OcrError::Internal(err.to_string()))?
1134 .to_luma8();
1135 let mut score = 0u64;
1136 for y in 0..decoded.height() {
1137 let mut dark = 0u64;
1138 for x in 0..decoded.width() {
1139 if decoded.get_pixel(x, y).0[0] < 200 {
1140 dark += 1;
1141 }
1142 }
1143 score = score.saturating_add(dark.saturating_mul(dark));
1144 }
1145 Ok(score)
1146 }
1147
1148 #[derive(Debug)]
1149 struct DeskewSensitiveBackend {
1150 minimum_score: u64,
1151 }
1152
1153 impl OcrBackend for DeskewSensitiveBackend {
1154 fn name(&self) -> &str {
1155 "deskew-sensitive"
1156 }
1157
1158 fn recognize(
1159 &self,
1160 image: ImageInput,
1161 _hints: OcrHints,
1162 ) -> Result<Vec<OcrSpan>, OcrError> {
1163 if horizontal_score(&image.bytes)? < self.minimum_score {
1164 return Ok(Vec::new());
1165 }
1166 Ok(vec![span("alice@example.invalid", 20, 20, 0.91)])
1168 }
1169 }
1170
1171 let mut image = GrayImage::from_pixel(120, 80, Luma([255]));
1172 for y in 38..42 {
1173 for x in 16..104 {
1174 image.put_pixel(x, y, Luma([0]));
1175 }
1176 }
1177 let skewed = rotate_about_center(
1178 &image,
1179 4.0_f32.to_radians(),
1180 Interpolation::Nearest,
1181 Luma([255]),
1182 );
1183 let mut bytes = Vec::new();
1184 skewed
1185 .write_to(
1186 &mut std::io::Cursor::new(&mut bytes),
1187 EncodedImageFormat::Png,
1188 )
1189 .expect("encode png");
1190 let raw_score = horizontal_score(&bytes).expect("raw score");
1191 let backend = DeskewSensitiveBackend {
1192 minimum_score: raw_score + 1_000,
1193 };
1194 assert!(
1195 backend
1196 .recognize(
1197 ImageInput {
1198 bytes: bytes.clone(),
1199 format: ImageFormat::Png,
1200 dpi: None
1201 },
1202 OcrHints::default()
1203 )
1204 .expect("raw recognize")
1205 .is_empty(),
1206 "raw skewed payload should miss before preprocessing"
1207 );
1208 let tmp = tempfile::tempdir().expect("tempdir");
1209 let input = tmp.path().join("input.png");
1210 fs::write(&input, bytes).expect("write input");
1211 let (agent_out, owner_out) = bundle_dirs(&tmp);
1212
1213 let bundle = Pipeline::new()
1214 .clean_with_ocr_backend(&input, agent_out, owner_out, &backend)
1215 .expect("clean succeeds");
1216
1217 assert!(
1218 bundle.clean_markdown.contains(":Email_"),
1219 "{}",
1220 bundle.clean_markdown
1221 );
1222 assert!(
1223 !bundle.clean_markdown.contains("alice@example.invalid"),
1225 "{}",
1226 bundle.clean_markdown
1227 );
1228 }
1229
1230 #[test]
1231 fn format_clean_markdown_appends_trailing_newline() {
1232 let md = format_clean_markdown("hello", InputKind::Png);
1233 assert!(md.ends_with('\n'));
1234 assert!(md.contains("Source kind: `png`"));
1235 assert!(md.contains("hello"));
1236 }
1237}