1use std::collections::BTreeMap;
3use std::io::{self, IsTerminal, Write};
4use std::time::{Duration, Instant};
5pub fn stage_name(stage: &str) -> &str {
6 match stage {
7 "输入分类" => "classify",
8 "原生全文分析" => "native_analysis",
9 "原生分析失败,回退版面模型" => "layout_fallback",
10 "读取文字与渲染" => "render",
11 "版面模型识别" => "layout",
12 "公式识别" => "formula",
13 "正文 OCR 检测与原生文字规划" => "ocr_detection",
14 "正文 OCR 批量识别" => "ocr_recognition",
15 "表格识别" => "table",
16 "印章识别" => "seal",
17 "页面组装与图片资产" => "assemble",
18 "补识别、段落和跨页表格" => "finalize",
19 "写出结果" => "write",
20 _ => stage,
21 }
22}
23fn label(stage: &str) -> &str {
24 match stage {
25 "layout" => "Layout Predict",
26 "formula" => "Formula Predict",
27 "ocr_detection" => "PDF/OCR-det Predict",
28 "ocr_recognition" => "OCR-rec Predict",
29 "table" => "Table Predict",
30 "assemble" => "Processing pages",
31 "orientation" => "Orientation Predict",
32 "seal" => "Seal Predict",
33 "finalize" => "Post-processing",
34 _ => stage,
35 }
36}
37fn clock(seconds: f64) -> String {
38 let s = seconds.max(0.) as u64;
39 if s >= 3600 {
40 format!("{}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60)
41 } else {
42 format!("{:02}:{:02}", s / 60, s % 60)
43 }
44}
45pub fn format_stage(stage: &str, done: usize, total: usize, elapsed: Duration) -> String {
46 let done = done.min(total);
47 let fill = if total == 0 {
48 20
49 } else {
50 done.saturating_mul(20) / total
51 };
52 let percent = if total == 0 {
53 100
54 } else {
55 done.saturating_mul(100) / total
56 };
57 let seconds = elapsed.as_secs_f64();
58 let (eta, rate) = if done > 0 && seconds > 0. {
59 let rate = done as f64 / seconds;
60 (
61 clock((total - done) as f64 / rate),
62 if rate >= 1. {
63 format!("{rate:.2}it/s")
64 } else {
65 format!("{:.2}s/it", 1. / rate)
66 },
67 )
68 } else if total == 0 {
69 ("00:00".into(), "0.00it/s".into())
70 } else {
71 ("?".into(), "?it/s".into())
72 };
73 format!(
74 "{}: {:3}%|{}{}| {}/{} [{}<{}, {}]",
75 label(stage),
76 percent,
77 "█".repeat(fill),
78 " ".repeat(20 - fill),
79 done,
80 total,
81 clock(seconds),
82 eta,
83 rate
84 )
85}
86struct Stage {
87 measured: Option<Duration>,
88 done: usize,
89 total: usize,
90 started: Instant,
91 finished: bool,
92}
93pub struct TerminalProgress {
94 enabled: bool,
95 dynamic: bool,
96 stages: BTreeMap<String, Stage>,
97 last_draw: Option<Instant>,
98 width: usize,
99 active: Option<String>,
100}
101impl TerminalProgress {
102 pub fn new(_name: String, _total: usize) -> Self {
103 let mode = std::env::var("RAPIDDOC_PROGRESS")
104 .unwrap_or_default()
105 .to_ascii_lowercase();
106 let dynamic = match mode.as_str() {
107 "always" | "1" => true,
108 "plain" => false,
109 _ => io::stderr().is_terminal() || std::env::var_os("PYCHARM_HOSTED").is_some(),
110 };
111 Self {
112 enabled: !matches!(mode.as_str(), "off" | "0"),
113 dynamic,
114 stages: BTreeMap::new(),
115 last_draw: None,
116 width: 0,
117 active: None,
118 }
119 }
120 pub fn update(&mut self, stage: &str, page: Option<usize>) {
121 self.update_detail(stage, page, None)
122 }
123 pub fn update_detail(&mut self, stage: &str, _page: Option<usize>, _detail: Option<&str>) {
124 if matches!(stage_name(stage), "finalize" | "write") {
125 self.clear();
126 if self.enabled {
127 let _ = writeln!(io::stderr().lock(), "{}...", label(stage));
128 }
129 }
130 }
131 pub fn reset(&mut self) {
132 self.clear();
133 self.stages.clear();
134 self.active = None;
135 }
136 pub fn advance(&mut self, stage: &str, done: usize, total: usize) {
137 self.advance_with_elapsed(stage, done, total, None)
138 }
139 pub fn advance_with_elapsed(
141 &mut self,
142 stage: &str,
143 done: usize,
144 total: usize,
145 elapsed: Option<Duration>,
146 ) {
147 if !self.enabled || total == 0 {
148 return;
149 }
150 let stage = stage_name(stage);
151 let fresh = !self.stages.contains_key(stage);
152 let state = self.stages.entry(stage.into()).or_insert_with(|| Stage {
153 measured: None,
154 done: 0,
155 total,
156 started: Instant::now(),
157 finished: false,
158 });
159 if state.finished {
160 return;
161 }
162 state.done = state.done.max(done.min(total));
163 state.total = total;
164 if let Some(elapsed) = elapsed {
165 state.measured = Some(state.measured.unwrap_or_default().max(elapsed));
166 }
167 let finished = state.done == total;
168 let due = self.last_draw.is_none_or(|t| {
169 t.elapsed()
170 >= if self.dynamic {
171 Duration::from_millis(100)
172 } else {
173 Duration::from_secs(5)
174 }
175 });
176 let switched = self.active.as_deref() != Some(stage);
177 if !finished && !fresh && !due && !(self.dynamic && switched) {
178 return;
179 }
180 let text = format_stage(
181 stage,
182 state.done,
183 total,
184 state.measured.unwrap_or_else(|| state.started.elapsed()),
185 );
186 state.finished = finished;
187 let mut stderr = io::stderr().lock();
188 if self.dynamic {
189 let _ = write!(
190 stderr,
191 "\r{}{}",
192 text,
193 " ".repeat(self.width.saturating_sub(text.chars().count()))
194 );
195 if finished {
196 let _ = writeln!(stderr);
197 self.width = 0;
198 } else {
199 self.width = text.chars().count();
200 }
201 } else {
202 let _ = writeln!(stderr, "{text}");
203 }
204 let _ = stderr.flush();
205 self.last_draw = Some(Instant::now());
206 self.active = Some(stage.into());
207 }
208 pub fn clear(&mut self) {
209 if self.dynamic && self.width > 0 {
210 let mut stderr = io::stderr().lock();
211 let _ = write!(stderr, "\r{}\r", " ".repeat(self.width));
212 let _ = stderr.flush();
213 self.width = 0;
214 }
215 self.last_draw = None;
216 }
217}
218impl Drop for TerminalProgress {
219 fn drop(&mut self) {
220 self.clear();
221 }
222}
223#[cfg(test)]
224mod tests {
225 use super::*;
226 #[test]
227 fn stage_counts_eta_rate_and_zero_work() {
228 let line = format_stage("formula", 2, 5, Duration::from_secs(4));
229 assert!(line.starts_with("Formula Predict: 40%|"));
230 assert!(line.ends_with("2/5 [00:04<00:06, 2.00s/it]"));
231 assert!(format_stage("layout", 14, 14, Duration::from_secs(7))
232 .ends_with("14/14 [00:07<00:00, 2.00it/s]"));
233 assert!(format_stage("table", 0, 0, Duration::ZERO).contains("100%"));
234 assert!(format_stage("formula", 0, 5, Duration::ZERO).contains("00:00<?, ?it/s"));
235 }
236}