Skip to main content

pdf_engine/
api_error.rs

1// Copyright (c) 2026 Innovation Trigger B.V.
2//
3// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
4// the PDFluent Commercial Licence. See the LICENSE file in this repository --
5// that file travels with the copy you received, which a URL does not.
6
7use std::fmt;
8use std::path::PathBuf;
9
10/// Common contract for engine error types.
11///
12/// `pdf-engine` exposes errors that flow up through the `pdfluent` facade
13/// to customers. Implementing this trait gives every error a stable,
14/// machine-readable code, an actionable help string, and a deep link to the
15/// public error documentation site.
16pub trait PdfError: std::error::Error {
17    /// Stable machine-readable identifier for the error.
18    ///
19    /// Format: `SCREAMING_SNAKE_CASE` (for example `"PASSWORD_REQUIRED"`).
20    /// These codes are part of the public API contract — bindings, log
21    /// pipelines, and customer error handlers depend on them, so they must
22    /// not change without a deprecation cycle.
23    fn code(&self) -> &str;
24
25    /// Human-readable, actionable help text.
26    ///
27    /// Suggests what the developer should do next: a code example, a
28    /// configuration switch, a check to run. Returned as `Option` because
29    /// some variants (e.g. raw I/O wrappers) have no useful generic advice.
30    fn help(&self) -> Option<String>;
31
32    /// Deep link to the canonical documentation page for this error.
33    ///
34    /// Derived from [`code`](Self::code) by lowercasing and substituting
35    /// `_` with `-`. Default implementation points at the anchor
36    /// `https://pdfluent.com/errors/#<slug>`; override only if you
37    /// host your own error documentation.
38    fn docs_url(&self) -> String {
39        format!(
40            "{ERROR_DOCS_BASE}{}",
41            self.code().to_lowercase().replace('_', "-")
42        )
43    }
44}
45
46/// Where [`PdfError::docs_url`] points: the error reference page, followed by
47/// the anchor for one code. Every engine code has an anchor of that slug there.
48const ERROR_DOCS_BASE: &str = "https://pdfluent.com/errors/#";
49
50/// Top-level error type for `pdf-engine` operations.
51///
52/// Each variant maps 1-to-1 to a stable [`PdfError::code`] string. Variants
53/// carry the minimum fields needed to render a useful message and to give
54/// the caller enough context to either retry or surface a clear message
55/// to a human.
56///
57/// The [`std::fmt::Display`] implementation produces a multi-line, formatted
58/// error message including a `Help:` block and a `Docs:` link. Use it as
59/// `format!("{err}")` for human consumption; use [`PdfError::code`] for
60/// programmatic dispatch.
61#[derive(Debug)]
62pub enum Error {
63    /// The requested file could not be found at the given path. Returned
64    /// before any parse work is attempted.
65    FileNotFound {
66        /// The path that was requested but does not exist.
67        path: PathBuf,
68    },
69    /// The PDF is encrypted and a password is required to open it. The
70    /// caller should retry with `OpenOptions::with_password(...)`.
71    PasswordRequired {
72        /// The path of the encrypted document.
73        path: PathBuf,
74    },
75    /// The PDF byte stream could not be parsed. The cross-reference table
76    /// is malformed, the trailer is unreachable, or a critical object is
77    /// missing. Try `OpenOptions::repair(true)` for a best-effort recovery.
78    CorruptPdf {
79        /// Path to the source file, when known.
80        path: Option<PathBuf>,
81        /// Human-readable diagnostic of what went wrong (for example
82        /// "missing trailer", "bad startxref offset").
83        reason: String,
84    },
85    /// The caller asked for a page index outside the document's range.
86    /// Page numbers are 1-based at the public API.
87    InvalidPageNumber {
88        /// The page index the caller requested.
89        requested: usize,
90        /// How many pages the document actually has.
91        total: usize,
92    },
93    /// A required font could not be located — neither embedded in the PDF,
94    /// nor on the system, nor in the SDK's Standard 14 fallback set.
95    FontNotFound {
96        /// The PDF base font name that could not be resolved.
97        font_name: String,
98    },
99    /// The requested operation is not allowed by the document's
100    /// permission flags. The caller may need an owner password.
101    PermissionDenied {
102        /// Which operation was denied (for example "modify", "print").
103        reason: String,
104    },
105    /// The document declares a PDF version this build of the SDK does not
106    /// understand.
107    UnsupportedPdfVersion {
108        /// The version string from the document header (e.g. `"2.1"`).
109        version: String,
110    },
111    /// A form-field operation referenced a field that does not exist in
112    /// the document's AcroForm dictionary.
113    FormFieldNotFound {
114        /// The field name the caller asked for. List actual field names
115        /// with `doc.form_fields()`.
116        field_name: String,
117    },
118    /// A digital-signature verification step failed (chain of trust,
119    /// hash mismatch, expired certificate, etc.).
120    SignatureVerificationFailed {
121        /// Specific failure reason from the signing pipeline.
122        reason: String,
123    },
124    /// A redaction operation could not complete. The document is
125    /// guaranteed to be unchanged when this is returned (redaction is
126    /// transactional — failure is total).
127    RedactionFailed {
128        /// Diagnostic of what blocked the redaction.
129        reason: String,
130    },
131    /// A format conversion (PDF → DOCX, PDF → image, HTML → PDF, etc.)
132    /// failed before output was produced.
133    ConversionFailed {
134        /// Diagnostic of what blocked the conversion.
135        reason: String,
136    },
137    /// A text or string encoding could not be decoded.
138    InvalidEncoding {
139        /// The encoding name that failed (for example `"WinAnsiEncoding"`).
140        encoding: String,
141    },
142    /// A PDF stream could not be decoded — typically because the stream
143    /// uses an unsupported filter or the encoded payload is corrupt.
144    StreamDecodeFailed {
145        /// The PDF filter name from the stream's `/Filter` entry.
146        filter: String,
147    },
148    /// The cross-reference table is corrupt or unreadable. Try
149    /// `OpenOptions::repair(true)` for a best-effort recovery pass.
150    XrefCorrupt {
151        /// Diagnostic of why the xref could not be parsed.
152        reason: String,
153    },
154    /// Writing the output document failed (disk full, permission denied,
155    /// network drive vanished, etc.).
156    OutputWriteFailed {
157        /// The destination path that was being written.
158        path: PathBuf,
159        /// Underlying I/O reason from the OS.
160        reason: String,
161    },
162    /// An embedded image could not be decoded. Common causes: corrupt JPEG,
163    /// unknown JPX profile, truncated pixel data.
164    ImageDecodeFailed {
165        /// The image format name (for example `"JPEG"`, `"JPX"`).
166        format: String,
167    },
168    /// An encryption operation failed (wrong key, unsupported cipher,
169    /// invalid permission flags).
170    EncryptionFailed {
171        /// Diagnostic of what blocked encryption.
172        reason: String,
173    },
174    /// The document violates a declared compliance standard
175    /// (PDF/A, PDF/UA, PDF/X). Use `pdf-compliance` to repair if possible.
176    ComplianceViolation {
177        /// The compliance standard the document failed against
178        /// (e.g. `"PDF/A-2b"`).
179        standard: String,
180        /// The specific violation that triggered the failure.
181        reason: String,
182    },
183    /// A wrapped `std::io::Error` from a lower layer. Inspect the inner
184    /// error for the concrete cause.
185    Io(std::io::Error),
186    /// The caller exercised a feature the SDK build does not include
187    /// (typically a feature-gated capability that was not enabled at
188    /// compile time).
189    UnsupportedFeature {
190        /// The feature name (matching the Cargo feature flag where
191        /// applicable).
192        feature: String,
193    },
194}
195
196impl std::error::Error for Error {
197    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
198        match self {
199            Error::Io(err) => Some(err),
200            _ => None,
201        }
202    }
203}
204
205impl From<std::io::Error> for Error {
206    fn from(err: std::io::Error) -> Self {
207        Error::Io(err)
208    }
209}
210
211impl PdfError for Error {
212    fn code(&self) -> &str {
213        match self {
214            Error::FileNotFound { .. } => "FILE_NOT_FOUND",
215            Error::PasswordRequired { .. } => "PASSWORD_REQUIRED",
216            Error::CorruptPdf { .. } => "CORRUPT_PDF",
217            Error::InvalidPageNumber { .. } => "INVALID_PAGE_NUMBER",
218            Error::FontNotFound { .. } => "FONT_NOT_FOUND",
219            Error::PermissionDenied { .. } => "PERMISSION_DENIED",
220            Error::UnsupportedPdfVersion { .. } => "UNSUPPORTED_PDF_VERSION",
221            Error::FormFieldNotFound { .. } => "FORM_FIELD_NOT_FOUND",
222            Error::SignatureVerificationFailed { .. } => "SIGNATURE_VERIFICATION_FAILED",
223            Error::RedactionFailed { .. } => "REDACTION_FAILED",
224            Error::ConversionFailed { .. } => "CONVERSION_FAILED",
225            Error::InvalidEncoding { .. } => "INVALID_ENCODING",
226            Error::StreamDecodeFailed { .. } => "STREAM_DECODE_FAILED",
227            Error::XrefCorrupt { .. } => "XREF_CORRUPT",
228            Error::OutputWriteFailed { .. } => "OUTPUT_WRITE_FAILED",
229            Error::ImageDecodeFailed { .. } => "IMAGE_DECODE_FAILED",
230            Error::EncryptionFailed { .. } => "ENCRYPTION_FAILED",
231            Error::ComplianceViolation { .. } => "COMPLIANCE_VIOLATION",
232            Error::Io(_) => "IO_ERROR",
233            Error::UnsupportedFeature { .. } => "UNSUPPORTED_FEATURE",
234        }
235    }
236
237    fn help(&self) -> Option<String> {
238        match self {
239            Error::FileNotFound { .. } => Some(format!("Check that the file exists and the path is correct.\n        Current directory: {}", std::env::current_dir().unwrap_or_default().display())),
240            Error::PasswordRequired { path } => {
241                let file_name = path.file_name().unwrap_or_default().to_string_lossy();
242                Some(format!("Pass a password when reading the file:\n\n    let doc = pdfluent::read_with(\"{}\", |opts| {{\n        opts.password(\"your-password\")\n    }})?;", file_name))
243            }
244            Error::CorruptPdf { .. } => Some("Try opts.repair(true) to attempt automatic repair.".to_string()),
245            Error::InvalidPageNumber { requested, total } => Some(format!("The document has {} pages. Requested {}, use a 1-based index up to doc.page_count().", total, requested)),
246            Error::FontNotFound { font_name } => Some(format!("Provide a custom font mapping for '{}', or ensure the font is installed on the system.", font_name)),
247            Error::PermissionDenied { .. } => Some("The PDF's permissions do not allow this operation. You may need an owner password.".to_string()),
248            Error::UnsupportedPdfVersion { version } => Some(format!("The SDK currently does not support PDF version {}.", version)),
249            Error::FormFieldNotFound { field_name } => Some(format!("Double-check the field name '{}' using doc.form_fields().", field_name)),
250            Error::SignatureVerificationFailed { .. } => Some("Check the certificate chain, validity period, and document integrity.".to_string()),
251            Error::RedactionFailed { .. } => Some("Ensure coordinates are within page bounds and the document allows redaction.".to_string()),
252            Error::ConversionFailed { .. } => Some("The document could not be converted to the requested format.".to_string()),
253            Error::InvalidEncoding { encoding } => Some(format!("The encoding '{}' is invalid or unsupported.", encoding)),
254            Error::StreamDecodeFailed { filter } => Some(format!("The stream could not be decoded using filter '{}'.", filter)),
255            Error::XrefCorrupt { .. } => Some("The cross-reference table is corrupt. Try opts.repair(true).".to_string()),
256            Error::OutputWriteFailed { .. } => Some("Ensure the destination path is writable and you have sufficient disk space.".to_string()),
257            Error::ImageDecodeFailed { format } => Some(format!("The image format '{}' could not be decoded.", format)),
258            Error::EncryptionFailed { .. } => Some("Check the encryption parameters and permissions.".to_string()),
259            Error::ComplianceViolation { standard, .. } => Some(format!("The document violates the {} standard. Consider using a compliance repair tool.", standard)),
260            Error::Io(_) => Some("Check the underlying I/O error details.".to_string()),
261            Error::UnsupportedFeature { .. } => Some("This feature is not yet supported by the PDFluent SDK.".to_string()),
262        }
263    }
264}
265
266impl fmt::Display for Error {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        let name = match self {
269            Error::FileNotFound { .. } => "FileNotFound",
270            Error::PasswordRequired { .. } => "PasswordRequired",
271            Error::CorruptPdf { .. } => "CorruptPdf",
272            Error::InvalidPageNumber { .. } => "InvalidPageNumber",
273            Error::FontNotFound { .. } => "FontNotFound",
274            Error::PermissionDenied { .. } => "PermissionDenied",
275            Error::UnsupportedPdfVersion { .. } => "UnsupportedPdfVersion",
276            Error::FormFieldNotFound { .. } => "FormFieldNotFound",
277            Error::SignatureVerificationFailed { .. } => "SignatureVerificationFailed",
278            Error::RedactionFailed { .. } => "RedactionFailed",
279            Error::ConversionFailed { .. } => "ConversionFailed",
280            Error::InvalidEncoding { .. } => "InvalidEncoding",
281            Error::StreamDecodeFailed { .. } => "StreamDecodeFailed",
282            Error::XrefCorrupt { .. } => "XrefCorrupt",
283            Error::OutputWriteFailed { .. } => "OutputWriteFailed",
284            Error::ImageDecodeFailed { .. } => "ImageDecodeFailed",
285            Error::EncryptionFailed { .. } => "EncryptionFailed",
286            Error::ComplianceViolation { .. } => "ComplianceViolation",
287            Error::Io(_) => "IoError",
288            Error::UnsupportedFeature { .. } => "UnsupportedFeature",
289        };
290
291        write!(f, "Error: {}\n\n", name)?;
292
293        match self {
294            Error::FileNotFound { path } => {
295                write!(f, "  Could not find the file at: {}\n\n", path.display())?;
296            }
297            Error::PasswordRequired { path } => {
298                write!(
299                    f,
300                    "  This PDF is encrypted and requires a password to open.\n\n"
301                )?;
302                write!(f, "  File: {}\n\n", path.display())?;
303            }
304            Error::CorruptPdf { path, reason } => {
305                if let Some(p) = path {
306                    write!(
307                        f,
308                        "  The PDF file '{}' is corrupt: {}\n\n",
309                        p.display(),
310                        reason
311                    )?;
312                } else {
313                    write!(f, "  The PDF data is corrupt: {}\n\n", reason)?;
314                }
315            }
316            Error::InvalidPageNumber { requested, total } => {
317                write!(
318                    f,
319                    "  Requested page number {}, but the document only has {} pages.\n\n",
320                    requested, total
321                )?;
322            }
323            Error::FontNotFound { font_name } => {
324                write!(
325                    f,
326                    "  The required font '{}' could not be found.\n\n",
327                    font_name
328                )?;
329            }
330            Error::PermissionDenied { reason } => {
331                write!(
332                    f,
333                    "  Operation denied by document permissions: {}\n\n",
334                    reason
335                )?;
336            }
337            Error::UnsupportedPdfVersion { version } => {
338                write!(f, "  PDF version {} is not supported.\n\n", version)?;
339            }
340            Error::FormFieldNotFound { field_name } => {
341                write!(f, "  Could not find form field: '{}'\n\n", field_name)?;
342            }
343            Error::SignatureVerificationFailed { reason } => {
344                write!(f, "  Signature verification failed: {}\n\n", reason)?;
345            }
346            Error::RedactionFailed { reason } => {
347                write!(f, "  Redaction operation failed: {}\n\n", reason)?;
348            }
349            Error::ConversionFailed { reason } => {
350                write!(f, "  Conversion failed: {}\n\n", reason)?;
351            }
352            Error::InvalidEncoding { encoding } => {
353                write!(
354                    f,
355                    "  Invalid or unsupported text encoding: {}\n\n",
356                    encoding
357                )?;
358            }
359            Error::StreamDecodeFailed { filter } => {
360                write!(f, "  Failed to decode stream using filter: {}\n\n", filter)?;
361            }
362            Error::XrefCorrupt { reason } => {
363                write!(f, "  The cross-reference table is corrupt: {}\n\n", reason)?;
364            }
365            Error::OutputWriteFailed { path, reason } => {
366                write!(
367                    f,
368                    "  Failed to write output to '{}': {}\n\n",
369                    path.display(),
370                    reason
371                )?;
372            }
373            Error::ImageDecodeFailed { format } => {
374                write!(f, "  Failed to decode {} image.\n\n", format)?;
375            }
376            Error::EncryptionFailed { reason } => {
377                write!(f, "  Encryption operation failed: {}\n\n", reason)?;
378            }
379            Error::ComplianceViolation { standard, reason } => {
380                write!(
381                    f,
382                    "  Document violates {} compliance: {}\n\n",
383                    standard, reason
384                )?;
385            }
386            Error::Io(err) => {
387                write!(f, "  I/O error occurred: {}\n\n", err)?;
388            }
389            Error::UnsupportedFeature { feature } => {
390                write!(f, "  Unsupported feature: {}\n\n", feature)?;
391            }
392        }
393
394        if let Some(help) = self.help() {
395            write!(f, "  Help: ")?;
396            let mut first = true;
397            for line in help.lines() {
398                if first {
399                    writeln!(f, "{}", line)?;
400                    first = false;
401                } else if line.is_empty() {
402                    writeln!(f)?;
403                } else {
404                    writeln!(f, "        {}", line)?;
405                }
406            }
407            writeln!(f)?;
408        }
409
410        write!(f, "  Docs: {}", self.docs_url())
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_file_not_found_message() {
420        let err = Error::FileNotFound {
421            path: PathBuf::from("/tmp/nonexistent.pdf"),
422        };
423        let msg = format!("{}", err);
424        assert!(msg.contains("FileNotFound"));
425        assert!(msg.contains("/tmp/nonexistent.pdf"));
426        assert!(msg.contains("Help:"));
427        assert!(msg.contains("Docs: https://pdfluent.com/errors/#file-not-found"));
428    }
429
430    #[test]
431    fn test_password_required_message() {
432        let err = Error::PasswordRequired {
433            path: PathBuf::from("invoice-2024.pdf"),
434        };
435        let msg = format!("{}", err);
436        assert!(msg.contains("PasswordRequired"));
437        assert!(msg.contains("invoice-2024.pdf"));
438        assert!(msg.contains("Help: Pass a password when reading the file:"));
439        assert!(msg.contains("Docs: https://pdfluent.com/errors/#password-required"));
440    }
441
442    #[test]
443    fn docs_url_is_the_anchor_of_the_code_on_the_errors_page() {
444        let err = Error::PasswordRequired {
445            path: PathBuf::from("a.pdf"),
446        };
447        assert_eq!(err.code(), "PASSWORD_REQUIRED");
448        assert_eq!(
449            err.docs_url(),
450            "https://pdfluent.com/errors/#password-required"
451        );
452    }
453}