1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
//! Error type for conversion.
use std::error::Error as _;
use std::fmt;
pub use docling_core::EncryptionError;
use crate::format::InputFormat;
/// Anything that can go wrong while loading or converting a source document.
#[derive(Debug)]
pub enum ConversionError {
/// Reading the input from disk failed.
Io(std::io::Error),
/// The file extension (or content) did not map to a known format.
UnknownFormat { hint: String },
/// The format is known but no backend is wired up for it yet.
UnsupportedFormat(InputFormat),
/// The backend recognized the format but failed to parse the content.
Parse(String),
/// The requested streaming conversion is not supported (e.g. JSON, or the
/// referenced image mode, which both need the whole document up front).
Streaming(String),
/// The headless-browser pre-render (`--use-web-browser`) failed, or the crate
/// was built without the `web-browser` feature.
Browser(String),
/// A conversion worker panicked — a backend bug reached on some input
/// (#395/#396). The panic itself is not swallowed: it still unwinds its own
/// thread and prints its message and backtrace to stderr. This turns it
/// into an ordinary error for the caller, so a server answers with a 500
/// instead of a silent empty document and a batch keeps going.
Panic(String),
/// The document budget (`DocumentConverter::document_timeout`, #497) ran
/// out on a **streaming** conversion. Not a failure: every chunk emitted
/// before it is the partial document, and this is the stream's last item
/// — the streaming counterpart of [`crate::ConversionStatus::PartialSuccess`]
/// with a timeout [`crate::ErrorItem`]. Buffered conversions never raise
/// it; they report the cut on the result.
Timeout(String),
/// A dependency failed during conversion. Unlike [`ConversionError::Parse`]
/// the underlying error is kept alive (not flattened into a string), so
/// callers can walk [`std::error::Error::source`] and downcast to the
/// original type — e.g. to tell a truncated archive from malformed XML.
WithSource {
/// What was being converted when the failure happened (backend prefix,
/// mirroring the `Parse` message style: "xlsx", "docling-json", …).
context: String,
/// The error that caused the failure, preserved on the chain.
source: Box<dyn std::error::Error + Send + Sync>,
},
}
impl ConversionError {
/// Wrap a dependency error, keeping it reachable via
/// [`std::error::Error::source`] instead of stringifying it.
pub fn with_source(
context: impl Into<String>,
source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
) -> Self {
ConversionError::WithSource {
context: context.into(),
source: source.into(),
}
}
/// The error for an encrypted `context` document (#636): displays as
/// `parse error: <context>: <error>` — the Office wording of #624 — with
/// the typed [`EncryptionError`] on the source chain for
/// [`encryption`](Self::encryption).
pub fn encrypted(context: impl Into<String>, error: EncryptionError) -> Self {
Self::with_source(context, error)
}
/// [`encrypted`](Self::encrypted) with a backend's own wording: `message`
/// is what the error displays (after `parse error: <context>: `), the
/// typed value stays the cause. For the readers whose text predates the
/// type (iWork, WordPerfect) — nothing a user or a test reads changed
/// when the value arrived.
pub fn encrypted_with_message(
context: impl Into<String>,
error: EncryptionError,
message: impl Into<String>,
) -> Self {
Self::with_source(
context,
EncryptedDocument {
message: message.into(),
error,
},
)
}
/// The typed reason when this error says the document is encrypted
/// (#636): anywhere on the [`source`](std::error::Error::source) chain —
/// the PDF reader's [`docling_pdf::PdfError::Encrypted`], the Office
/// decryptor's value, an iWork or WordPerfect backend's — so a caller
/// prompts for a password on [`EncryptionError::needs_password`] instead
/// of matching message text. `None` for every other failure.
pub fn encryption(&self) -> Option<&EncryptionError> {
let mut cause: Option<&(dyn std::error::Error + 'static)> = self.source();
while let Some(c) = cause {
if let Some(e) = c.downcast_ref::<EncryptionError>() {
return Some(e);
}
cause = c.source();
}
None
}
}
/// An encryption error in a backend's own words; the typed value is its
/// cause. See [`ConversionError::encrypted_with_message`].
#[derive(Debug)]
struct EncryptedDocument {
message: String,
error: EncryptionError,
}
impl fmt::Display for EncryptedDocument {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for EncryptedDocument {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.error)
}
}
impl fmt::Display for ConversionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConversionError::Io(e) => write!(f, "i/o error: {e}"),
ConversionError::UnknownFormat { hint } => {
write!(f, "could not determine input format (hint: {hint})")
}
ConversionError::UnsupportedFormat(fmt) => {
write!(
f,
"no backend implemented yet for format '{}'",
fmt.as_str()
)
}
ConversionError::Parse(msg) => write!(f, "parse error: {msg}"),
ConversionError::Streaming(msg) => write!(f, "streaming not supported: {msg}"),
ConversionError::Browser(msg) => write!(f, "web-browser render error: {msg}"),
ConversionError::Panic(msg) => write!(f, "conversion panicked: {msg}"),
ConversionError::Timeout(msg) => write!(f, "document timeout: {msg}"),
ConversionError::WithSource { context, source } => {
// A source that already names its format (`PdfError`'s
// `pdf: …`) is not prefixed twice (`pdf: pdf: …`).
let msg = source.to_string();
if msg.starts_with(&format!("{context}: ")) {
write!(f, "parse error: {msg}")
} else {
write!(f, "parse error: {context}: {msg}")
}
}
}
}
}
impl std::error::Error for ConversionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConversionError::Io(e) => Some(e),
ConversionError::WithSource { source, .. } => Some(source.as_ref()),
_ => None,
}
}
}
impl From<std::io::Error> for ConversionError {
fn from(e: std::io::Error) -> Self {
ConversionError::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
#[test]
fn with_source_keeps_the_cause_on_the_chain() {
let cause = serde_json::from_str::<i32>("boom").unwrap_err();
let err = ConversionError::with_source("docling-json", cause);
assert!(err.to_string().starts_with("parse error: docling-json: "));
let source = err.source().expect("source is chained");
assert!(
source.downcast_ref::<serde_json::Error>().is_some(),
"chained source downcasts to the original type"
);
}
/// A source whose message already names the context is not prefixed
/// with it again (`parse error: pdf: pdf: …`).
#[test]
fn with_source_does_not_repeat_the_context() {
let cause = std::io::Error::other("pdf: not a readable PDF");
assert_eq!(
ConversionError::with_source("pdf", cause).to_string(),
"parse error: pdf: not a readable PDF"
);
let cause = std::io::Error::other("bad header");
assert_eq!(
ConversionError::with_source("pdf", cause).to_string(),
"parse error: pdf: bad header"
);
}
#[test]
fn stringly_variants_have_no_source() {
assert!(ConversionError::Parse("x".into()).source().is_none());
}
/// #636: the typed value is found however deep it sits — directly as
/// the source, behind a backend's own wording, or behind another
/// wrapper (`with_source` stacks) — and its text is the #624 one.
#[test]
fn encryption_is_found_anywhere_on_the_chain() {
let direct = ConversionError::encrypted("docx", EncryptionError::NeedPassword);
assert_eq!(
direct.to_string(),
"parse error: docx: document is encrypted (a password is required to open it)"
);
assert_eq!(direct.encryption(), Some(&EncryptionError::NeedPassword));
let worded = ConversionError::encrypted_with_message(
"iwork",
EncryptionError::NotDecryptable("iWork package encryption".into()),
"the document is password-protected",
);
assert_eq!(
worded.to_string(),
"parse error: iwork: the document is password-protected"
);
assert!(matches!(
worded.encryption(),
Some(EncryptionError::NotDecryptable(_))
));
assert!(!worded.encryption().unwrap().needs_password());
let stacked = ConversionError::with_source("archive entry", worded);
assert!(stacked.encryption().is_some(), "{stacked}");
assert_eq!(ConversionError::Parse("bad zip".into()).encryption(), None);
assert_eq!(
ConversionError::with_source("xlsx", std::io::Error::other("short read")).encryption(),
None
);
}
}