1use easypdf_core::PdfDocumentModel;
4use easypdf_core::PdfInput;
5use easypdf_core::Result;
6
7use crate::{
8 DetailedProcessorCapabilities, MarkdownProcessorCapabilities, MarkdownWarning,
9 PdfMarkdownProcessor,
10};
11
12#[derive(Debug)]
27pub struct ProcessorPipeline {
28 entries: Vec<PipelineEntry>,
30 target_level: Option<DetailedProcessorCapabilities>,
32 fail_fast: bool,
34}
35
36struct 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
52pub const PRIORITY_SPECIFIC: f64 = 0.0;
56
57pub const PRIORITY_GENERIC: f64 = 10.0;
59
60impl ProcessorPipeline {
61 #[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 pub fn register(&mut self, processor: Box<dyn PdfMarkdownProcessor>) -> &mut Self {
75 self.register_with_priority(processor, PRIORITY_GENERIC)
76 }
77
78 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 #[must_use]
105 pub fn with_target_level(mut self, target: DetailedProcessorCapabilities) -> Self {
106 self.target_level = Some(target);
107 self
108 }
109
110 #[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 pub fn run(
129 &mut self,
130 input: &PdfInput,
131 document: PdfDocumentModel,
132 ) -> Result<(PdfDocumentModel, Vec<MarkdownWarning>)> {
133 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 let (processed, mut warnings) = entry.processor.process(input, current_doc)?;
148 current_doc = processed;
149 all_warnings.append(&mut warnings);
150 } else {
151 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 #[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 #[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 #[must_use]
199 pub fn len(&self) -> usize {
200 self.entries.len()
201 }
202
203 #[must_use]
205 pub fn is_empty(&self) -> bool {
206 self.entries.is_empty()
207 }
208
209 #[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 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 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 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 let doc = empty_doc();
296 let (result, _) = pipeline.run(&empty_input(), doc).unwrap();
297 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 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 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 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 let blocks: Vec<_> = result.iter_all_blocks().collect();
417 assert_eq!(blocks.len(), 1);
418 assert_eq!(warnings.len(), 1);
419 }
420
421 #[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 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 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}