Skip to main content

pdf_engine/
error.rs

1//! Error types for the rendering engine.
2
3// Copyright (c) 2026 Innovation Trigger B.V.
4//
5// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
6// the PDFluent Commercial Licence. See the LICENSE file in this repository --
7// that file travels with the copy you received, which a URL does not.
8
9use crate::api_error::PdfError;
10
11/// Errors that can occur in the rendering engine.
12#[derive(Debug, thiserror::Error)]
13pub enum EngineError {
14    /// The PDF data is invalid or could not be parsed.
15    #[error("invalid PDF: {0}")]
16    InvalidPdf(String),
17
18    /// A page index is out of range.
19    #[error("page {index} out of range (document has {count} pages)")]
20    PageOutOfRange {
21        /// The requested page index.
22        index: usize,
23        /// Total number of pages.
24        count: usize,
25    },
26
27    /// A rendering error occurred.
28    #[error("render error: {0}")]
29    RenderError(String),
30
31    /// An I/O error occurred.
32    #[error(transparent)]
33    Io(#[from] std::io::Error),
34
35    /// The PDF is encrypted and requires a password to open.
36    #[error("PDF is encrypted: {0}")]
37    Encrypted(String),
38
39    /// The page has invalid or degenerate dimensions.
40    #[error("invalid page geometry (w={width:.2}pt, h={height:.2}pt): {reason}")]
41    InvalidPageGeometry {
42        /// Page width in points.
43        width: f32,
44        /// Page height in points.
45        height: f32,
46        /// Human-readable reason for the failure.
47        reason: String,
48    },
49
50    /// XFA flattening failed.
51    #[error("XFA flatten failed: {0}")]
52    XfaFlattenFailed(String),
53
54    /// A configured resource limit was exceeded during rendering or
55    /// text extraction.  Carries the [`LimitError`] so callers can
56    /// branch on the specific cap that fired.
57    ///
58    /// [`LimitError`]: crate::limits::LimitError
59    #[error("resource limit exceeded: {0}")]
60    LimitExceeded(crate::limits::LimitError),
61}
62
63impl EngineError {
64    /// If this error is an OCR failure carried as [`EngineError::Io`], the
65    /// failure's message.
66    ///
67    /// OCR failures ride as I/O errors because this enum is exhaustive in
68    /// 1.0.0 and a new variant would break semver: engine-construction
69    /// failures carry the typed `pdf_ocr::TesseractCliError`, everything else
70    /// an [`OcrError`], both via [`std::io::Error::other`]. Callers that need
71    /// the typed cause downcast [`std::io::Error::get_ref`]; callers that only
72    /// need the message read it here. An OS error, and any other variant,
73    /// reads as `None`.
74    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    /// An engine-side OCR failure: a malformed request, a build without an
89    /// engine, or an internal step that failed. See [`EngineError::ocr_message`]
90    /// for why it rides as [`EngineError::Io`].
91    pub(crate) fn ocr_failure(message: impl Into<String>) -> Self {
92        EngineError::Io(std::io::Error::other(OcrError::new(message)))
93    }
94
95    /// An OCR engine that could not be constructed, typed so callers can
96    /// downcast to it. See [`EngineError::ocr_message`].
97    #[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/// An OCR failure on the engine side of the recognition: a malformed request
104/// (`searchable_copy` with `Never`, a zero dpi), a build without an engine,
105/// or an internal step that failed (reopening the copy, the pipeline, ...).
106///
107/// Carried inside [`EngineError::Io`], where engine-construction failures
108/// carry the typed `pdf_ocr::TesseractCliError` instead; see
109/// [`EngineError::ocr_message`].
110///
111/// This is not [`crate::ocr::OcrError`]: that one is what an [`OcrBackend`]
112/// reports, this one is what the extraction path reports around it.
113///
114/// [`OcrBackend`]: crate::ocr::OcrBackend
115#[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    /// The failure, as it reads after the `OCR error: ` prefix the mappings
129    /// put in front of it.
130    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            // An OptionsError rides inside Io, but it is a mistake in the
150            // call, not an OS failure, so it reports its own code (#574).
151            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/// Option and selection errors: mistakes in the call, not faults in the
191/// document or the engine.
192///
193/// `#[non_exhaustive]`, and that is the semver contract: new 1.x error cases
194/// arrive as new variants of THIS enum, never as new variants of
195/// [`EngineError`]. `EngineError` is an exhaustive enum published at 1.0.0,
196/// so adding a variant to it breaks every downstream exhaustive `match` — a
197/// major-version change. These cases travel inside [`EngineError::Io`] with
198/// [`std::io::ErrorKind::InvalidInput`] instead, readable through
199/// [`EngineError::options`], and matching on this enum always keeps a
200/// fallback arm.
201#[non_exhaustive]
202#[derive(Debug, Clone, thiserror::Error)]
203pub enum OptionsError {
204    /// `pages` and the `first_page`/`page_count` window were combined.
205    #[error("cannot combine pages with first_page/page_count: pass one or the other")]
206    ConflictingPageSelection,
207    /// A clip margin was negative or not finite.
208    #[error("invalid clip margins: {side} must be finite and at least 0, got {value}")]
209    InvalidClipMargins {
210        /// Which side: `"left"`, `"top"`, `"right"` or `"bottom"`.
211        side: &'static str,
212        /// What was asked for: negative, or not finite.
213        value: f64,
214    },
215    /// `quality` was [`Quality::Balanced`](crate::Quality) on a call that
216    /// returns blocks: the balanced tier assembles markdown, so it is read by
217    /// `extract_markdown` only (#641).
218    #[error("quality `balanced` is markdown-only: it is read by extract_markdown, not by extract_blocks")]
219    BalancedIsMarkdownOnly,
220    /// TATR table structure was asked for on a build without it. The model
221    /// runs only behind the `ort-tables` feature; without it the request
222    /// is refused, never run as fast.
223    #[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    /// The [`OptionsError`] carried by this error, if any.
249    ///
250    /// An additive inherent method, so adding it is semver-safe. Callers
251    /// that used to match `EngineError::ConflictingPageSelection` check this
252    /// first, before whatever they do with a plain [`EngineError::Io`].
253    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    /// The [`ModelError`](crate::model::ModelError) carried by this error, if
263    /// any (#641).
264    ///
265    /// Model failures ride as [`EngineError::Io`] because this enum is
266    /// exhaustive in 1.0.0 and a new variant would break semver; this walks
267    /// the chain back to the typed cause, on every build. Branch on
268    /// [`ModelErrorKind::code`](crate::model::ModelErrorKind::code).
269    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
277/// Convenience type alias.
278pub 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        // options() only reads an OptionsError off InvalidInput: the same
309        // payload under any other kind is not an OptionsError (#574).
310        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        // The mirror case: InvalidInput kind, but a plain message rather
319        // than an OptionsError payload, is still not an OptionsError (#574).
320        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        // And the typed cause is there for callers that downcast.
333        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        // `PartialEq` is manual because `value` is an `f64`: the bits
374        // compare, so even NaN is reflexive and `Eq` holds.
375        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        // The shared ONNX layer reports without a new EngineError variant
423        // (#641): ModelError -> io::Error::other -> EngineError::Io, and
424        // `model_error` walks the chain back to the same kind.
425        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        // The walk starts at the io::Error: EngineError::Io is transparent,
442        // which forwards source() past the payload rather than to it, so a
443        // walk from &engine cannot see it (see model_error's docs).
444        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}