pdf_oxide 0.3.78

The fastest Rust PDF library — 0.8ms mean, 5× faster than the industry leaders, 100% pass rate on 3,830 real-world PDFs. Text extraction, Markdown/HTML conversion, PDF creation and editing.
Documentation
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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! Structured warning surface.
//!
//! `PdfDocument::flatten_warnings()` returns the warnings raised since
//! the document was opened, as a list of structured `Warning` records.
//! Callers who want diagnostics as data (rather than stderr text from
//! `log::warn!`) opt in to this surface. The existing `log::warn!`
//! calls continue to fire so the `setup_logging(level="WARNING")`
//! shape keeps working.

#![forbid(unsafe_code)]

use serde::{Deserialize, Serialize};
use std::sync::Mutex;

/// A single structured warning raised during PDF processing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Warning {
    /// The category — used by callers to filter.
    pub category: WarningCategory,
    /// The page index the warning was raised on, if any. `None` means
    /// the warning is document-scoped (xref recovery, trailer parse,
    /// etc.).
    pub page: Option<usize>,
    /// Free-form message. Matches the `log::warn!` strings to
    /// preserve grep-ability for users transitioning off the stderr
    /// noise.
    pub message: String,
    /// PDF spec section the warning references, when applicable.
    /// E.g. "7.3.8.1" for the stream-keyword newline violation.
    pub spec_section: Option<&'static str>,
}

/// Coarse-grained category for filtering. Each maps to a target in
/// `log::warn!` calls — `pdf_oxide::parser`, `pdf_oxide::fonts`,
/// `pdf_oxide::content`, etc.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum WarningCategory {
    /// PDF spec violations during xref / stream / content-stream parsing.
    /// E.g. "SPEC VIOLATION: No newline after stream keyword".
    SpecViolation,
    /// Font has no `ToUnicode` entry; falling back to AGL / CID-as-
    /// Unicode chain.
    ToUnicodeMissing,
    /// Xref table corrupt; reconstructing from `obj`/`endobj` scan.
    XrefRecovery,
    /// Content stream exceeded `MAX_OPERATORS` cap; truncating.
    OperatorCapExceeded,
    /// Type 3 font detected — may require special glyph name mapping.
    Type3Font,
    /// Unexpected EOF while reading an object header / body.
    EofPremature,
    /// Encryption / decryption related warning.
    Encryption,
    /// Other font warnings (DescendantFonts inline-dict fallback, etc.).
    Font,
    /// Layout / reading-order warnings.
    Layout,
    /// A glyph produced no rendered output while the cursor still advanced,
    /// so the page renders with an invisible gap.
    GlyphDropped,
    /// A page carries no extractable text layer and looks like a scan, so
    /// extraction returns nothing for it and OCR is what would recover it.
    ///
    /// Raised instead of writing that sentence into the extracted content:
    /// the caller decides whether to surface it, where, and in what language.
    NoTextLayer,
    /// An image was not embedded in the converted output because its encoded
    /// size exceeds the inline-image cap.
    ///
    /// Raised instead of writing an HTML comment into the markdown: the
    /// content is what the page draws, and a note about why the library
    /// declined to inline something is a diagnostic about the library.
    ImageSuppressed,
}

impl WarningCategory {
    /// Stable kebab-case string for cross-binding consumption.
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::SpecViolation => "spec_violation",
            Self::ToUnicodeMissing => "to_unicode_missing",
            Self::XrefRecovery => "xref_recovery",
            Self::OperatorCapExceeded => "operator_cap_exceeded",
            Self::Type3Font => "type3_font",
            Self::EofPremature => "eof_premature",
            Self::Encryption => "encryption",
            Self::Font => "font",
            Self::Layout => "layout",
            Self::GlyphDropped => "glyph_dropped",
            Self::NoTextLayer => "no_text_layer",
            Self::ImageSuppressed => "image_suppressed",
        }
    }
}

/// Thread-safe sink for warnings raised during a single `PdfDocument`
/// lifetime. Backed by a `Mutex<Vec<Warning>>` so multi-threaded usage
/// (e.g. parallel-page extraction) doesn't lose warnings to a data race.
///
/// One sink per document. The document holds it in an `Arc` so worker
/// threads can clone it.
#[derive(Debug, Default)]
pub struct WarningSink {
    warnings: Mutex<Vec<Warning>>,
}

