Skip to main content

easypdf_markdown/
processor_pipeline.rs

1//! 语义处理器管道调度器。
2
3use easypdf_core::PdfDocumentModel;
4use easypdf_core::PdfInput;
5use easypdf_core::Result;
6
7use crate::{
8    DetailedProcessorCapabilities, MarkdownProcessorCapabilities, MarkdownWarning,
9    PdfMarkdownProcessor,
10};
11
12/// 处理器管道:按优先级确定性组合多个 [`PdfMarkdownProcessor`]。
13///
14/// 多个处理器按 `priority` 升序排列后依次执行;前一个处理器的输出
15/// 作为后一个的输入。相同优先级的处理器保持注册顺序(稳定排序)。
16///
17/// # Examples
18///
19/// ```
20/// use easypdf_markdown::ProcessorPipeline;
21///
22/// let pipeline = ProcessorPipeline::new();
23/// assert!(pipeline.is_empty());
24/// assert_eq!(pipeline.len(), 0);
25/// ```
26#[derive(Debug)]
27pub struct ProcessorPipeline {
28    /// `(priority, processor)` 对,`run()` 前按 priority 稳定排序。
29    entries: Vec<PipelineEntry>,
30    /// 目标能力等级;处理器能力低于目标时可被跳过。
31    target_level: Option<DetailedProcessorCapabilities>,
32    /// 单个处理器失败时是否立即返回错误(默认 `false`,收集到 warnings)。
33    fail_fast: bool,
34}
35
36/// 管道中的单个处理器条目。
37struct PipelineEntry {
38    priority: f64,
39    processor: Box<dyn PdfMarkdownProcessor>,
40    registration_order: usize,
41}
42
43impl std::fmt::Debug for PipelineEntry {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("PipelineEntry")
46            .field("priority", &self.priority)
47            .field("registration_order", &self.registration_order)
48            .finish_non_exhaustive()
49    }
50}
51
52/// 默认优先级常量:对齐 markitdown 的 `PRIORITY_SPECIFIC_FILE_FORMAT`。
53///
54/// 用于需要最先执行的特定格式处理器。
55pub const PRIORITY_SPECIFIC: f64 = 0.0;
56
57/// 默认优先级常量:对齐 markitdown 的 `PRIORITY_GENERIC_FILE_FORMAT`。
58pub const PRIORITY_GENERIC: f64 = 10.0;
59
60impl ProcessorPipeline {
61    /// 创建空管道。
62    #[must_use]
63    pub fn new() -> Self {
64        Self {
65            entries: Vec::new(),
66            target_level: None,
67            fail_fast: false,
68        }
69    }
70
71    /// 注册处理器,使用默认优先级 [`PRIORITY_GENERIC`](`10.0`)。
72    ///
73    /// 返回 `&mut Self` 以支持链式调用。
74    pub fn register(&mut self, processor: Box<dyn PdfMarkdownProcessor>) -> &mut Self {
75        self.register_with_priority(processor, PRIORITY_GENERIC)
76    }
77
78    /// 注册处理器并指定优先级(值越小越先执行)。
79    ///
80    /// 对齐 markitdown 的 priority 机制:
81    /// - [`PRIORITY_SPECIFIC`](`0.0`):特定格式处理器,优先执行
82    /// - [`PRIORITY_GENERIC`](`10.0`):通用处理器,后执行
83    ///
84    /// 返回 `&mut Self` 以支持链式调用。
85    pub fn register_with_priority(
86        &mut self,
87        processor: Box<dyn PdfMarkdownProcessor>,
88        priority: f64,
89    ) -> &mut Self {
90        let order = self.entries.len();
91        self.entries.push(PipelineEntry {
92            priority,
93            processor,
94            registration_order: order,
95        });
96        self
97    }
98
99    /// 设置目标能力等级,用于降级策略。
100    ///
101    /// 处理器的能力低于目标等级时仍会执行(不会被跳过),
102    /// 但 [`aggregate_capabilities`](Self::aggregate_capabilities)
103    /// 返回的结果可用于判断是否满足目标。
104    #[must_use]
105    pub fn with_target_level(mut self, target: DetailedProcessorCapabilities) -> Self {
106        self.target_level = Some(target);
107        self
108    }
109
110    /// 设置是否在单个处理器失败时立即返回错误。
111    ///
112    /// 默认为 `false`:处理器错误会被收集到 warnings 中,管道继续执行。
113    /// 设为 `true` 时,第一个处理器错误即终止管道。
114    #[must_use]
115    pub const fn fail_fast(mut self, fail_fast: bool) -> Self {
116        self.fail_fast = fail_fast;
117        self
118    }
119
120    /// 执行管道:按优先级升序依次调用每个处理器的 `process()`。
121    ///
122    /// 返回最终的文档模型与所有处理器产生的警告。
123    ///
124    /// # Errors
125    ///
126    /// 当 `fail_fast` 为 `true` 且任一处理器返回错误时,立即传播该错误。
127    /// 当 `fail_fast` 为 `false` 时,处理器错误被转为警告,管道继续执行。
128    pub fn run(
129        &mut self,
130        input: &PdfInput,
131        document: PdfDocumentModel,
132    ) -> Result<(PdfDocumentModel, Vec<MarkdownWarning>)> {
133        // 按 priority 稳定排序(升序),同 priority 保持注册顺序。
134        self.entries.sort_by(|a, b| {
135            a.priority
136                .partial_cmp(&b.priority)
137                .unwrap_or(std::cmp::Ordering::Equal)
138                .then_with(|| a.registration_order.cmp(&b.registration_order))
139        });
140
141        let mut current_doc = document;
142        let mut all_warnings = Vec::new();
143
144        for entry in &self.entries {
145            if self.fail_fast {
146                // fail_fast 模式:直接传播错误。
147                let (processed, mut warnings) = entry.processor.process(input, current_doc)?;
148                current_doc = processed;
149                all_warnings.append(&mut warnings);
150            } else {
151                // 宽容模式:处理器失败时保留当前文档,收集警告。
152                // 需要 clone 以在失败时保留文档。
153                let doc_snapshot = current_doc.clone();
154                match entry.processor.process(input, current_doc) {
155                    Ok((processed, mut warnings)) => {
156                        current_doc = processed;
157                        all_warnings.append(&mut warnings);
158                    }
159                    Err(err) => {
160                        current_doc = doc_snapshot;
161                        all_warnings.push(MarkdownWarning::ProcessorFailed {
162                            message: err.to_string(),
163                        });
164                    }
165                }
166            }
167        }
168
169        Ok((current_doc, all_warnings))
170    }
171
172    /// 聚合所有处理器的能力(每项取最高 level)。
173    ///
174    /// 遍历已注册的处理器,调用其 `capabilities()` 并取并集。
175    /// 结果以 [`DetailedProcessorCapabilities`] 返回。
176    #[must_use]
177    pub fn aggregate_capabilities(&self) -> DetailedProcessorCapabilities {
178        let mut merged = DetailedProcessorCapabilities::new();
179        for entry in &self.entries {
180            let caps = entry.processor.capabilities();
181            let detailed = DetailedProcessorCapabilities::from(caps);
182            merged = merged.merge(&detailed);
183        }
184        merged
185    }
186
187    /// 聚合所有处理器的能力(布尔值版本,保持向后兼容)。
188    #[must_use]
189    pub fn aggregate_bool_capabilities(&self) -> MarkdownProcessorCapabilities {
190        let mut merged = MarkdownProcessorCapabilities::new();
191        for entry in &self.entries {
192            merged = merged.union(entry.processor.capabilities());
193        }
194        merged
195    }
196
197    /// 返回已注册的处理器数量。
198    #[must_use]
199    pub fn len(&self) -> usize {
200        self.entries.len()
201    }
202
203    /// 判断管道是否为空。
204    #[must_use]
205    pub fn is_empty(&self) -> bool {
206        self.entries.is_empty()
207    }
208
209    /// 返回目标能力等级(如果已设置)。
210    #[must_use]
211    pub const fn target_level(&self) -> Option<&DetailedProcessorCapabilities> {
212        self.target_level.as_ref()
213    }
214}
215
216impl Default for ProcessorPipeline {
217    fn default() -> Self {
218        Self::new()
219    }
220}
221
222#[cfg(test)]
223#[allow(clippy::uninlined_format_args, clippy::float_cmp)]
224mod tests {
225    use super::*;
226    use easypdf_core::{PageIndex, PdfMetadata};
227    use easypdf_core::{PdfBlock, PdfPageModel, SourceLocation};
228
229    /// 简单测试处理器:在文档中追加一个段落。
230    struct AppendProcessor {
231        text: String,
232    }
233
234    impl PdfMarkdownProcessor for AppendProcessor {
235        fn process(
236            &self,
237            _input: &PdfInput,
238            document: PdfDocumentModel,
239        ) -> Result<(PdfDocumentModel, Vec<MarkdownWarning>)> {
240            let loc = SourceLocation::new(PageIndex::new(0), 1.0);
241            let page = PdfPageModel::new(PageIndex::new(0))
242                .with_block(PdfBlock::paragraph(&self.text, loc));
243            Ok((
244                PdfDocumentModel::new(document.metadata().clone(), vec![page]),
245                Vec::new(),
246            ))
247        }
248    }
249
250    /// 总是返回错误的处理器。
251    struct FailProcessor;
252
253    impl PdfMarkdownProcessor for FailProcessor {
254        fn process(
255            &self,
256            _input: &PdfInput,
257            _document: PdfDocumentModel,
258        ) -> Result<(PdfDocumentModel, Vec<MarkdownWarning>)> {
259            Err(easypdf_core::PdfError::Other("test failure".into()))
260        }
261    }
262
263    fn empty_doc() -> PdfDocumentModel {
264        PdfDocumentModel::new(PdfMetadata::default(), Vec::new())
265    }
266
267    fn empty_input() -> PdfInput {
268        PdfInput::from_bytes(Vec::new())
269    }
270
271    #[test]
272    fn empty_pipeline_returns_unchanged() {
273        let mut pipeline = ProcessorPipeline::new();
274        let doc = empty_doc();
275        let (result, warnings) = pipeline.run(&empty_input(), doc).unwrap();
276        assert!(result.is_empty());
277        assert!(warnings.is_empty());
278    }
279
280    #[test]
281    fn processors_execute_in_priority_order() {
282        let mut pipeline = ProcessorPipeline::new();
283        // 注册顺序:generic (10.0) 先, specific (0.0) 后
284        pipeline.register(Box::new(AppendProcessor {
285            text: "generic".into(),
286        }));
287        pipeline.register_with_priority(
288            Box::new(AppendProcessor {
289                text: "specific".into(),
290            }),
291            0.0,
292        );
293        // 排序后 specific (0.0) 先执行,generic (10.0) 后执行
294        // 后执行的覆盖前一个的输出(因为 AppendProcessor 替换整个文档)
295        let doc = empty_doc();
296        let (result, _) = pipeline.run(&empty_input(), doc).unwrap();
297        // generic 后执行,所以它的文本出现在最终结果中
298        let blocks: Vec<_> = result.iter_all_blocks().collect();
299        assert_eq!(blocks.len(), 1);
300        if let PdfBlock::Paragraph { text, .. } = blocks[0].1 {
301            assert_eq!(text, "generic");
302        } else {
303            panic!("expected Paragraph");
304        }
305    }
306
307    #[test]
308    fn fail_fast_returns_error() {
309        let mut pipeline = ProcessorPipeline::new().fail_fast(true);
310        pipeline.register(Box::new(FailProcessor));
311        let doc = empty_doc();
312        let result = pipeline.run(&empty_input(), doc);
313        assert!(result.is_err());
314    }
315
316    #[test]
317    fn fail_collects_warning() {
318        let mut pipeline = ProcessorPipeline::new();
319        pipeline.register(Box::new(FailProcessor));
320        let doc = empty_doc();
321        let (_, warnings) = pipeline.run(&empty_input(), doc).unwrap();
322        assert_eq!(warnings.len(), 1);
323        assert!(matches!(
324            warnings[0],
325            MarkdownWarning::ProcessorFailed { .. }
326        ));
327    }
328
329    #[test]
330    fn len_and_is_empty() {
331        let mut pipeline = ProcessorPipeline::new();
332        assert!(pipeline.is_empty());
333        assert_eq!(pipeline.len(), 0);
334        pipeline.register(Box::new(AppendProcessor { text: "x".into() }));
335        assert!(!pipeline.is_empty());
336        assert_eq!(pipeline.len(), 1);
337    }
338
339    #[test]
340    fn aggregate_capabilities_merges() {
341        let mut pipeline = ProcessorPipeline::new();
342        pipeline.register(Box::new(AppendProcessor { text: "x".into() }));
343        let caps = pipeline.aggregate_capabilities();
344        // AppendProcessor 的 capabilities() 返回默认(全 None)
345        assert!(!caps.supports(crate::ProcessorCapability::TableDetection));
346    }
347
348    #[test]
349    fn default_pipeline_fail_fast_false() {
350        let pipeline = ProcessorPipeline::new();
351        assert!(!pipeline.fail_fast);
352    }
353
354    #[test]
355    fn fail_fast_setter() {
356        let pipeline = ProcessorPipeline::new().fail_fast(true);
357        assert!(pipeline.fail_fast);
358    }
359
360    #[test]
361    fn new_is_empty() {
362        let pipeline = ProcessorPipeline::new();
363        assert!(pipeline.is_empty());
364        assert_eq!(pipeline.len(), 0);
365    }
366
367    #[test]
368    fn register_increases_len() {
369        let mut pipeline = ProcessorPipeline::new();
370        pipeline.register(Box::new(AppendProcessor { text: "a".into() }));
371        pipeline.register(Box::new(AppendProcessor { text: "b".into() }));
372        assert_eq!(pipeline.len(), 2);
373        assert!(!pipeline.is_empty());
374    }
375
376    #[test]
377    fn multiple_processors_execute() {
378        let mut pipeline = ProcessorPipeline::new();
379        // Both at same priority (10.0), execution order is stable
380        pipeline.register(Box::new(AppendProcessor {
381            text: "first".into(),
382        }));
383        pipeline.register(Box::new(AppendProcessor {
384            text: "second".into(),
385        }));
386        let doc = empty_doc();
387        let (result, warnings) = pipeline.run(&empty_input(), doc).unwrap();
388        // Last processor wins since AppendProcessor replaces doc
389        let blocks: Vec<_> = result.iter_all_blocks().collect();
390        assert_eq!(blocks.len(), 1);
391        assert!(warnings.is_empty());
392    }
393
394    #[test]
395    fn fail_fast_stops_on_first_error() {
396        let mut pipeline = ProcessorPipeline::new().fail_fast(true);
397        pipeline.register(Box::new(FailProcessor));
398        pipeline.register(Box::new(AppendProcessor {
399            text: "never".into(),
400        }));
401        let doc = empty_doc();
402        let result = pipeline.run(&empty_input(), doc);
403        assert!(result.is_err());
404    }
405
406    #[test]
407    fn no_fail_fast_continues_after_error() {
408        let mut pipeline = ProcessorPipeline::new().fail_fast(false);
409        pipeline.register(Box::new(FailProcessor));
410        pipeline.register(Box::new(AppendProcessor {
411            text: "continued".into(),
412        }));
413        let doc = empty_doc();
414        let (result, warnings) = pipeline.run(&empty_input(), doc).unwrap();
415        // Second processor still runs
416        let blocks: Vec<_> = result.iter_all_blocks().collect();
417        assert_eq!(blocks.len(), 1);
418        assert_eq!(warnings.len(), 1);
419    }
420
421    // --- Additional coverage tests ---
422
423    #[test]
424    fn with_target_level_stores_value() {
425        let caps = DetailedProcessorCapabilities::new();
426        let pipeline = ProcessorPipeline::new().with_target_level(caps);
427        assert!(pipeline.target_level().is_some());
428    }
429
430    #[test]
431    fn target_level_none_by_default() {
432        let pipeline = ProcessorPipeline::new();
433        assert!(pipeline.target_level().is_none());
434    }
435
436    #[test]
437    fn aggregate_bool_capabilities_merges() {
438        let mut pipeline = ProcessorPipeline::new();
439        pipeline.register(Box::new(AppendProcessor { text: "x".into() }));
440        let caps = pipeline.aggregate_bool_capabilities();
441        // AppendProcessor returns default capabilities
442        assert!(!caps.ocr());
443    }
444
445    #[test]
446    fn default_creates_empty_pipeline() {
447        let pipeline = ProcessorPipeline::default();
448        assert!(pipeline.is_empty());
449    }
450
451    #[test]
452    fn same_priority_preserves_registration_order() {
453        let mut pipeline = ProcessorPipeline::new();
454        pipeline.register(Box::new(AppendProcessor {
455            text: "first".into(),
456        }));
457        pipeline.register(Box::new(AppendProcessor {
458            text: "second".into(),
459        }));
460        // Both at PRIORITY_GENERIC (10.0), second should run last
461        let doc = empty_doc();
462        let (result, _) = pipeline.run(&empty_input(), doc).unwrap();
463        let blocks: Vec<_> = result.iter_all_blocks().collect();
464        assert_eq!(blocks.len(), 1);
465        if let PdfBlock::Paragraph { text, .. } = blocks[0].1 {
466            assert_eq!(text, "second");
467        }
468    }
469}