Skip to main content

superbook_pdf/
yomitoku.rs

1//! YomiToku Japanese AI-OCR module
2//!
3//! Provides integration with YomiToku for Japanese text recognition in images.
4//!
5//! # Features
6//!
7//! - High-accuracy Japanese OCR
8//! - Vertical/horizontal text detection
9//! - Searchable PDF layer generation
10//! - Batch processing support
11//!
12//! # Example
13//!
14//! ```rust,no_run
15//! use superbook_pdf::{YomiToku, YomiTokuOptions};
16//! use superbook_pdf::yomitoku::Language;
17//!
18//! // Configure OCR
19//! let options = YomiTokuOptions::builder()
20//!     .language(Language::Japanese)
21//!     .confidence_threshold(0.5)
22//!     .build();
23//!
24//! // Perform OCR
25//! // let result = YomiToku::new().recognize("page.png", &options);
26//! ```
27
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30use thiserror::Error;
31
32use crate::ai_bridge::{AiBridgeError, AiTool, SubprocessBridge};
33
34// ============================================================
35// Constants
36// ============================================================
37
38/// Default confidence threshold for OCR results
39const DEFAULT_CONFIDENCE_THRESHOLD: f32 = 0.5;
40
41/// Lower confidence threshold for book scanning (captures more text)
42const BOOK_CONFIDENCE_THRESHOLD: f32 = 0.3;
43
44/// Default timeout for OCR processing (5 minutes)
45const DEFAULT_TIMEOUT_SECS: u64 = 300;
46
47/// Minimum confidence threshold
48const MIN_CONFIDENCE: f32 = 0.0;
49
50/// Maximum confidence threshold
51const MAX_CONFIDENCE: f32 = 1.0;
52
53/// YomiToku error types
54#[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/// YomiToku OCR options
84#[derive(Debug, Clone)]
85pub struct YomiTokuOptions {
86    /// Output format
87    pub output_format: OutputFormat,
88    /// Enable GPU acceleration
89    pub use_gpu: bool,
90    /// GPU device ID
91    pub gpu_id: Option<u32>,
92    /// Confidence threshold (0.0-1.0)
93    pub confidence_threshold: f32,
94    /// Timeout in seconds
95    pub timeout_secs: u64,
96    /// Enable vertical text detection
97    pub detect_vertical: bool,
98    /// Language hint
99    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    /// Create a new options builder
118    pub fn builder() -> YomiTokuOptionsBuilder {
119        YomiTokuOptionsBuilder::default()
120    }
121
122    /// Create options optimized for book scanning
123    pub fn for_books() -> Self {
124        Self {
125            detect_vertical: true,
126            confidence_threshold: BOOK_CONFIDENCE_THRESHOLD,
127            ..Default::default()
128        }
129    }
130
131    /// Create options for horizontal text only
132    pub fn horizontal_only() -> Self {
133        Self {
134            detect_vertical: false,
135            ..Default::default()
136        }
137    }
138}
139
140/// Builder for YomiTokuOptions
141#[derive(Debug, Default)]
142pub struct YomiTokuOptionsBuilder {
143    options: YomiTokuOptions,
144}
145
146impl YomiTokuOptionsBuilder {
147    /// Set output format
148    #[must_use]
149    pub fn output_format(mut self, format: OutputFormat) -> Self {
150        self.options.output_format = format;
151        self
152    }
153
154    /// Enable/disable GPU
155    #[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    /// Set GPU device ID
162    #[must_use]
163    pub fn gpu_id(mut self, id: u32) -> Self {
164        self.options.gpu_id = Some(id);
165        self
166    }
167
168    /// Set confidence threshold
169    #[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    /// Set timeout in seconds
176    #[must_use]
177    pub fn timeout(mut self, secs: u64) -> Self {
178        self.options.timeout_secs = secs;
179        self
180    }
181
182    /// Enable/disable vertical text detection
183    #[must_use]
184    pub fn detect_vertical(mut self, detect: bool) -> Self {
185        self.options.detect_vertical = detect;
186        self
187    }
188
189    /// Set language hint
190    #[must_use]
191    pub fn language(mut self, lang: Language) -> Self {
192        self.options.language = lang;
193        self
194    }
195
196    /// Build the options
197    #[must_use]
198    pub fn build(self) -> YomiTokuOptions {
199        self.options
200    }
201}
202
203/// Output format for OCR results
204#[derive(Debug, Clone, Copy, Default)]
205pub enum OutputFormat {
206    #[default]
207    Json,
208    Text,
209    Hocr,
210    Pdf,
211}
212
213impl OutputFormat {
214    /// Get file extension for format
215    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/// Language hint for OCR
226#[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    /// Get language code
239    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/// OCR result for a single page
252#[derive(Debug, Clone)]
253pub struct OcrResult {
254    /// Input image path
255    pub input_path: PathBuf,
256    /// Recognized text blocks
257    pub text_blocks: Vec<TextBlock>,
258    /// Overall confidence score
259    pub confidence: f32,
260    /// Processing time
261    pub processing_time: Duration,
262    /// Detected text direction
263    pub text_direction: TextDirection,
264}
265
266/// A recognized text block
267#[derive(Debug, Clone)]
268pub struct TextBlock {
269    /// Recognized text content
270    pub text: String,
271    /// Bounding box (x, y, width, height)
272    pub bbox: (u32, u32, u32, u32),
273    /// Confidence score (0.0-1.0)
274    pub confidence: f32,
275    /// Text direction
276    pub direction: TextDirection,
277    /// Font size estimate (points)
278    pub font_size: Option<f32>,
279}
280
281/// Text direction
282#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
283pub enum TextDirection {
284    #[default]
285    Horizontal,
286    Vertical,
287    Mixed,
288}
289
290/// Batch OCR result
291#[derive(Debug)]
292pub struct BatchOcrResult {
293    /// Successful results
294    pub successful: Vec<OcrResult>,
295    /// Failed files
296    pub failed: Vec<(PathBuf, String)>,
297    /// Total processing time
298    pub total_time: Duration,
299}
300
301/// YomiToku OCR processor
302pub struct YomiToku {
303    bridge: SubprocessBridge,
304}
305
306impl YomiToku {
307    /// Create a new YomiToku processor
308    pub fn new(bridge: SubprocessBridge) -> Self {
309        Self { bridge }
310    }
311
312    /// Check if YomiToku is available
313    pub fn is_available(&self) -> bool {
314        self.bridge.check_tool(AiTool::YomiToku).unwrap_or(false)
315    }
316
317    /// Perform OCR on a single image
318    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        // Get bridge script path
326        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        // Build command arguments for bridge script
337        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        // Execute YomiToku via bridge
359        let output = self
360            .bridge
361            .execute_with_timeout(&args, Duration::from_secs(options.timeout_secs))?;
362
363        // Parse JSON output from bridge
364        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        // Check for error in response
369        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        // Extract text blocks from bridge output format
376        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    /// Perform OCR on multiple images
398    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    /// Extract full text from OCR result
427    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    /// Parse text blocks from JSON output
437    #[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    /// Parse text blocks from bridge script JSON output
489    fn parse_bridge_output(&self, json: &serde_json::Value) -> Result<Vec<TextBlock>> {
490        // Bridge script returns: { "text_blocks": [...], "full_text": "...", ... }
491        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            // Bridge format: bbox is [x1, y1, x2, y2]
506            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]) // Convert to x, y, w, h
516                    } 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    /// Calculate overall confidence from text blocks
546    #[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    /// Detect dominant text direction
557    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 extract_text with empty blocks
754    #[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 extract_text with single block
769    #[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 all language codes
790    #[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 text direction equality
800    #[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 output format conversion
809    #[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 builder with all options
824    #[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 OcrResult with multiple blocks
846    #[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 batch result with failures
878    #[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 error display messages
904    #[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 TextBlock without font_size
935    #[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 TextBlock bbox boundary values
949    #[test]
950    fn test_text_block_bbox_boundaries() {
951        // Zero-size bbox
952        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); // width
960        assert_eq!(zero_bbox.bbox.3, 0); // height
961
962        // Large bbox
963        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 confidence threshold edge cases
975    #[test]
976    fn test_confidence_threshold_edges() {
977        // Minimum confidence
978        let opts_min = YomiTokuOptions::builder().confidence_threshold(0.0).build();
979        assert_eq!(opts_min.confidence_threshold, 0.0);
980
981        // Maximum confidence
982        let opts_max = YomiTokuOptions::builder().confidence_threshold(1.0).build();
983        assert_eq!(opts_max.confidence_threshold, 1.0);
984
985        // Default confidence
986        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 batch result with empty arrays
992    #[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 batch result all successful
1006    #[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 Language default
1035    #[test]
1036    fn test_language_default() {
1037        let opts = YomiTokuOptions::default();
1038        assert!(matches!(opts.language, Language::Japanese));
1039    }
1040
1041    // Test OutputFormat default
1042    #[test]
1043    fn test_output_format_default() {
1044        let opts = YomiTokuOptions::default();
1045        assert!(matches!(opts.output_format, OutputFormat::Json));
1046    }
1047
1048    // Test GPU configuration options
1049    #[test]
1050    fn test_gpu_configuration() {
1051        // GPU disabled
1052        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        // GPU enabled with specific device
1057        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 text extraction with empty blocks
1063    #[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 text extraction preserves order
1078    #[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        // Verify order: First appears before Second, Second before Third
1115        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 timeout configuration
1123    #[test]
1124    fn test_timeout_configuration() {
1125        // Very short timeout
1126        let opts_short = YomiTokuOptions::builder().timeout(1).build();
1127        assert_eq!(opts_short.timeout_secs, 1);
1128
1129        // Very long timeout (24 hours)
1130        let opts_long = YomiTokuOptions::builder().timeout(86400).build();
1131        assert_eq!(opts_long.timeout_secs, 86400);
1132    }
1133
1134    // Test all text directions in OcrResult
1135    #[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            // Verify direction is set correctly
1152            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    // Additional comprehensive tests
1161
1162    #[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        // Absolute path
1329        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        // Relative path
1339        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        // Zero time
1352        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        // Very long processing time
1362        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), // 1 hour
1367            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        // Very small font
1375        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        // Very large font
1385        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        // Books preset should detect vertical
1401        assert!(books.detect_vertical);
1402
1403        // Horizontal only should not detect vertical
1404        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        // Mix of success and failure
1422        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    // ============ Error Handling Tests ============
1499
1500    #[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        // Test OcrResult when no text was detected
1556        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    // ==================== Concurrency Tests ====================
1634
1635    #[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    // ==================== Boundary Value Tests ====================
1723
1724    #[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); // width
1758        assert_eq!(block.bbox.3, 0); // height
1759    }
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); // x
1771        assert_eq!(block.bbox.2, 5000); // width
1772    }
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}