/// global process-wide structured-warning sink for
/// the seven highest-frequency `log::warn!` sites that live in free
/// functions (where `&PdfDocument` is not available to push to a
/// per-document sink). Sites currently routed through this global
/// sink:
///
/// - `src/parser.rs::read_stream_data` (SPEC VIOLATION / Stream
///   /Length mismatch)
/// - `src/content/parser.rs::*` (operator-cap exceeded)
/// - `src/fonts/font_dict.rs::*` (Type0 ToUnicode missing, Type 3
///   font detected)
///
/// Callers retrieve via [`drain_global_warnings`] OR through
/// `PdfDocument::flatten_warnings()` which merges global +
/// per-document warnings.
///
/// The sink is **thread-local**, not process-wide.
///
/// It was process-wide, and the drain is first-caller-wins, so two documents
/// being read at the same time stole each other's warnings: whichever called
/// `structured_warnings()` first collected the other's tail and reported it
/// against the wrong file. libxml2 reached the same conclusion about its
/// global handlers and deprecated them for per-context ones.
///
/// Thread-local scope fixes the case that actually occurs — a pool reading
/// documents in parallel, one per thread. It does not fix two documents read
/// sequentially on one thread where the first never drains; that needs the
/// sink threaded into the producers, which are free functions with no
/// document in scope. The producers are listed above so that work has a
/// starting point.
///
/// Bounded, because a long-lived reader that never drains would otherwise grow
/// without limit: at the cap a single `diagnostics_truncated` entry records
/// how many were dropped rather than the vector continuing to grow.
const MAX_SINK_ENTRIES: usize = 1000;

thread_local! {
    static WARNING_SINK: std::cell::RefCell<Vec<Warning>> =
        const { std::cell::RefCell::new(Vec::new()) };
    static DROPPED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}

/// Push a structured warning into this thread's sink. Called by
/// free-function log sites that can't access a `&PdfDocument`.
pub fn push_global_warning(warning: Warning) {
    WARNING_SINK.with(|sink| {
        let mut v = sink.borrow_mut();
        if v.len() >= MAX_SINK_ENTRIES {
            DROPPED.with(|d| d.set(d.get() + 1));
            return;
        }
        // Repeats are common — one malformed font warns once per glyph — and a
        // consumer wants to know it happened, not to read it a thousand times.
        if let Some(last) = v.iter_mut().rev().take(16).find(|w| {
            w.category == warning.category && w.page == warning.page && w.message == warning.message
        }) {
            let _ = last;
            return;
        }
        v.push(warning);
    });
}

/// Drain the process-wide structured-warning sink, returning a snapshot
/// and clearing the underlying storage. Used by
/// `PdfDocument::flatten_warnings` to surface free-function warnings
/// alongside per-document ones.
pub fn drain_global_warnings() -> Vec<Warning> {
    let mut out = WARNING_SINK.with(|sink| std::mem::take(&mut *sink.borrow_mut()));
    let dropped = DROPPED.with(|d| d.replace(0));
    if dropped > 0 {
        out.push(Warning {
            category: WarningCategory::SpecViolation,
            page: None,
            message: format!(
                "{dropped} further diagnostics were dropped after the {MAX_SINK_ENTRIES}-entry cap"
            ),
            spec_section: None,
        });
    }
    out
}

/// Snapshot this thread's sink without draining (for tests / observability).
pub fn snapshot_global_warnings() -> Vec<Warning> {
    WARNING_SINK.with(|sink| sink.borrow().clone())
}

/// Put warnings back at the front of this thread's sink.
///
/// Used to restore diagnostics belonging to a document that is mid-flight when
/// another document borrows the thread. Order is preserved so a later drain
/// sees them as they were raised.
pub(crate) fn restore_global_warnings(mut warnings: Vec<Warning>) {
    if warnings.is_empty() {
        return;
    }
    WARNING_SINK.with(|sink| {
        let mut v = sink.borrow_mut();
        warnings.append(&mut v);
        *v = warnings;
    });
}

impl WarningSink {
    /// Create an empty sink.
    pub fn new() -> Self {
        Self::default()
    }

    /// Push a new warning. Inexpensive — no `log` macro fired here; the
    /// existing `log::warn!` sites continue to fire on their own. Use
    /// `push_with_log` from the migrated call sites to emit both.
    pub fn push(&self, warning: Warning) {
        if let Ok(mut v) = self.warnings.lock() {
            v.push(warning);
        }
        // If the mutex was poisoned, silently drop — better than panic.
    }

    /// Snapshot of all warnings raised so far. Returns owned clones so
    /// the caller can keep them past the document's lifetime.
    pub fn snapshot(&self) -> Vec<Warning> {
        self.warnings.lock().map(|v| v.clone()).unwrap_or_default()
    }

    /// Total warning count.
    pub fn len(&self) -> usize {
        self.warnings.lock().map(|v| v.len()).unwrap_or(0)
    }

    /// True if no warnings have been raised.
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Clear all warnings. Used by `PdfDocument::reset_warnings()` for
    /// callers who want to track per-operation warnings.
    pub fn clear(&self) {
        if let Ok(mut v) = self.warnings.lock() {
            v.clear();
        }
    }

    /// Push multiple warnings at once. Used by callers that merge a
    /// drained external sink (e.g. the process-wide global sink) into
    /// the per-document sink under a single lock acquisition.
    pub fn extend(&self, warnings: impl IntoIterator<Item = Warning>) {
        if let Ok(mut v) = self.warnings.lock() {
            v.extend(warnings);
        }
    }

