1use std::path::{Path, PathBuf};
29use std::time::Duration;
30use thiserror::Error;
31
32use crate::ai_bridge::{AiBridgeError, AiTool, SubprocessBridge};
33
34const DEFAULT_CONFIDENCE_THRESHOLD: f32 = 0.5;
40
41const BOOK_CONFIDENCE_THRESHOLD: f32 = 0.3;
43
44const DEFAULT_TIMEOUT_SECS: u64 = 300;
46
47const MIN_CONFIDENCE: f32 = 0.0;
49
50const MAX_CONFIDENCE: f32 = 1.0;
52
53#[derive(Debug, Error)]
55pub enum YomiTokuError {
56 #[error("Input file not found: {0}")]
57 InputNotFound(PathBuf),
58
59 #[error("Output directory not writable: {0}")]
60 OutputNotWritable(PathBuf),
61
62 #[error("YomiToku execution failed: {0}")]
63 ExecutionFailed(String),
64
65 #[error("YomiToku not installed or not found")]
66 NotInstalled,
67
68 #[error("Invalid output format")]
69 InvalidOutput,
70
71 #[error("IO error: {0}")]
72 IoError(#[from] std::io::Error),
73
74 #[error("JSON parse error: {0}")]
75 JsonError(#[from] serde_json::Error),
76
77 #[error("AI Bridge error: {0}")]
78 BridgeError(#[from] AiBridgeError),
79}
80
81pub type Result<T> = std::result::Result<T, YomiTokuError>;
82
83#[derive(Debug, Clone)]
85pub struct YomiTokuOptions {
86 pub output_format: OutputFormat,
88 pub use_gpu: bool,
90 pub gpu_id: Option<u32>,
92 pub confidence_threshold: f32,
94 pub timeout_secs: u64,
96 pub detect_vertical: bool,
98 pub language: Language,
100}
101
102impl Default for YomiTokuOptions {
103 fn default() -> Self {
104 Self {
105 output_format: OutputFormat::Json,
106 use_gpu: true,
107 gpu_id: None,
108 confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD,
109 timeout_secs: DEFAULT_TIMEOUT_SECS,
110 detect_vertical: true,
111 language: Language::Japanese,
112 }
113 }
114}
115
116impl YomiTokuOptions {
117 pub fn builder() -> YomiTokuOptionsBuilder {
119 YomiTokuOptionsBuilder::default()
120 }
121
122 pub fn for_books() -> Self {
124 Self {
125 detect_vertical: true,
126 confidence_threshold: BOOK_CONFIDENCE_THRESHOLD,
127 ..Default::default()
128 }
129 }
130
131 pub fn horizontal_only() -> Self {
133 Self {
134 detect_vertical: false,
135 ..Default::default()
136 }
137 }
138}
139
140#[derive(Debug, Default)]
142pub struct YomiTokuOptionsBuilder {
143 options: YomiTokuOptions,
144}
145
146impl YomiTokuOptionsBuilder {
147 #[must_use]
149 pub fn output_format(mut self, format: OutputFormat) -> Self {
150 self.options.output_format = format;
151 self
152 }
153
154 #[must_use]
156 pub fn use_gpu(mut self, use_gpu: bool) -> Self {
157 self.options.use_gpu = use_gpu;
158 self
159 }
160
161 #[must_use]
163 pub fn gpu_id(mut self, id: u32) -> Self {
164 self.options.gpu_id = Some(id);
165 self
166 }
167
168 #[must_use]
170 pub fn confidence_threshold(mut self, threshold: f32) -> Self {
171 self.options.confidence_threshold = threshold.clamp(MIN_CONFIDENCE, MAX_CONFIDENCE);
172 self
173 }
174
175 #[must_use]
177 pub fn timeout(mut self, secs: u64) -> Self {
178 self.options.timeout_secs = secs;
179 self
180 }
181
182 #[must_use]
184 pub fn detect_vertical(mut self, detect: bool) -> Self {
185 self.options.detect_vertical = detect;
186 self
187 }
188
189 #[must_use]
191 pub fn language(mut self, lang: Language) -> Self {
192 self.options.language = lang;
193 self
194 }
195
196 #[must_use]
198 pub fn build(self) -> YomiTokuOptions {
199 self.options
200 }
201}
202
203#[derive(Debug, Clone, Copy, Default)]
205pub enum OutputFormat {
206 #[default]
207 Json,
208 Text,
209 Hocr,
210 Pdf,
211}
212
213impl OutputFormat {
214 pub fn extension(&self) -> &str {
216 match self {
217 OutputFormat::Json => "json",
218 OutputFormat::Text => "txt",
219 OutputFormat::Hocr => "hocr",
220 OutputFormat::Pdf => "pdf",
221 }
222 }
223}
224
225#[derive(Debug, Clone, Copy, Default)]
227pub enum Language {
228 #[default]
229 Japanese,
230 English,
231 ChineseSimplified,
232 ChineseTraditional,
233 Korean,
234 Mixed,
235}
236
237impl Language {
238 pub fn code(&self) -> &str {
240 match self {
241 Language::Japanese => "ja",
242 Language::English => "en",
243 Language::ChineseSimplified => "zh-CN",
244 Language::ChineseTraditional => "zh-TW",
245 Language::Korean => "ko",
246 Language::Mixed => "mixed",
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct OcrResult {
254 pub input_path: PathBuf,
256 pub text_blocks: Vec<TextBlock>,
258 pub confidence: f32,
260 pub processing_time: Duration,
262 pub text_direction: TextDirection,
264}
265
266#[derive(Debug, Clone)]
268pub struct TextBlock {
269 pub text: String,
271 pub bbox: (u32, u32, u32, u32),
273 pub confidence: f32,
275 pub direction: TextDirection,
277 pub font_size: Option<f32>,
279}
280
281#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
283pub enum TextDirection {
284 #[default]
285 Horizontal,
286 Vertical,
287 Mixed,
288}
289
290#[derive(Debug)]
292pub struct BatchOcrResult {
293 pub successful: Vec<OcrResult>,
295 pub failed: Vec<(PathBuf, String)>,
297 pub total_time: Duration,
299}
300
301pub struct YomiToku {
303 bridge: SubprocessBridge,
304}
305
306impl YomiToku {
307 pub fn new(bridge: SubprocessBridge) -> Self {
309 Self { bridge }
310 }
311
312 pub fn is_available(&self) -> bool {
314 self.bridge.check_tool(AiTool::YomiToku).unwrap_or(false)
315 }
316
317 pub fn ocr(&self, input_path: &Path, options: &YomiTokuOptions) -> Result<OcrResult> {
319 let start_time = std::time::Instant::now();
320
321 if !input_path.exists() {
322 return Err(YomiTokuError::InputNotFound(input_path.to_path_buf()));
323 }
324
325 let bridge_dir = self.bridge.config().venv_path.parent().unwrap_or(Path::new("."));
327 let bridge_script = bridge_dir.join("yomitoku_bridge.py");
328
329 if !bridge_script.exists() {
330 return Err(YomiTokuError::ExecutionFailed(format!(
331 "Bridge script not found: {}",
332 bridge_script.display()
333 )));
334 }
335
336 let mut args = vec![
338 bridge_script.to_string_lossy().to_string(),
339 input_path.to_string_lossy().to_string(),
340 "--format".to_string(),
341 "json".to_string(),
342 ];
343
344 if options.use_gpu {
345 args.push("--gpu".to_string());
346 if let Some(gpu_id) = options.gpu_id {
347 args.push(gpu_id.to_string());
348 } else {
349 args.push("0".to_string());
350 }
351 } else {
352 args.push("--no-gpu".to_string());
353 }
354
355 args.push("--confidence".to_string());
356 args.push(options.confidence_threshold.to_string());
357
358 let output = self
360 .bridge
361 .execute_with_timeout(&args, Duration::from_secs(options.timeout_secs))?;
362
363 let json_result: serde_json::Value = serde_json::from_str(&output).map_err(|e| {
365 YomiTokuError::ExecutionFailed(format!("Failed to parse output: {} (raw: {})", e, output))
366 })?;
367
368 if let Some(error) = json_result.get("error") {
370 return Err(YomiTokuError::ExecutionFailed(
371 error.as_str().unwrap_or("Unknown error").to_string(),
372 ));
373 }
374
375 let text_blocks = self.parse_bridge_output(&json_result)?;
377 let overall_confidence = json_result
378 .get("confidence")
379 .and_then(|c| c.as_f64())
380 .unwrap_or(0.0) as f32;
381 let text_direction = match json_result.get("text_direction").and_then(|d| d.as_str()) {
382 Some("vertical") => TextDirection::Vertical,
383 Some("horizontal") => TextDirection::Horizontal,
384 Some("mixed") => TextDirection::Mixed,
385 _ => self.detect_dominant_direction(&text_blocks),
386 };
387
388 Ok(OcrResult {
389 input_path: input_path.to_path_buf(),
390 text_blocks,
391 confidence: overall_confidence,
392 processing_time: start_time.elapsed(),
393 text_direction,
394 })
395 }
396
397 pub fn ocr_batch(
399 &self,
400 input_files: &[PathBuf],
401 options: &YomiTokuOptions,
402 progress: Option<Box<dyn Fn(usize, usize) + Send>>,
403 ) -> Result<BatchOcrResult> {
404 let start_time = std::time::Instant::now();
405 let mut successful = Vec::new();
406 let mut failed = Vec::new();
407
408 for (i, input_path) in input_files.iter().enumerate() {
409 if let Some(ref progress_fn) = progress {
410 progress_fn(i + 1, input_files.len());
411 }
412
413 match self.ocr(input_path, options) {
414 Ok(result) => successful.push(result),
415 Err(e) => failed.push((input_path.clone(), e.to_string())),
416 }
417 }
418
419 Ok(BatchOcrResult {
420 successful,
421 failed,
422 total_time: start_time.elapsed(),
423 })
424 }
425
426 pub fn extract_text(result: &OcrResult) -> String {
428 result
429 .text_blocks
430 .iter()
431 .map(|block| block.text.as_str())
432 .collect::<Vec<_>>()
433 .join("\n")
434 }
435
436 #[allow(dead_code)]
438 fn parse_text_blocks(&self, json: &serde_json::Value) -> Result<Vec<TextBlock>> {
439 let blocks = json
440 .get("blocks")
441 .and_then(|b| b.as_array())
442 .ok_or(YomiTokuError::InvalidOutput)?;
443
444 let mut text_blocks = Vec::new();
445
446 for block in blocks {
447 let text = block
448 .get("text")
449 .and_then(|t| t.as_str())
450 .unwrap_or("")
451 .to_string();
452
453 let bbox = (
454 block.get("x").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
455 block.get("y").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
456 block.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
457 block.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
458 );
459
460 let confidence = block
461 .get("confidence")
462 .and_then(|c| c.as_f64())
463 .unwrap_or(0.0) as f32;
464
465 let direction = match block.get("direction").and_then(|d| d.as_str()) {
466 Some("vertical") => TextDirection::Vertical,
467 Some("horizontal") => TextDirection::Horizontal,
468 _ => TextDirection::Horizontal,
469 };
470
471 let font_size = block
472 .get("font_size")
473 .and_then(|f| f.as_f64())
474 .map(|f| f as f32);
475
476 text_blocks.push(TextBlock {
477 text,
478 bbox,
479 confidence,
480 direction,
481 font_size,
482 });
483 }
484
485 Ok(text_blocks)
486 }
487
488 fn parse_bridge_output(&self, json: &serde_json::Value) -> Result<Vec<TextBlock>> {
490 let blocks = json
492 .get("text_blocks")
493 .and_then(|b| b.as_array())
494 .ok_or(YomiTokuError::InvalidOutput)?;
495
496 let mut text_blocks = Vec::new();
497
498 for block in blocks {
499 let text = block
500 .get("text")
501 .and_then(|t| t.as_str())
502 .unwrap_or("")
503 .to_string();
504
505 let bbox_arr = block
507 .get("bbox")
508 .and_then(|b| b.as_array())
509 .map(|arr| {
510 let vals: Vec<u32> = arr
511 .iter()
512 .filter_map(|v| v.as_f64().map(|f| f as u32))
513 .collect();
514 if vals.len() >= 4 {
515 (vals[0], vals[1], vals[2] - vals[0], vals[3] - vals[1]) } else {
517 (0, 0, 0, 0)
518 }
519 })
520 .unwrap_or((0, 0, 0, 0));
521
522 let confidence = block
523 .get("confidence")
524 .and_then(|c| c.as_f64())
525 .unwrap_or(0.0) as f32;
526
527 let direction = match block.get("direction").and_then(|d| d.as_str()) {
528 Some("vertical") => TextDirection::Vertical,
529 Some("horizontal") => TextDirection::Horizontal,
530 _ => TextDirection::Horizontal,
531 };
532
533 text_blocks.push(TextBlock {
534 text,
535 bbox: bbox_arr,
536 confidence,
537 direction,
538 font_size: None,
539 });
540 }
541
542 Ok(text_blocks)
543 }
544
545 #[allow(dead_code)]
547 fn calculate_overall_confidence(&self, blocks: &[TextBlock]) -> f32 {
548 if blocks.is_empty() {
549 return 0.0;
550 }
551
552 let total: f32 = blocks.iter().map(|b| b.confidence).sum();
553 total / blocks.len() as f32
554 }
555
556 fn detect_dominant_direction(&self, blocks: &[TextBlock]) -> TextDirection {
558 if blocks.is_empty() {
559 return TextDirection::Horizontal;
560 }
561
562 let vertical_count = blocks
563 .iter()
564 .filter(|b| b.direction == TextDirection::Vertical)
565 .count();
566
567 let horizontal_count = blocks.len() - vertical_count;
568
569 if vertical_count > horizontal_count * 2 {
570 TextDirection::Vertical
571 } else if horizontal_count > vertical_count * 2 {
572 TextDirection::Horizontal
573 } else {
574 TextDirection::Mixed
575 }
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn test_default_options() {
585 let opts = YomiTokuOptions::default();
586
587 assert!(opts.use_gpu);
588 assert!(opts.detect_vertical);
589 assert_eq!(opts.confidence_threshold, 0.5);
590 assert!(matches!(opts.output_format, OutputFormat::Json));
591 assert!(matches!(opts.language, Language::Japanese));
592 }
593
594 #[test]
595 fn test_builder_pattern() {
596 let opts = YomiTokuOptions::builder()
597 .use_gpu(false)
598 .confidence_threshold(0.8)
599 .detect_vertical(false)
600 .language(Language::English)
601 .timeout(600)
602 .build();
603
604 assert!(!opts.use_gpu);
605 assert_eq!(opts.confidence_threshold, 0.8);
606 assert!(!opts.detect_vertical);
607 assert!(matches!(opts.language, Language::English));
608 assert_eq!(opts.timeout_secs, 600);
609 }
610
611 #[test]
612 fn test_confidence_clamping() {
613 let opts = YomiTokuOptions::builder().confidence_threshold(1.5).build();
614 assert_eq!(opts.confidence_threshold, 1.0);
615
616 let opts = YomiTokuOptions::builder()
617 .confidence_threshold(-0.5)
618 .build();
619 assert_eq!(opts.confidence_threshold, 0.0);
620 }
621
622 #[test]
623 fn test_output_format_extension() {
624 assert_eq!(OutputFormat::Json.extension(), "json");
625 assert_eq!(OutputFormat::Text.extension(), "txt");
626 assert_eq!(OutputFormat::Hocr.extension(), "hocr");
627 assert_eq!(OutputFormat::Pdf.extension(), "pdf");
628 }
629
630 #[test]
631 fn test_language_code() {
632 assert_eq!(Language::Japanese.code(), "ja");
633 assert_eq!(Language::English.code(), "en");
634 assert_eq!(Language::ChineseSimplified.code(), "zh-CN");
635 assert_eq!(Language::Korean.code(), "ko");
636 }
637
638 #[test]
639 fn test_for_books_preset() {
640 let opts = YomiTokuOptions::for_books();
641
642 assert!(opts.detect_vertical);
643 assert_eq!(opts.confidence_threshold, 0.3);
644 }
645
646 #[test]
647 fn test_horizontal_only_preset() {
648 let opts = YomiTokuOptions::horizontal_only();
649
650 assert!(!opts.detect_vertical);
651 }
652
653 #[test]
654 fn test_text_block_construction() {
655 let block = TextBlock {
656 text: "テスト".to_string(),
657 bbox: (10, 20, 100, 50),
658 confidence: 0.95,
659 direction: TextDirection::Horizontal,
660 font_size: Some(12.0),
661 };
662
663 assert_eq!(block.text, "テスト");
664 assert_eq!(block.bbox, (10, 20, 100, 50));
665 assert_eq!(block.confidence, 0.95);
666 assert!(matches!(block.direction, TextDirection::Horizontal));
667 assert_eq!(block.font_size, Some(12.0));
668 }
669
670 #[test]
671 fn test_ocr_result_construction() {
672 let result = OcrResult {
673 input_path: PathBuf::from("/test/page.png"),
674 text_blocks: vec![],
675 confidence: 0.85,
676 processing_time: Duration::from_secs(5),
677 text_direction: TextDirection::Vertical,
678 };
679
680 assert_eq!(result.input_path, PathBuf::from("/test/page.png"));
681 assert_eq!(result.confidence, 0.85);
682 assert!(matches!(result.text_direction, TextDirection::Vertical));
683 }
684
685 #[test]
686 fn test_text_direction_variants() {
687 assert!(matches!(
688 TextDirection::Horizontal,
689 TextDirection::Horizontal
690 ));
691 assert!(matches!(TextDirection::Vertical, TextDirection::Vertical));
692 assert!(matches!(TextDirection::Mixed, TextDirection::Mixed));
693 }
694
695 #[test]
696 fn test_error_types() {
697 let _err1 = YomiTokuError::InputNotFound(PathBuf::from("/test/path"));
698 let _err2 = YomiTokuError::OutputNotWritable(PathBuf::from("/test/path"));
699 let _err3 = YomiTokuError::ExecutionFailed("test".to_string());
700 let _err4 = YomiTokuError::NotInstalled;
701 let _err5 = YomiTokuError::InvalidOutput;
702 }
703
704 #[test]
705 fn test_extract_text() {
706 let result = OcrResult {
707 input_path: PathBuf::from("/test.png"),
708 text_blocks: vec![
709 TextBlock {
710 text: "行1".to_string(),
711 bbox: (0, 0, 100, 20),
712 confidence: 0.9,
713 direction: TextDirection::Horizontal,
714 font_size: None,
715 },
716 TextBlock {
717 text: "行2".to_string(),
718 bbox: (0, 20, 100, 20),
719 confidence: 0.85,
720 direction: TextDirection::Horizontal,
721 font_size: None,
722 },
723 ],
724 confidence: 0.875,
725 processing_time: Duration::from_millis(100),
726 text_direction: TextDirection::Horizontal,
727 };
728
729 let text = YomiToku::extract_text(&result);
730 assert_eq!(text, "行1\n行2");
731 }
732
733 #[test]
734 fn test_gpu_id_setting() {
735 let opts = YomiTokuOptions::builder().gpu_id(1).build();
736
737 assert_eq!(opts.gpu_id, Some(1));
738 }
739
740 #[test]
741 fn test_batch_result_construction() {
742 let batch = BatchOcrResult {
743 successful: vec![],
744 failed: vec![],
745 total_time: Duration::from_secs(10),
746 };
747
748 assert!(batch.successful.is_empty());
749 assert!(batch.failed.is_empty());
750 assert_eq!(batch.total_time, Duration::from_secs(10));
751 }
752
753 #[test]
755 fn test_extract_text_empty() {
756 let result = OcrResult {
757 input_path: PathBuf::from("/test.png"),
758 text_blocks: vec![],
759 confidence: 0.0,
760 processing_time: Duration::from_millis(10),
761 text_direction: TextDirection::Horizontal,
762 };
763
764 let text = YomiToku::extract_text(&result);
765 assert!(text.is_empty());
766 }
767
768 #[test]
770 fn test_extract_text_single_block() {
771 let result = OcrResult {
772 input_path: PathBuf::from("/test.png"),
773 text_blocks: vec![TextBlock {
774 text: "単一ブロック".to_string(),
775 bbox: (0, 0, 100, 20),
776 confidence: 0.9,
777 direction: TextDirection::Horizontal,
778 font_size: None,
779 }],
780 confidence: 0.9,
781 processing_time: Duration::from_millis(50),
782 text_direction: TextDirection::Horizontal,
783 };
784
785 let text = YomiToku::extract_text(&result);
786 assert_eq!(text, "単一ブロック");
787 }
788
789 #[test]
791 fn test_all_language_codes() {
792 assert_eq!(Language::Japanese.code(), "ja");
793 assert_eq!(Language::English.code(), "en");
794 assert_eq!(Language::ChineseSimplified.code(), "zh-CN");
795 assert_eq!(Language::ChineseTraditional.code(), "zh-TW");
796 assert_eq!(Language::Korean.code(), "ko");
797 }
798
799 #[test]
801 fn test_text_direction_equality() {
802 assert_eq!(TextDirection::Horizontal, TextDirection::Horizontal);
803 assert_eq!(TextDirection::Vertical, TextDirection::Vertical);
804 assert_eq!(TextDirection::Mixed, TextDirection::Mixed);
805 assert_ne!(TextDirection::Horizontal, TextDirection::Vertical);
806 }
807
808 #[test]
810 fn test_output_format_all() {
811 let formats = vec![
812 (OutputFormat::Json, "json"),
813 (OutputFormat::Text, "txt"),
814 (OutputFormat::Hocr, "hocr"),
815 (OutputFormat::Pdf, "pdf"),
816 ];
817
818 for (format, expected_ext) in formats {
819 assert_eq!(format.extension(), expected_ext);
820 }
821 }
822
823 #[test]
825 fn test_builder_all_options() {
826 let opts = YomiTokuOptions::builder()
827 .use_gpu(true)
828 .confidence_threshold(0.7)
829 .detect_vertical(true)
830 .output_format(OutputFormat::Hocr)
831 .language(Language::ChineseSimplified)
832 .timeout(1800)
833 .gpu_id(2)
834 .build();
835
836 assert!(opts.use_gpu);
837 assert_eq!(opts.confidence_threshold, 0.7);
838 assert!(opts.detect_vertical);
839 assert!(matches!(opts.output_format, OutputFormat::Hocr));
840 assert!(matches!(opts.language, Language::ChineseSimplified));
841 assert_eq!(opts.timeout_secs, 1800);
842 assert_eq!(opts.gpu_id, Some(2));
843 }
844
845 #[test]
847 fn test_ocr_result_multiple_blocks() {
848 let blocks = vec![
849 TextBlock {
850 text: "第一章".to_string(),
851 bbox: (100, 50, 200, 30),
852 confidence: 0.95,
853 direction: TextDirection::Horizontal,
854 font_size: Some(24.0),
855 },
856 TextBlock {
857 text: "本文内容".to_string(),
858 bbox: (50, 100, 300, 400),
859 confidence: 0.88,
860 direction: TextDirection::Vertical,
861 font_size: Some(12.0),
862 },
863 ];
864
865 let result = OcrResult {
866 input_path: PathBuf::from("/page1.png"),
867 text_blocks: blocks,
868 confidence: 0.915,
869 processing_time: Duration::from_secs(2),
870 text_direction: TextDirection::Mixed,
871 };
872
873 assert_eq!(result.text_blocks.len(), 2);
874 assert!(matches!(result.text_direction, TextDirection::Mixed));
875 }
876
877 #[test]
879 fn test_batch_result_with_failures() {
880 let successful = vec![OcrResult {
881 input_path: PathBuf::from("/page1.png"),
882 text_blocks: vec![],
883 confidence: 0.8,
884 processing_time: Duration::from_secs(1),
885 text_direction: TextDirection::Horizontal,
886 }];
887
888 let failed = vec![
889 (PathBuf::from("/page2.png"), "File not found".to_string()),
890 (PathBuf::from("/page3.png"), "Invalid image".to_string()),
891 ];
892
893 let batch = BatchOcrResult {
894 successful,
895 failed,
896 total_time: Duration::from_secs(3),
897 };
898
899 assert_eq!(batch.successful.len(), 1);
900 assert_eq!(batch.failed.len(), 2);
901 }
902
903 #[test]
905 fn test_error_display_messages() {
906 let errors: Vec<(YomiTokuError, &str)> = vec![
907 (
908 YomiTokuError::InputNotFound(PathBuf::from("/test")),
909 "not found",
910 ),
911 (
912 YomiTokuError::OutputNotWritable(PathBuf::from("/test")),
913 "not writable",
914 ),
915 (
916 YomiTokuError::ExecutionFailed("test".to_string()),
917 "Execution failed",
918 ),
919 (YomiTokuError::NotInstalled, "not installed"),
920 (YomiTokuError::InvalidOutput, "Invalid output"),
921 ];
922
923 for (err, expected_substr) in errors {
924 let msg = err.to_string().to_lowercase();
925 assert!(
926 msg.contains(&expected_substr.to_lowercase()),
927 "Expected '{}' to contain '{}'",
928 msg,
929 expected_substr
930 );
931 }
932 }
933
934 #[test]
936 fn test_text_block_no_font_size() {
937 let block = TextBlock {
938 text: "テスト".to_string(),
939 bbox: (0, 0, 50, 20),
940 confidence: 0.9,
941 direction: TextDirection::Horizontal,
942 font_size: None,
943 };
944
945 assert!(block.font_size.is_none());
946 }
947
948 #[test]
950 fn test_text_block_bbox_boundaries() {
951 let zero_bbox = TextBlock {
953 text: "".to_string(),
954 bbox: (0, 0, 0, 0),
955 confidence: 0.0,
956 direction: TextDirection::Horizontal,
957 font_size: None,
958 };
959 assert_eq!(zero_bbox.bbox.2, 0); assert_eq!(zero_bbox.bbox.3, 0); let large_bbox = TextBlock {
964 text: "Large".to_string(),
965 bbox: (0, 0, 10000, 15000),
966 confidence: 1.0,
967 direction: TextDirection::Vertical,
968 font_size: Some(72.0),
969 };
970 assert_eq!(large_bbox.bbox.2, 10000);
971 assert_eq!(large_bbox.bbox.3, 15000);
972 }
973
974 #[test]
976 fn test_confidence_threshold_edges() {
977 let opts_min = YomiTokuOptions::builder().confidence_threshold(0.0).build();
979 assert_eq!(opts_min.confidence_threshold, 0.0);
980
981 let opts_max = YomiTokuOptions::builder().confidence_threshold(1.0).build();
983 assert_eq!(opts_max.confidence_threshold, 1.0);
984
985 let opts_default = YomiTokuOptions::default();
987 assert!(opts_default.confidence_threshold > 0.0);
988 assert!(opts_default.confidence_threshold <= 1.0);
989 }
990
991 #[test]
993 fn test_batch_result_empty() {
994 let batch = BatchOcrResult {
995 successful: vec![],
996 failed: vec![],
997 total_time: Duration::ZERO,
998 };
999
1000 assert!(batch.successful.is_empty());
1001 assert!(batch.failed.is_empty());
1002 assert_eq!(batch.total_time, Duration::ZERO);
1003 }
1004
1005 #[test]
1007 fn test_batch_result_all_successful() {
1008 let results: Vec<OcrResult> = (0..5)
1009 .map(|i| OcrResult {
1010 input_path: PathBuf::from(format!("/page{}.png", i)),
1011 text_blocks: vec![TextBlock {
1012 text: format!("Page {}", i),
1013 bbox: (0, 0, 100, 50),
1014 confidence: 0.9,
1015 direction: TextDirection::Horizontal,
1016 font_size: Some(12.0),
1017 }],
1018 confidence: 0.9,
1019 processing_time: Duration::from_millis(100),
1020 text_direction: TextDirection::Horizontal,
1021 })
1022 .collect();
1023
1024 let batch = BatchOcrResult {
1025 successful: results,
1026 failed: vec![],
1027 total_time: Duration::from_millis(500),
1028 };
1029
1030 assert_eq!(batch.successful.len(), 5);
1031 assert!(batch.failed.is_empty());
1032 }
1033
1034 #[test]
1036 fn test_language_default() {
1037 let opts = YomiTokuOptions::default();
1038 assert!(matches!(opts.language, Language::Japanese));
1039 }
1040
1041 #[test]
1043 fn test_output_format_default() {
1044 let opts = YomiTokuOptions::default();
1045 assert!(matches!(opts.output_format, OutputFormat::Json));
1046 }
1047
1048 #[test]
1050 fn test_gpu_configuration() {
1051 let opts_no_gpu = YomiTokuOptions::builder().use_gpu(false).build();
1053 assert!(!opts_no_gpu.use_gpu);
1054 assert!(opts_no_gpu.gpu_id.is_none());
1055
1056 let opts_gpu = YomiTokuOptions::builder().use_gpu(true).gpu_id(1).build();
1058 assert!(opts_gpu.use_gpu);
1059 assert_eq!(opts_gpu.gpu_id, Some(1));
1060 }
1061
1062 #[test]
1064 fn test_extract_text_empty_blocks() {
1065 let result = OcrResult {
1066 input_path: PathBuf::from("/empty.png"),
1067 text_blocks: vec![],
1068 confidence: 0.0,
1069 processing_time: Duration::ZERO,
1070 text_direction: TextDirection::Horizontal,
1071 };
1072
1073 let text = YomiToku::extract_text(&result);
1074 assert!(text.is_empty());
1075 }
1076
1077 #[test]
1079 fn test_extract_text_preserves_order() {
1080 let result = OcrResult {
1081 input_path: PathBuf::from("/ordered.png"),
1082 text_blocks: vec![
1083 TextBlock {
1084 text: "First".to_string(),
1085 bbox: (0, 0, 50, 20),
1086 confidence: 0.9,
1087 direction: TextDirection::Horizontal,
1088 font_size: None,
1089 },
1090 TextBlock {
1091 text: "Second".to_string(),
1092 bbox: (0, 20, 50, 20),
1093 confidence: 0.9,
1094 direction: TextDirection::Horizontal,
1095 font_size: None,
1096 },
1097 TextBlock {
1098 text: "Third".to_string(),
1099 bbox: (0, 40, 50, 20),
1100 confidence: 0.9,
1101 direction: TextDirection::Horizontal,
1102 font_size: None,
1103 },
1104 ],
1105 confidence: 0.9,
1106 processing_time: Duration::from_millis(100),
1107 text_direction: TextDirection::Horizontal,
1108 };
1109
1110 let text = YomiToku::extract_text(&result);
1111 assert!(text.contains("First"));
1112 assert!(text.contains("Second"));
1113 assert!(text.contains("Third"));
1114 let first_pos = text.find("First").unwrap();
1116 let second_pos = text.find("Second").unwrap();
1117 let third_pos = text.find("Third").unwrap();
1118 assert!(first_pos < second_pos);
1119 assert!(second_pos < third_pos);
1120 }
1121
1122 #[test]
1124 fn test_timeout_configuration() {
1125 let opts_short = YomiTokuOptions::builder().timeout(1).build();
1127 assert_eq!(opts_short.timeout_secs, 1);
1128
1129 let opts_long = YomiTokuOptions::builder().timeout(86400).build();
1131 assert_eq!(opts_long.timeout_secs, 86400);
1132 }
1133
1134 #[test]
1136 fn test_all_text_directions() {
1137 let directions = [
1138 TextDirection::Horizontal,
1139 TextDirection::Vertical,
1140 TextDirection::Mixed,
1141 ];
1142
1143 for dir in directions {
1144 let result = OcrResult {
1145 input_path: PathBuf::from("/test.png"),
1146 text_blocks: vec![],
1147 confidence: 0.5,
1148 processing_time: Duration::from_secs(1),
1149 text_direction: dir,
1150 };
1151 match result.text_direction {
1153 TextDirection::Horizontal => assert!(matches!(dir, TextDirection::Horizontal)),
1154 TextDirection::Vertical => assert!(matches!(dir, TextDirection::Vertical)),
1155 TextDirection::Mixed => assert!(matches!(dir, TextDirection::Mixed)),
1156 }
1157 }
1158 }
1159
1160 #[test]
1163 fn test_options_debug_impl() {
1164 let options = YomiTokuOptions::builder().confidence_threshold(0.8).build();
1165 let debug_str = format!("{:?}", options);
1166 assert!(debug_str.contains("YomiTokuOptions"));
1167 assert!(debug_str.contains("0.8"));
1168 }
1169
1170 #[test]
1171 fn test_options_clone() {
1172 let original = YomiTokuOptions::builder()
1173 .use_gpu(false)
1174 .confidence_threshold(0.7)
1175 .language(Language::Korean)
1176 .build();
1177 let cloned = original.clone();
1178 assert_eq!(cloned.use_gpu, original.use_gpu);
1179 assert_eq!(cloned.confidence_threshold, original.confidence_threshold);
1180 assert!(matches!(cloned.language, Language::Korean));
1181 }
1182
1183 #[test]
1184 fn test_text_block_debug_impl() {
1185 let block = TextBlock {
1186 text: "テスト".to_string(),
1187 bbox: (10, 20, 30, 40),
1188 confidence: 0.95,
1189 direction: TextDirection::Vertical,
1190 font_size: Some(14.0),
1191 };
1192 let debug_str = format!("{:?}", block);
1193 assert!(debug_str.contains("TextBlock"));
1194 assert!(debug_str.contains("テスト"));
1195 }
1196
1197 #[test]
1198 fn test_text_block_clone() {
1199 let original = TextBlock {
1200 text: "Clone test".to_string(),
1201 bbox: (0, 0, 100, 50),
1202 confidence: 0.9,
1203 direction: TextDirection::Horizontal,
1204 font_size: Some(12.0),
1205 };
1206 let cloned = original.clone();
1207 assert_eq!(cloned.text, original.text);
1208 assert_eq!(cloned.bbox, original.bbox);
1209 assert_eq!(cloned.confidence, original.confidence);
1210 }
1211
1212 #[test]
1213 fn test_ocr_result_debug_impl() {
1214 let result = OcrResult {
1215 input_path: PathBuf::from("/test.png"),
1216 text_blocks: vec![],
1217 confidence: 0.85,
1218 processing_time: Duration::from_secs(1),
1219 text_direction: TextDirection::Horizontal,
1220 };
1221 let debug_str = format!("{:?}", result);
1222 assert!(debug_str.contains("OcrResult"));
1223 }
1224
1225 #[test]
1226 fn test_batch_result_debug_impl() {
1227 let batch = BatchOcrResult {
1228 successful: vec![],
1229 failed: vec![],
1230 total_time: Duration::from_secs(5),
1231 };
1232 let debug_str = format!("{:?}", batch);
1233 assert!(debug_str.contains("BatchOcrResult"));
1234 }
1235
1236 #[test]
1237 fn test_error_debug_impl() {
1238 let err = YomiTokuError::NotInstalled;
1239 let debug_str = format!("{:?}", err);
1240 assert!(debug_str.contains("NotInstalled"));
1241 }
1242
1243 #[test]
1244 fn test_language_debug_impl() {
1245 let lang = Language::ChineseTraditional;
1246 let debug_str = format!("{:?}", lang);
1247 assert!(debug_str.contains("ChineseTraditional"));
1248 }
1249
1250 #[test]
1251 fn test_output_format_debug_impl() {
1252 let format = OutputFormat::Hocr;
1253 let debug_str = format!("{:?}", format);
1254 assert!(debug_str.contains("Hocr"));
1255 }
1256
1257 #[test]
1258 fn test_text_direction_debug_impl() {
1259 let dir = TextDirection::Mixed;
1260 let debug_str = format!("{:?}", dir);
1261 assert!(debug_str.contains("Mixed"));
1262 }
1263
1264 #[test]
1265 fn test_text_direction_clone() {
1266 let original = TextDirection::Vertical;
1267 let cloned = original;
1268 assert!(matches!(cloned, TextDirection::Vertical));
1269 }
1270
1271 #[test]
1272 fn test_language_clone() {
1273 let original = Language::English;
1274 let cloned = original;
1275 assert_eq!(cloned.code(), original.code());
1276 }
1277
1278 #[test]
1279 fn test_output_format_clone() {
1280 let original = OutputFormat::Pdf;
1281 let cloned = original;
1282 assert_eq!(cloned.extension(), original.extension());
1283 }
1284
1285 #[test]
1286 fn test_error_io_conversion() {
1287 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1288 let yomi_err: YomiTokuError = io_err.into();
1289 let msg = yomi_err.to_string().to_lowercase();
1290 assert!(msg.contains("io") || msg.contains("error"));
1291 }
1292
1293 #[test]
1294 fn test_builder_default_produces_valid_options() {
1295 let opts = YomiTokuOptionsBuilder::default().build();
1296 assert!(opts.confidence_threshold >= 0.0 && opts.confidence_threshold <= 1.0);
1297 assert!(opts.timeout_secs > 0);
1298 }
1299
1300 #[test]
1301 fn test_text_block_with_unicode_text() {
1302 let block = TextBlock {
1303 text: "日本語テスト 한국어 中文 🎉".to_string(),
1304 bbox: (0, 0, 100, 50),
1305 confidence: 0.9,
1306 direction: TextDirection::Horizontal,
1307 font_size: None,
1308 };
1309 assert!(block.text.contains("日本語"));
1310 assert!(block.text.contains("한국어"));
1311 assert!(block.text.contains("中文"));
1312 }
1313
1314 #[test]
1315 fn test_text_block_with_empty_text() {
1316 let block = TextBlock {
1317 text: String::new(),
1318 bbox: (0, 0, 0, 0),
1319 confidence: 0.0,
1320 direction: TextDirection::Horizontal,
1321 font_size: None,
1322 };
1323 assert!(block.text.is_empty());
1324 }
1325
1326 #[test]
1327 fn test_ocr_result_path_types() {
1328 let result_abs = OcrResult {
1330 input_path: PathBuf::from("/absolute/path/image.png"),
1331 text_blocks: vec![],
1332 confidence: 0.5,
1333 processing_time: Duration::ZERO,
1334 text_direction: TextDirection::Horizontal,
1335 };
1336 assert!(result_abs.input_path.is_absolute());
1337
1338 let result_rel = OcrResult {
1340 input_path: PathBuf::from("relative/path/image.png"),
1341 text_blocks: vec![],
1342 confidence: 0.5,
1343 processing_time: Duration::ZERO,
1344 text_direction: TextDirection::Horizontal,
1345 };
1346 assert!(result_rel.input_path.is_relative());
1347 }
1348
1349 #[test]
1350 fn test_processing_time_variations() {
1351 let result_zero = OcrResult {
1353 input_path: PathBuf::from("/fast.png"),
1354 text_blocks: vec![],
1355 confidence: 1.0,
1356 processing_time: Duration::ZERO,
1357 text_direction: TextDirection::Horizontal,
1358 };
1359 assert_eq!(result_zero.processing_time, Duration::ZERO);
1360
1361 let result_long = OcrResult {
1363 input_path: PathBuf::from("/slow.png"),
1364 text_blocks: vec![],
1365 confidence: 0.5,
1366 processing_time: Duration::from_secs(3600), text_direction: TextDirection::Horizontal,
1368 };
1369 assert_eq!(result_long.processing_time.as_secs(), 3600);
1370 }
1371
1372 #[test]
1373 fn test_font_size_variations() {
1374 let small_font = TextBlock {
1376 text: "tiny".to_string(),
1377 bbox: (0, 0, 10, 5),
1378 confidence: 0.5,
1379 direction: TextDirection::Horizontal,
1380 font_size: Some(4.0),
1381 };
1382 assert_eq!(small_font.font_size, Some(4.0));
1383
1384 let large_font = TextBlock {
1386 text: "HUGE".to_string(),
1387 bbox: (0, 0, 500, 200),
1388 confidence: 0.9,
1389 direction: TextDirection::Horizontal,
1390 font_size: Some(144.0),
1391 };
1392 assert_eq!(large_font.font_size, Some(144.0));
1393 }
1394
1395 #[test]
1396 fn test_preset_consistency() {
1397 let books = YomiTokuOptions::for_books();
1398 let horizontal = YomiTokuOptions::horizontal_only();
1399
1400 assert!(books.detect_vertical);
1402
1403 assert!(!horizontal.detect_vertical);
1405 }
1406
1407 #[test]
1408 fn test_error_path_extraction() {
1409 let path = PathBuf::from("/some/input/file.png");
1410 let err = YomiTokuError::InputNotFound(path.clone());
1411
1412 if let YomiTokuError::InputNotFound(p) = err {
1413 assert_eq!(p, path);
1414 } else {
1415 panic!("Wrong error variant");
1416 }
1417 }
1418
1419 #[test]
1420 fn test_batch_result_mixed() {
1421 let successful = vec![
1423 OcrResult {
1424 input_path: PathBuf::from("/page1.png"),
1425 text_blocks: vec![],
1426 confidence: 0.9,
1427 processing_time: Duration::from_millis(100),
1428 text_direction: TextDirection::Horizontal,
1429 },
1430 OcrResult {
1431 input_path: PathBuf::from("/page3.png"),
1432 text_blocks: vec![],
1433 confidence: 0.8,
1434 processing_time: Duration::from_millis(150),
1435 text_direction: TextDirection::Vertical,
1436 },
1437 ];
1438
1439 let failed = vec![(PathBuf::from("/page2.png"), "corrupt image".to_string())];
1440
1441 let batch = BatchOcrResult {
1442 successful,
1443 failed,
1444 total_time: Duration::from_millis(350),
1445 };
1446
1447 assert_eq!(batch.successful.len(), 2);
1448 assert_eq!(batch.failed.len(), 1);
1449 assert!(batch.failed[0].1.contains("corrupt"));
1450 }
1451
1452 #[test]
1453 fn test_confidence_zero_to_one_range() {
1454 for i in 0..=10 {
1455 let conf = i as f32 / 10.0;
1456 let block = TextBlock {
1457 text: format!("conf_{}", i),
1458 bbox: (0, 0, 10, 10),
1459 confidence: conf,
1460 direction: TextDirection::Horizontal,
1461 font_size: None,
1462 };
1463 assert!(block.confidence >= 0.0 && block.confidence <= 1.0);
1464 }
1465 }
1466
1467 #[test]
1468 fn test_all_language_variants() {
1469 let languages = [
1470 Language::Japanese,
1471 Language::English,
1472 Language::ChineseSimplified,
1473 Language::ChineseTraditional,
1474 Language::Korean,
1475 ];
1476
1477 for lang in languages {
1478 let code = lang.code();
1479 assert!(!code.is_empty());
1480 }
1481 }
1482
1483 #[test]
1484 fn test_all_output_format_variants() {
1485 let formats = [
1486 OutputFormat::Json,
1487 OutputFormat::Text,
1488 OutputFormat::Hocr,
1489 OutputFormat::Pdf,
1490 ];
1491
1492 for format in formats {
1493 let ext = format.extension();
1494 assert!(!ext.is_empty());
1495 }
1496 }
1497
1498 #[test]
1501 fn test_error_input_not_found() {
1502 let path = std::path::PathBuf::from("/nonexistent/image.png");
1503 let err = YomiTokuError::InputNotFound(path.clone());
1504 let msg = format!("{}", err);
1505 assert!(msg.contains("Input file not found"));
1506 assert!(msg.contains("/nonexistent/image.png"));
1507 }
1508
1509 #[test]
1510 fn test_error_execution_failed() {
1511 let err = YomiTokuError::ExecutionFailed("Engine initialization failed".to_string());
1512 let msg = format!("{}", err);
1513 assert!(msg.contains("execution failed"));
1514 }
1515
1516 #[test]
1517 fn test_error_not_installed() {
1518 let err = YomiTokuError::NotInstalled;
1519 let msg = format!("{}", err);
1520 assert!(msg.contains("not installed") || msg.contains("not found"));
1521 }
1522
1523 #[test]
1524 fn test_error_output_not_writable() {
1525 let path = std::path::PathBuf::from("/readonly/dir");
1526 let err = YomiTokuError::OutputNotWritable(path);
1527 let msg = format!("{}", err);
1528 assert!(msg.contains("not writable"));
1529 }
1530
1531 #[test]
1532 fn test_error_invalid_output() {
1533 let err = YomiTokuError::InvalidOutput;
1534 let msg = format!("{}", err);
1535 assert!(msg.contains("Invalid output"));
1536 }
1537
1538 #[test]
1539 fn test_error_from_io_error() {
1540 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1541 let yomi_err: YomiTokuError = io_err.into();
1542 let msg = format!("{}", yomi_err);
1543 assert!(!msg.is_empty());
1544 }
1545
1546 #[test]
1547 fn test_error_execution_failed_debug() {
1548 let err = YomiTokuError::ExecutionFailed("test".to_string());
1549 let debug = format!("{:?}", err);
1550 assert!(debug.contains("ExecutionFailed"));
1551 }
1552
1553 #[test]
1554 fn test_ocr_result_with_error_state() {
1555 let result = OcrResult {
1557 input_path: std::path::PathBuf::from("test.png"),
1558 text_blocks: vec![],
1559 confidence: 0.0,
1560 processing_time: std::time::Duration::from_secs(1),
1561 text_direction: TextDirection::Horizontal,
1562 };
1563 assert!(result.text_blocks.is_empty());
1564 assert_eq!(result.confidence, 0.0);
1565 }
1566
1567 #[test]
1568 fn test_batch_result_partial_failure() {
1569 let results = BatchOcrResult {
1570 successful: vec![OcrResult {
1571 input_path: std::path::PathBuf::from("good.png"),
1572 text_blocks: vec![],
1573 confidence: 0.9,
1574 processing_time: std::time::Duration::from_millis(100),
1575 text_direction: TextDirection::Horizontal,
1576 }],
1577 failed: vec![(
1578 std::path::PathBuf::from("bad.png"),
1579 "OCR failed".to_string(),
1580 )],
1581 total_time: std::time::Duration::from_millis(200),
1582 };
1583
1584 assert_eq!(results.successful.len(), 1);
1585 assert_eq!(results.failed.len(), 1);
1586 }
1587
1588 #[test]
1589 fn test_batch_result_all_errors() {
1590 let results = BatchOcrResult {
1591 successful: vec![],
1592 failed: vec![
1593 (std::path::PathBuf::from("a.png"), "Timeout".to_string()),
1594 (
1595 std::path::PathBuf::from("b.png"),
1596 "Python not found".to_string(),
1597 ),
1598 ],
1599 total_time: std::time::Duration::from_secs(30),
1600 };
1601
1602 assert!(results.successful.is_empty());
1603 assert_eq!(results.failed.len(), 2);
1604 }
1605
1606 #[test]
1607 fn test_batch_result_all_success() {
1608 let results = BatchOcrResult {
1609 successful: vec![
1610 OcrResult {
1611 input_path: std::path::PathBuf::from("page1.png"),
1612 text_blocks: vec![],
1613 confidence: 0.95,
1614 processing_time: std::time::Duration::from_millis(50),
1615 text_direction: TextDirection::Horizontal,
1616 },
1617 OcrResult {
1618 input_path: std::path::PathBuf::from("page2.png"),
1619 text_blocks: vec![],
1620 confidence: 0.92,
1621 processing_time: std::time::Duration::from_millis(55),
1622 text_direction: TextDirection::Vertical,
1623 },
1624 ],
1625 failed: vec![],
1626 total_time: std::time::Duration::from_millis(105),
1627 };
1628
1629 assert_eq!(results.successful.len(), 2);
1630 assert!(results.failed.is_empty());
1631 }
1632
1633 #[test]
1636 fn test_yomitoku_types_send_sync() {
1637 fn assert_send_sync<T: Send + Sync>() {}
1638 assert_send_sync::<YomiTokuOptions>();
1639 assert_send_sync::<TextBlock>();
1640 assert_send_sync::<OcrResult>();
1641 assert_send_sync::<BatchOcrResult>();
1642 }
1643
1644 #[test]
1645 fn test_concurrent_options_building() {
1646 use std::thread;
1647
1648 let handles: Vec<_> = (0..4)
1649 .map(|i| {
1650 thread::spawn(move || -> YomiTokuOptions {
1651 YomiTokuOptions::builder()
1652 .confidence_threshold(0.3 + (i as f32 * 0.1))
1653 .timeout(100 + i as u64 * 50)
1654 .build()
1655 })
1656 })
1657 .collect();
1658
1659 for (i, handle) in handles.into_iter().enumerate() {
1660 let opts: YomiTokuOptions = handle.join().unwrap();
1661 let expected_conf = 0.3 + (i as f32 * 0.1);
1662 assert!((opts.confidence_threshold - expected_conf).abs() < 0.001);
1663 }
1664 }
1665
1666 #[test]
1667 fn test_ocr_result_thread_transfer() {
1668 use std::thread;
1669
1670 let result = OcrResult {
1671 input_path: std::path::PathBuf::from("test.png"),
1672 text_blocks: vec![TextBlock {
1673 text: "テスト".to_string(),
1674 bbox: (10, 20, 100, 50),
1675 confidence: 0.95,
1676 direction: TextDirection::Vertical,
1677 font_size: Some(12.0),
1678 }],
1679 confidence: 0.95,
1680 processing_time: Duration::from_millis(200),
1681 text_direction: TextDirection::Vertical,
1682 };
1683
1684 let handle = thread::spawn(move || {
1685 assert_eq!(result.text_blocks.len(), 1);
1686 assert_eq!(result.text_blocks[0].text, "テスト");
1687 result
1688 });
1689
1690 let received = handle.join().unwrap();
1691 assert_eq!(received.confidence, 0.95);
1692 }
1693
1694 #[test]
1695 fn test_options_shared_across_threads() {
1696 use std::sync::Arc;
1697 use std::thread;
1698
1699 let opts = Arc::new(
1700 YomiTokuOptions::builder()
1701 .confidence_threshold(0.7)
1702 .language(Language::Japanese)
1703 .build(),
1704 );
1705
1706 let handles: Vec<_> = (0..4)
1707 .map(|_| {
1708 let o = Arc::clone(&opts);
1709 thread::spawn(move || {
1710 assert!((o.confidence_threshold - 0.7).abs() < 0.001);
1711 o.confidence_threshold
1712 })
1713 })
1714 .collect();
1715
1716 for handle in handles {
1717 let conf = handle.join().unwrap();
1718 assert!((conf - 0.7).abs() < 0.001);
1719 }
1720 }
1721
1722 #[test]
1725 fn test_confidence_threshold_boundary_zero() {
1726 let opts = YomiTokuOptions::builder().confidence_threshold(0.0).build();
1727 assert_eq!(opts.confidence_threshold, 0.0);
1728 }
1729
1730 #[test]
1731 fn test_confidence_threshold_boundary_one() {
1732 let opts = YomiTokuOptions::builder().confidence_threshold(1.0).build();
1733 assert_eq!(opts.confidence_threshold, 1.0);
1734 }
1735
1736 #[test]
1737 fn test_timeout_secs_zero() {
1738 let opts = YomiTokuOptions::builder().timeout(0).build();
1739 assert_eq!(opts.timeout_secs, 0);
1740 }
1741
1742 #[test]
1743 fn test_timeout_secs_large() {
1744 let opts = YomiTokuOptions::builder().timeout(86400).build();
1745 assert_eq!(opts.timeout_secs, 86400);
1746 }
1747
1748 #[test]
1749 fn test_bbox_zero_dimensions() {
1750 let block = TextBlock {
1751 text: "test".to_string(),
1752 bbox: (0, 0, 0, 0),
1753 confidence: 0.5,
1754 direction: TextDirection::Horizontal,
1755 font_size: None,
1756 };
1757 assert_eq!(block.bbox.2, 0); assert_eq!(block.bbox.3, 0); }
1760
1761 #[test]
1762 fn test_bbox_large_dimensions() {
1763 let block = TextBlock {
1764 text: "large".to_string(),
1765 bbox: (10000, 20000, 5000, 3000),
1766 confidence: 0.9,
1767 direction: TextDirection::Horizontal,
1768 font_size: Some(24.0),
1769 };
1770 assert_eq!(block.bbox.0, 10000); assert_eq!(block.bbox.2, 5000); }
1773
1774 #[test]
1775 fn test_text_block_empty_text_boundary() {
1776 let block = TextBlock {
1777 text: String::new(),
1778 bbox: (0, 0, 100, 50),
1779 confidence: 0.5,
1780 direction: TextDirection::Horizontal,
1781 font_size: None,
1782 };
1783 assert!(block.text.is_empty());
1784 }
1785
1786 #[test]
1787 fn test_text_block_unicode_boundary() {
1788 let block = TextBlock {
1789 text: "日本語テキスト🎉".to_string(),
1790 bbox: (0, 0, 200, 100),
1791 confidence: 0.99,
1792 direction: TextDirection::Vertical,
1793 font_size: Some(16.0),
1794 };
1795 assert!(block.text.contains("日本語"));
1796 assert!(block.text.contains("🎉"));
1797 }
1798
1799 #[test]
1800 fn test_processing_time_nanos_boundary() {
1801 let result = OcrResult {
1802 input_path: std::path::PathBuf::from("nano.png"),
1803 text_blocks: vec![],
1804 confidence: 1.0,
1805 processing_time: Duration::from_nanos(1),
1806 text_direction: TextDirection::Horizontal,
1807 };
1808 assert_eq!(result.processing_time.as_nanos(), 1);
1809 }
1810
1811 #[test]
1812 fn test_batch_result_empty_boundary() {
1813 let results = BatchOcrResult {
1814 successful: vec![],
1815 failed: vec![],
1816 total_time: Duration::ZERO,
1817 };
1818 assert!(results.successful.is_empty());
1819 assert!(results.failed.is_empty());
1820 assert_eq!(results.total_time, Duration::ZERO);
1821 }
1822
1823 #[test]
1824 fn test_font_size_none() {
1825 let block = TextBlock {
1826 text: "no size".to_string(),
1827 bbox: (0, 0, 50, 20),
1828 confidence: 0.8,
1829 direction: TextDirection::Horizontal,
1830 font_size: None,
1831 };
1832 assert!(block.font_size.is_none());
1833 }
1834
1835 #[test]
1836 fn test_font_size_zero() {
1837 let block = TextBlock {
1838 text: "zero size".to_string(),
1839 bbox: (0, 0, 50, 20),
1840 confidence: 0.8,
1841 direction: TextDirection::Horizontal,
1842 font_size: Some(0.0),
1843 };
1844 assert_eq!(block.font_size, Some(0.0));
1845 }
1846}