1use crate::api_error::PdfError;
10
11#[derive(Debug, thiserror::Error)]
13pub enum EngineError {
14 #[error("invalid PDF: {0}")]
16 InvalidPdf(String),
17
18 #[error("page {index} out of range (document has {count} pages)")]
20 PageOutOfRange {
21 index: usize,
23 count: usize,
25 },
26
27 #[error("render error: {0}")]
29 RenderError(String),
30
31 #[error(transparent)]
33 Io(#[from] std::io::Error),
34
35 #[error("PDF is encrypted: {0}")]
37 Encrypted(String),
38
39 #[error("invalid page geometry (w={width:.2}pt, h={height:.2}pt): {reason}")]
41 InvalidPageGeometry {
42 width: f32,
44 height: f32,
46 reason: String,
48 },
49
50 #[error("XFA flatten failed: {0}")]
52 XfaFlattenFailed(String),
53
54 #[error("resource limit exceeded: {0}")]
60 LimitExceeded(crate::limits::LimitError),
61}
62
63impl EngineError {
64 pub fn ocr_message(&self) -> Option<String> {
75 let EngineError::Io(e) = self else {
76 return None;
77 };
78 let cause = e.get_ref()?;
79 #[cfg(feature = "ocr-cli")]
80 if let Some(cli) = cause.downcast_ref::<pdf_ocr::TesseractCliError>() {
81 return Some(cli.to_string());
82 }
83 cause
84 .downcast_ref::<OcrError>()
85 .map(|ocr| ocr.message().to_string())
86 }
87
88 pub(crate) fn ocr_failure(message: impl Into<String>) -> Self {
92 EngineError::Io(std::io::Error::other(OcrError::new(message)))
93 }
94
95 #[cfg(feature = "ocr-cli")]
98 pub(crate) fn ocr_engine_failure(e: pdf_ocr::TesseractCliError) -> Self {
99 EngineError::Io(std::io::Error::other(e))
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
116#[non_exhaustive]
117pub struct OcrError {
118 message: String,
119}
120
121impl OcrError {
122 pub(crate) fn new(message: impl Into<String>) -> Self {
123 Self {
124 message: message.into(),
125 }
126 }
127
128 pub fn message(&self) -> &str {
131 &self.message
132 }
133}
134
135impl std::fmt::Display for OcrError {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 write!(f, "{}", self.message)
138 }
139}
140
141impl std::error::Error for OcrError {}
142
143impl PdfError for EngineError {
144 fn code(&self) -> &str {
145 match self {
146 EngineError::InvalidPdf(_) => "INVALID_PDF",
147 EngineError::PageOutOfRange { .. } => "PAGE_OUT_OF_RANGE",
148 EngineError::RenderError(_) => "RENDER_ERROR",
149 EngineError::Io(_) if self.options().is_some() => "INVALID_OPTIONS",
152 EngineError::Io(_) => "IO_ERROR",
153 EngineError::Encrypted(_) => "ENCRYPTED",
154 EngineError::InvalidPageGeometry { .. } => "INVALID_PAGE_GEOMETRY",
155 EngineError::XfaFlattenFailed(_) => "XFA_FLATTEN_FAILED",
156 EngineError::LimitExceeded(_) => "LIMIT_EXCEEDED",
157 }
158 }
159
160 fn help(&self) -> Option<String> {
161 match self {
162 EngineError::InvalidPdf(_) => {
163 Some("The PDF file is corrupt or invalid. Try using a PDF repair tool or re-save the document.".to_string())
164 }
165 EngineError::PageOutOfRange { index, count } => {
166 Some(format!("Page {} does not exist. This document has {} pages (0-{}).", index, count, count - 1))
167 }
168 EngineError::RenderError(_) => {
169 Some("Rendering failed. Check that the page exists and the document is not corrupted.".to_string())
170 }
171 EngineError::Io(_) => {
172 Some("An I/O error occurred. Check file permissions and disk space.".to_string())
173 }
174 EngineError::Encrypted(_) => {
175 Some("The PDF is encrypted. Provide the correct password to open it.".to_string())
176 }
177 EngineError::InvalidPageGeometry { .. } => {
178 Some("The page has invalid or degenerate dimensions and cannot be rendered.".to_string())
179 }
180 EngineError::XfaFlattenFailed(_) => {
181 Some("XFA flattening produced an invalid PDF. The document may use unsupported XFA features.".to_string())
182 }
183 EngineError::LimitExceeded(_) => {
184 Some("A processing resource limit was exceeded. Raise the relevant cap in ProcessingLimits or check that the input is not adversarial.".to_string())
185 }
186 }
187 }
188}
189
190#[non_exhaustive]
202#[derive(Debug, Clone, thiserror::Error)]
203pub enum OptionsError {
204 #[error("cannot combine pages with first_page/page_count: pass one or the other")]
206 ConflictingPageSelection,
207 #[error("invalid clip margins: {side} must be finite and at least 0, got {value}")]
209 InvalidClipMargins {
210 side: &'static str,
212 value: f64,
214 },
215 #[error("quality `balanced` is markdown-only: it is read by extract_markdown, not by extract_blocks")]
219 BalancedIsMarkdownOnly,
220 #[error("tatr table structure needs the `ort-tables` build; this build has no ONNX Runtime")]
224 TatrNeedsOrtTables,
225}
226
227impl PartialEq for OptionsError {
228 fn eq(&self, other: &Self) -> bool {
229 match (self, other) {
230 (Self::ConflictingPageSelection, Self::ConflictingPageSelection)
231 | (Self::BalancedIsMarkdownOnly, Self::BalancedIsMarkdownOnly)
232 | (Self::TatrNeedsOrtTables, Self::TatrNeedsOrtTables) => true,
233 (
234 Self::InvalidClipMargins { side, value },
235 Self::InvalidClipMargins {
236 side: other_side,
237 value: other_value,
238 },
239 ) => side == other_side && value.to_bits() == other_value.to_bits(),
240 _ => false,
241 }
242 }
243}
244
245impl Eq for OptionsError {}
246
247impl EngineError {
248 pub fn options(&self) -> Option<&OptionsError> {
254 match self {
255 EngineError::Io(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
256 e.get_ref()?.downcast_ref::<OptionsError>()
257 }
258 _ => None,
259 }
260 }
261
262 pub fn model_error(&self) -> Option<&crate::model::ModelError> {
270 match self {
271 EngineError::Io(e) => crate::model::model_error(e),
272 _ => None,
273 }
274 }
275}
276
277pub type Result<T> = std::result::Result<T, EngineError>;
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn encrypted_error_code() {
286 let e = EngineError::Encrypted("bad password".into());
287 assert_eq!(e.code(), "ENCRYPTED");
288 }
289
290 #[test]
291 fn invalid_page_geometry_error_code() {
292 let e = EngineError::InvalidPageGeometry {
293 width: 0.5,
294 height: 0.5,
295 reason: "test".into(),
296 };
297 assert_eq!(e.code(), "INVALID_PAGE_GEOMETRY");
298 }
299
300 #[test]
301 fn xfa_flatten_failed_error_code() {
302 let e = EngineError::XfaFlattenFailed("corrupt output".into());
303 assert_eq!(e.code(), "XFA_FLATTEN_FAILED");
304 }
305
306 #[test]
307 fn options_ignores_a_non_invalid_input_kind() {
308 let e = EngineError::Io(std::io::Error::other(
311 OptionsError::ConflictingPageSelection,
312 ));
313 assert_eq!(e.options(), None);
314 }
315
316 #[test]
317 fn options_ignores_an_invalid_input_without_a_payload() {
318 let e = EngineError::Io(std::io::Error::new(
321 std::io::ErrorKind::InvalidInput,
322 "plain",
323 ));
324 assert_eq!(e.options(), None);
325 }
326
327 #[test]
328 fn an_engine_side_ocr_failure_reads_through_ocr_message() {
329 let e = EngineError::ocr_failure("ocr_dpi must be positive");
330 assert!(matches!(e, EngineError::Io(_)));
331 assert_eq!(e.ocr_message().as_deref(), Some("ocr_dpi must be positive"));
332 let EngineError::Io(io) = &e else {
334 panic!("an OCR failure rides as Io");
335 };
336 let cause = io.get_ref().expect("io::Error::other carries its cause");
337 assert_eq!(
338 cause.downcast_ref::<OcrError>().map(OcrError::message),
339 Some("ocr_dpi must be positive")
340 );
341 }
342
343 #[cfg(feature = "ocr-cli")]
344 #[test]
345 fn an_engine_construction_failure_reads_typed_through_ocr_message() {
346 let e = EngineError::ocr_engine_failure(pdf_ocr::TesseractCliError::Other(
347 "tesseract language data for 'xx' is not installed".to_string(),
348 ));
349 assert!(matches!(e, EngineError::Io(_)));
350 assert_eq!(
351 e.ocr_message().as_deref(),
352 Some("tesseract language data for 'xx' is not installed")
353 );
354 let EngineError::Io(io) = &e else {
355 panic!("an OCR failure rides as Io");
356 };
357 assert!(
358 io.get_ref()
359 .is_some_and(|cause| cause.is::<pdf_ocr::TesseractCliError>()),
360 "callers downcast through io::Error::get_ref()"
361 );
362 }
363
364 #[test]
365 fn an_os_error_and_other_variants_are_not_ocr_failures() {
366 let os = EngineError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
367 assert_eq!(os.ocr_message(), None);
368 let render = EngineError::RenderError("x".to_string());
369 assert_eq!(render.ocr_message(), None);
370 }
371 #[test]
372 fn clip_margins_errors_compare_by_side_and_bits() {
373 let nan = || OptionsError::InvalidClipMargins {
376 side: "left",
377 value: f64::NAN,
378 };
379 assert_eq!(nan(), nan());
380 assert_eq!(
381 OptionsError::InvalidClipMargins {
382 side: "top",
383 value: -1.0
384 },
385 OptionsError::InvalidClipMargins {
386 side: "top",
387 value: -1.0
388 },
389 );
390 assert_ne!(
391 OptionsError::InvalidClipMargins {
392 side: "left",
393 value: -1.0
394 },
395 OptionsError::InvalidClipMargins {
396 side: "right",
397 value: -1.0
398 },
399 );
400 assert_ne!(
401 OptionsError::InvalidClipMargins {
402 side: "left",
403 value: -1.0
404 },
405 OptionsError::InvalidClipMargins {
406 side: "left",
407 value: -2.0
408 },
409 );
410 assert_ne!(
411 OptionsError::InvalidClipMargins {
412 side: "left",
413 value: -1.0
414 },
415 OptionsError::ConflictingPageSelection,
416 );
417 }
418
419 #[cfg(feature = "ort")]
420 #[test]
421 fn a_model_error_round_trips_through_io_to_engine_error_and_back() {
422 let spec = pdf_ocr::runtime::ModelSpec::new(
426 "roundtrip",
427 "roundtrip.onnx",
428 "0000000000000000000000000000000000000000000000000000000000000000",
429 "install roundtrip",
430 );
431 let config = pdf_ocr::runtime::RuntimeConfig::new()
432 .with_model_dir("ort-roundtrip-test-no-such-dir")
433 .with_ort_dylib("ort-roundtrip-test-no-such-lib");
434 let model =
435 pdf_ocr::runtime::session(&spec, &config).expect_err("a missing model dir must fail");
436 assert_eq!(
437 model.kind,
438 pdf_ocr::runtime::ModelErrorKind::ModelNotAvailable
439 );
440 let engine = EngineError::from(std::io::Error::from(model));
441 let EngineError::Io(io) = &engine else {
445 panic!("a model failure rides as Io");
446 };
447 let back = pdf_ocr::runtime::model_error(io).expect("the chain carries the ModelError");
448 assert_eq!(
449 back.kind,
450 pdf_ocr::runtime::ModelErrorKind::ModelNotAvailable
451 );
452 assert_eq!(back.kind.code(), "MODEL_NOT_AVAILABLE");
453 }
454}