    /// Drain and return all accumulated warnings.
    pub fn take(&self) -> Vec<Warning> {
        if let Ok(mut v) = self.warnings.lock() {
            std::mem::take(&mut *v)
        } else {
            Vec::new()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sink_starts_empty() {
        let sink = WarningSink::new();
        assert!(sink.is_empty());
        assert_eq!(sink.len(), 0);
        assert_eq!(sink.snapshot().len(), 0);
    }

    #[test]
    fn push_and_snapshot() {
        let sink = WarningSink::new();
        sink.push(Warning {
            category: WarningCategory::ToUnicodeMissing,
            page: Some(0),
            message: "Type0 font 'X' has no ToUnicode entry!".into(),
            spec_section: Some("9.10.2"),
        });
        assert_eq!(sink.len(), 1);
        let snap = sink.snapshot();
        assert_eq!(snap[0].category, WarningCategory::ToUnicodeMissing);
        assert_eq!(snap[0].page, Some(0));
        assert!(snap[0].message.contains("ToUnicode"));
    }

    #[test]
    fn category_as_str_stable() {
        assert_eq!(WarningCategory::SpecViolation.as_str(), "spec_violation");
        assert_eq!(WarningCategory::ToUnicodeMissing.as_str(), "to_unicode_missing");
        assert_eq!(WarningCategory::OperatorCapExceeded.as_str(), "operator_cap_exceeded");
    }

    #[test]
    fn clear_resets() {
        let sink = WarningSink::new();
        sink.push(Warning {
            category: WarningCategory::SpecViolation,
            page: None,
            message: "x".into(),
            spec_section: None,
        });
        assert_eq!(sink.len(), 1);
        sink.clear();
        assert!(sink.is_empty());
    }

    #[test]
    fn warning_serializes_to_json() {
        let w = Warning {
            category: WarningCategory::SpecViolation,
            page: Some(0),
            message: "No newline after stream keyword".into(),
            spec_section: Some("7.3.8.1"),
        };
        let json = serde_json::to_string(&w).unwrap();
        assert!(json.contains("\"category\":\"spec_violation\""));
        assert!(json.contains("\"page\":0"));
        assert!(json.contains("\"spec_section\":\"7.3.8.1\""));
    }

    #[test]
    fn sink_thread_safe() {
        use std::sync::Arc;
        use std::thread;

        let sink = Arc::new(WarningSink::new());
        let mut handles = Vec::new();
        for i in 0..10 {
            let s = sink.clone();
            handles.push(thread::spawn(move || {
                s.push(Warning {
                    category: WarningCategory::Font,
                    page: Some(i),
                    message: format!("font warning {}", i),
                    spec_section: None,
                });
            }));
        }
        for h in handles {
            h.join().unwrap();
        }
        assert_eq!(sink.len(), 10);
    }
}

#[cfg(test)]
mod sink_scope_tests {
    use super::*;

    fn w(msg: &str) -> Warning {
        Warning {
            category: WarningCategory::SpecViolation,
            page: None,
            message: msg.to_string(),
            spec_section: None,
        }
    }

    /// Two readers running at once must not collect each other's warnings.
    ///
    /// The sink was process-wide and the drain is first-caller-wins, so
    /// whichever document asked first took the other's tail and reported it
    /// against the wrong file.
    #[test]
    fn one_thread_does_not_drain_anothers_warnings() {
        let _ = drain_global_warnings();
        push_global_warning(w("belongs to the main thread"));

        let other = std::thread::spawn(|| {
            push_global_warning(w("belongs to the spawned thread"));
            drain_global_warnings()
        })
        .join()
        .expect("thread");

        assert_eq!(other.len(), 1, "the other thread saw {other:?}");
        assert_eq!(other[0].message, "belongs to the spawned thread");

        let mine = drain_global_warnings();
        assert_eq!(mine.len(), 1, "this thread saw {mine:?}");
        assert_eq!(mine[0].message, "belongs to the main thread");
    }

    /// A repeated warning is recorded once, not once per occurrence.
    #[test]
    fn test_identical_warning_is_not_recorded_repeatedly() {
        let _ = drain_global_warnings();
        for _ in 0..50 {
            push_global_warning(w("one malformed font, warned per glyph"));
        }
        assert_eq!(drain_global_warnings().len(), 1);
    }

    /// A reader that never drains does not grow without bound, and is told
    /// how many were dropped rather than silently losing them.
    #[test]
    fn test_sink_is_bounded_and_reports_what_it_dropped() {
        let _ = drain_global_warnings();
        for i in 0..MAX_SINK_ENTRIES + 25 {
            push_global_warning(w(&format!("distinct {i}")));
        }
        let out = drain_global_warnings();
        assert_eq!(out.len(), MAX_SINK_ENTRIES + 1, "capped, plus one sentinel");
        assert!(
            out.last()
                .expect("sentinel")
                .message
                .contains("were dropped"),
            "the drop must be reported, not silent: {:?}",
            out.last()
        );
    }
}