Skip to main content

pdf_engine/
api_error.rs

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