Skip to main content

superbook_pdf/
progress.rs

1//! Progress tracking module for PDF processing.
2//!
3//! This module provides structured progress tracking and display,
4//! ported from the C# ProgressTracker.cs implementation.
5
6use std::fmt;
7use std::io::{self, Write};
8use std::time::Instant;
9
10/// Processing stages for PDF conversion
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum ProcessingStage {
13    /// Initializing
14    #[default]
15    Initializing,
16    /// Extracting images from PDF
17    Extracting,
18    /// Deskewing images
19    Deskewing,
20    /// Normalizing to internal resolution
21    Normalizing,
22    /// Applying color correction
23    ColorCorrecting,
24    /// Cropping margins
25    Cropping,
26    /// AI upscaling (RealESRGAN)
27    Upscaling,
28    /// Final output processing
29    Finalizing,
30    /// Writing PDF
31    WritingPdf,
32    /// OCR processing (YomiToku)
33    OCR,
34    /// Completed
35    Completed,
36}
37
38impl ProcessingStage {
39    /// Get the English name of the stage
40    pub fn name(&self) -> &'static str {
41        match self {
42            ProcessingStage::Initializing => "Initializing",
43            ProcessingStage::Extracting => "Extracting",
44            ProcessingStage::Deskewing => "Deskewing",
45            ProcessingStage::Normalizing => "Normalizing",
46            ProcessingStage::ColorCorrecting => "ColorCorrecting",
47            ProcessingStage::Cropping => "Cropping",
48            ProcessingStage::Upscaling => "Upscaling",
49            ProcessingStage::Finalizing => "Finalizing",
50            ProcessingStage::WritingPdf => "WritingPdf",
51            ProcessingStage::OCR => "OCR",
52            ProcessingStage::Completed => "Completed",
53        }
54    }
55
56    /// Get the Japanese description of the stage
57    pub fn description_ja(&self) -> &'static str {
58        match self {
59            ProcessingStage::Initializing => "初期化中",
60            ProcessingStage::Extracting => "抽出中",
61            ProcessingStage::Deskewing => "傾き補正中",
62            ProcessingStage::Normalizing => "正規化中",
63            ProcessingStage::ColorCorrecting => "色補正中",
64            ProcessingStage::Cropping => "クロップ中",
65            ProcessingStage::Upscaling => "AI高画質化中",
66            ProcessingStage::Finalizing => "最終処理中",
67            ProcessingStage::WritingPdf => "PDF生成中",
68            ProcessingStage::OCR => "文字認識中",
69            ProcessingStage::Completed => "完了",
70        }
71    }
72}
73
74impl fmt::Display for ProcessingStage {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "{} ({})", self.name(), self.description_ja())
77    }
78}
79
80/// Output verbosity mode
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum OutputMode {
83    /// No output
84    Quiet,
85    /// Normal output (stage display only)
86    #[default]
87    Normal,
88    /// Verbose output (page-level progress)
89    Verbose,
90    /// Very verbose (all items displayed)
91    VeryVerbose,
92}
93
94impl OutputMode {
95    /// Create OutputMode from verbosity level
96    pub fn from_verbosity(level: u8) -> Self {
97        match level {
98            0 => OutputMode::Normal,
99            1 => OutputMode::Verbose,
100            _ => OutputMode::VeryVerbose,
101        }
102    }
103
104    /// Check if output should be shown at this mode
105    pub fn should_show(&self, required: OutputMode) -> bool {
106        use OutputMode::*;
107        match (self, required) {
108            (Quiet, _) => false,
109            (Normal, Quiet | Normal) => true,
110            (Verbose, Quiet | Normal | Verbose) => true,
111            (VeryVerbose, _) => true,
112            _ => false,
113        }
114    }
115}
116
117/// Progress bar width in characters
118const PROGRESS_BAR_WIDTH: usize = 40;
119
120/// Build a progress bar string
121pub fn build_progress_bar(percent: u8) -> String {
122    let percent = percent.min(100);
123    let filled = (percent as usize * PROGRESS_BAR_WIDTH) / 100;
124    let empty = PROGRESS_BAR_WIDTH - filled;
125    format!("[{}{}]", "=".repeat(filled), "-".repeat(empty))
126}
127
128/// Progress tracker for PDF processing
129#[derive(Debug)]
130pub struct ProgressTracker {
131    /// Current file number (1-based)
132    pub current_file: usize,
133    /// Total number of files
134    pub total_files: usize,
135    /// Current filename
136    pub current_filename: String,
137    /// Current processing stage
138    pub current_stage: ProcessingStage,
139    /// Current page number (1-based)
140    pub current_page: usize,
141    /// Total number of pages
142    pub total_pages: usize,
143    /// Current item being processed
144    pub current_item: String,
145    /// Start time
146    start_time: Instant,
147    /// Output mode
148    output_mode: OutputMode,
149}
150
151impl Default for ProgressTracker {
152    fn default() -> Self {
153        Self::new(1, OutputMode::Normal)
154    }
155}
156
157impl ProgressTracker {
158    /// Create a new progress tracker
159    pub fn new(total_files: usize, output_mode: OutputMode) -> Self {
160        Self {
161            current_file: 0,
162            total_files,
163            current_filename: String::new(),
164            current_stage: ProcessingStage::Initializing,
165            current_page: 0,
166            total_pages: 0,
167            current_item: String::new(),
168            start_time: Instant::now(),
169            output_mode,
170        }
171    }
172
173    /// Start processing a new file
174    pub fn start_file(&mut self, file_number: usize, filename: &str) {
175        self.current_file = file_number;
176        self.current_filename = filename.to_string();
177        self.current_stage = ProcessingStage::Initializing;
178        self.current_page = 0;
179        self.total_pages = 0;
180        self.current_item.clear();
181        self.start_time = Instant::now();
182
183        if self.output_mode.should_show(OutputMode::Normal) {
184            self.print_file_header();
185        }
186    }
187
188    /// Set the current processing stage
189    pub fn set_stage(&mut self, stage: ProcessingStage, total_pages: usize) {
190        self.current_stage = stage;
191        if total_pages > 0 {
192            self.total_pages = total_pages;
193        }
194        self.current_page = 0;
195
196        if self.output_mode.should_show(OutputMode::Normal) {
197            self.print_stage();
198        }
199    }
200
201    /// Update page progress
202    pub fn update_page(&mut self, page_number: usize, item_name: &str) {
203        self.current_page = page_number;
204        if !item_name.is_empty() {
205            self.current_item = item_name.to_string();
206        }
207
208        if self.output_mode.should_show(OutputMode::Verbose) {
209            self.print_progress();
210        }
211    }
212
213    /// Mark the current file as complete
214    pub fn complete_file(&mut self) {
215        self.current_stage = ProcessingStage::Completed;
216
217        if self.output_mode.should_show(OutputMode::Normal) {
218            let elapsed = self.start_time.elapsed();
219            println!("  Completed in {:.2}s", elapsed.as_secs_f64());
220            println!();
221        }
222    }
223
224    /// Get elapsed time in seconds
225    pub fn elapsed_secs(&self) -> f64 {
226        self.start_time.elapsed().as_secs_f64()
227    }
228
229    /// Print file header
230    fn print_file_header(&self) {
231        println!();
232        println!("{}", "=".repeat(80));
233        println!(
234            "[File {}/{}] {}",
235            self.current_file, self.total_files, self.current_filename
236        );
237        println!("{}", "=".repeat(80));
238    }
239
240    /// Print current stage
241    fn print_stage(&self) {
242        println!("  Stage: {}", self.current_stage);
243    }
244
245    /// Print progress
246    fn print_progress(&self) {
247        if self.total_pages > 0 && self.current_stage != ProcessingStage::Completed {
248            let percent = ((self.current_page as f64 / self.total_pages as f64) * 100.0) as u8;
249            let bar = build_progress_bar(percent);
250            print!(
251                "\r    {} {:3}% ({}/{})",
252                bar, percent, self.current_page, self.total_pages
253            );
254            if self.output_mode.should_show(OutputMode::VeryVerbose) && !self.current_item.is_empty()
255            {
256                print!(" {}", self.current_item);
257            }
258            let _ = io::stdout().flush();
259        }
260    }
261
262    /// Print final summary
263    pub fn print_summary(
264        total_files: usize,
265        ok_count: usize,
266        skip_count: usize,
267        error_count: usize,
268    ) {
269        println!();
270        println!("{}", "=".repeat(80));
271        println!("Processing Summary");
272        println!("{}", "=".repeat(80));
273        println!("  Total files:  {}", total_files);
274        println!("  Succeeded:    {}", ok_count);
275        println!("  Skipped:      {}", skip_count);
276        println!("  Errors:       {}", error_count);
277        println!("{}", "=".repeat(80));
278        println!();
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    // PROG-001: ProgressTracker新規作成
287    #[test]
288    fn test_progress_tracker_new() {
289        let tracker = ProgressTracker::new(5, OutputMode::Normal);
290        assert_eq!(tracker.total_files, 5);
291        assert_eq!(tracker.current_file, 0);
292        assert_eq!(tracker.current_stage, ProcessingStage::Initializing);
293    }
294
295    // PROG-002: start_file()でファイル開始
296    #[test]
297    fn test_start_file() {
298        let mut tracker = ProgressTracker::new(3, OutputMode::Quiet);
299        tracker.start_file(1, "test.pdf");
300        assert_eq!(tracker.current_file, 1);
301        assert_eq!(tracker.current_filename, "test.pdf");
302    }
303
304    // PROG-003: set_stage()でステージ変更
305    #[test]
306    fn test_set_stage() {
307        let mut tracker = ProgressTracker::new(1, OutputMode::Quiet);
308        tracker.set_stage(ProcessingStage::Extracting, 100);
309        assert_eq!(tracker.current_stage, ProcessingStage::Extracting);
310        assert_eq!(tracker.total_pages, 100);
311    }
312
313    // PROG-004: update_page()で進捗更新
314    #[test]
315    fn test_update_page() {
316        let mut tracker = ProgressTracker::new(1, OutputMode::Quiet);
317        tracker.set_stage(ProcessingStage::Deskewing, 50);
318        tracker.update_page(25, "page_025.png");
319        assert_eq!(tracker.current_page, 25);
320        assert_eq!(tracker.current_item, "page_025.png");
321    }
322
323    // PROG-005: complete_file()で完了マーク
324    #[test]
325    fn test_complete_file() {
326        let mut tracker = ProgressTracker::new(1, OutputMode::Quiet);
327        tracker.start_file(1, "test.pdf");
328        tracker.complete_file();
329        assert_eq!(tracker.current_stage, ProcessingStage::Completed);
330    }
331
332    // PROG-006: ProcessingStage名称取得
333    #[test]
334    fn test_processing_stage_name() {
335        assert_eq!(ProcessingStage::Initializing.name(), "Initializing");
336        assert_eq!(ProcessingStage::Extracting.name(), "Extracting");
337        assert_eq!(ProcessingStage::Deskewing.name(), "Deskewing");
338        assert_eq!(ProcessingStage::Normalizing.name(), "Normalizing");
339        assert_eq!(ProcessingStage::ColorCorrecting.name(), "ColorCorrecting");
340        assert_eq!(ProcessingStage::Cropping.name(), "Cropping");
341        assert_eq!(ProcessingStage::Upscaling.name(), "Upscaling");
342        assert_eq!(ProcessingStage::Finalizing.name(), "Finalizing");
343        assert_eq!(ProcessingStage::WritingPdf.name(), "WritingPdf");
344        assert_eq!(ProcessingStage::OCR.name(), "OCR");
345        assert_eq!(ProcessingStage::Completed.name(), "Completed");
346    }
347
348    // PROG-007: ProcessingStage日本語説明取得
349    #[test]
350    fn test_processing_stage_description_ja() {
351        assert_eq!(ProcessingStage::Initializing.description_ja(), "初期化中");
352        assert_eq!(ProcessingStage::Extracting.description_ja(), "抽出中");
353        assert_eq!(ProcessingStage::Deskewing.description_ja(), "傾き補正中");
354        assert_eq!(ProcessingStage::Completed.description_ja(), "完了");
355    }
356
357    // PROG-008: プログレスバー構築
358    #[test]
359    fn test_build_progress_bar() {
360        let bar_0 = build_progress_bar(0);
361        assert_eq!(bar_0, "[----------------------------------------]");
362
363        let bar_50 = build_progress_bar(50);
364        assert_eq!(bar_50, "[====================--------------------]");
365
366        let bar_100 = build_progress_bar(100);
367        assert_eq!(bar_100, "[========================================]");
368    }
369
370    // PROG-009: プログレスバー境界値
371    #[test]
372    fn test_build_progress_bar_boundary() {
373        // Over 100 should be clamped
374        let bar_150 = build_progress_bar(150);
375        assert_eq!(bar_150, "[========================================]");
376
377        // 25%
378        let bar_25 = build_progress_bar(25);
379        assert_eq!(bar_25, "[==========------------------------------]");
380
381        // 75%
382        let bar_75 = build_progress_bar(75);
383        assert_eq!(bar_75, "[==============================----------]");
384    }
385
386    // PROG-010: OutputMode::Quiet動作確認
387    #[test]
388    fn test_output_mode_quiet() {
389        let mode = OutputMode::Quiet;
390        assert!(!mode.should_show(OutputMode::Quiet));
391        assert!(!mode.should_show(OutputMode::Normal));
392        assert!(!mode.should_show(OutputMode::Verbose));
393    }
394
395    // PROG-011: OutputMode::Verbose動作確認
396    #[test]
397    fn test_output_mode_verbose() {
398        let mode = OutputMode::Verbose;
399        assert!(mode.should_show(OutputMode::Quiet));
400        assert!(mode.should_show(OutputMode::Normal));
401        assert!(mode.should_show(OutputMode::Verbose));
402        assert!(!mode.should_show(OutputMode::VeryVerbose));
403    }
404
405    // PROG-012: 経過時間計算
406    #[test]
407    fn test_elapsed_secs() {
408        let tracker = ProgressTracker::new(1, OutputMode::Quiet);
409        std::thread::sleep(std::time::Duration::from_millis(10));
410        let elapsed = tracker.elapsed_secs();
411        assert!(elapsed >= 0.01);
412    }
413
414    // Additional tests
415
416    #[test]
417    fn test_output_mode_from_verbosity() {
418        assert_eq!(OutputMode::from_verbosity(0), OutputMode::Normal);
419        assert_eq!(OutputMode::from_verbosity(1), OutputMode::Verbose);
420        assert_eq!(OutputMode::from_verbosity(2), OutputMode::VeryVerbose);
421        assert_eq!(OutputMode::from_verbosity(10), OutputMode::VeryVerbose);
422    }
423
424    #[test]
425    fn test_processing_stage_display() {
426        let stage = ProcessingStage::Extracting;
427        let display = format!("{}", stage);
428        assert_eq!(display, "Extracting (抽出中)");
429    }
430
431    #[test]
432    fn test_processing_stage_default() {
433        let stage: ProcessingStage = Default::default();
434        assert_eq!(stage, ProcessingStage::Initializing);
435    }
436
437    #[test]
438    fn test_output_mode_default() {
439        let mode: OutputMode = Default::default();
440        assert_eq!(mode, OutputMode::Normal);
441    }
442
443    #[test]
444    fn test_progress_tracker_default() {
445        let tracker: ProgressTracker = Default::default();
446        assert_eq!(tracker.total_files, 1);
447        assert_eq!(tracker.current_file, 0);
448    }
449
450    #[test]
451    fn test_update_page_empty_item() {
452        let mut tracker = ProgressTracker::new(1, OutputMode::Quiet);
453        tracker.current_item = "previous.png".to_string();
454        tracker.update_page(10, "");
455        assert_eq!(tracker.current_page, 10);
456        assert_eq!(tracker.current_item, "previous.png"); // unchanged
457    }
458
459    #[test]
460    fn test_set_stage_zero_pages() {
461        let mut tracker = ProgressTracker::new(1, OutputMode::Quiet);
462        tracker.total_pages = 100;
463        tracker.set_stage(ProcessingStage::Deskewing, 0);
464        assert_eq!(tracker.total_pages, 100); // unchanged when 0
465    }
466
467    #[test]
468    fn test_output_mode_very_verbose() {
469        let mode = OutputMode::VeryVerbose;
470        assert!(mode.should_show(OutputMode::Quiet));
471        assert!(mode.should_show(OutputMode::Normal));
472        assert!(mode.should_show(OutputMode::Verbose));
473        assert!(mode.should_show(OutputMode::VeryVerbose));
474    }
475
476    #[test]
477    fn test_output_mode_normal() {
478        let mode = OutputMode::Normal;
479        assert!(mode.should_show(OutputMode::Quiet));
480        assert!(mode.should_show(OutputMode::Normal));
481        assert!(!mode.should_show(OutputMode::Verbose));
482        assert!(!mode.should_show(OutputMode::VeryVerbose));
483    }
484}