Skip to main content

seyk_core/
pdf.rs

1//! Bounded embedded-text extraction from PDF bytes.
2//!
3//! Applications with a logger must filter `pdf_extract` to Off, plus
4//! `hayro_interpret`, `hayro_syntax` and `hayro_font` with `pdf-render`. This module
5//! catches unwinding parser panics with thread-local suppression under a permanent
6//! dispatcher, installed lazily once and delegating other threads to its captured
7//! hook. A later external hook replaces it and may print; Seyk never overwrites
8//! that hook. First installation is deferred during unwinding and that parse
9//! may print. Abort, OOM and
10//! parser hangs are not contained. `pdf-render` adds CPU page rendering with
11//! bundled fallback fonts and no system-font lookup. No OCR is performed.
12
13use crate::extractor::{ExtractedContent, ExtractionProvenance, Extractor};
14use crate::{Error, Modality, ResourceId, ResourceLocator, ResourceMetadata, Result};
15use pdf_extract::{Document, PlainTextOutput};
16use std::io::Write;
17
18pub use crate::resource::PDF_TEXT_VERSION as TEXT_VERSION;
19pub const MAX_PAGES: usize = 512;
20pub const MAX_PAGE_TEXT_BYTES: usize = 1024 * 1024;
21pub const MAX_DOCUMENT_TEXT_BYTES: usize = 32 * 1024 * 1024;
22pub(crate) const PREPARATION_VERSION: &str = "pdf-text-v3-page-isolation;512p;1MiB-page;32MiB-doc";
23
24#[cfg(feature = "pdf-render")]
25#[path = "pdf/render.rs"]
26pub(crate) mod render;
27#[cfg(feature = "pdf-render")]
28pub use render::{RENDER_VERSION, render_page};
29
30pub(crate) struct PageText {
31    pub page: u32,
32    pub text: String,
33    pub error: Option<Error>,
34}
35
36fn failure(source: &ResourceId, reason: &'static str) -> Error {
37    Error::PdfParse {
38        resource: source.clone(),
39        reason,
40    }
41}
42
43/// All parser work, including document loading and destruction, stays inside
44/// this boundary. Only allowlisted static panic reasons are exposed.
45fn guarded<T>(source: &ResourceId, f: impl FnOnce() -> Result<T>) -> Result<T> {
46    let silence = panic_hook::Guard::new();
47    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| {
48        Err(Error::ParserPanicked {
49            resource: source.clone(),
50            reason: silence.message(),
51        })
52    })
53}
54
55fn document(bytes: &[u8], source: &ResourceId) -> Result<Document> {
56    let mut doc = Document::load_mem(bytes).map_err(|_| failure(source, "invalid document"))?;
57    if doc.is_encrypted() {
58        return Err(failure(source, "encrypted documents are unsupported"));
59    }
60    // Without a logger, log! arguments are not evaluated. Reject invalid Type-4
61    // UTF-8 explicitly instead of depending on upstream's log-only unwrap panic.
62    for object in doc.objects.values() {
63        if let Ok(stream) = object.as_stream()
64            && stream
65                .dict
66                .get(b"FunctionType")
67                .and_then(|v| v.as_i64())
68                .ok()
69                == Some(4)
70        {
71            let content = stream
72                .get_plain_content()
73                .map_err(|_| failure(source, "invalid function stream"))?;
74            if std::str::from_utf8(&content).is_err() {
75                return Err(failure(source, "non-UTF-8 Type-4 function"));
76            }
77        }
78    }
79    // Validate the whole resource even when the caller only requests one page.
80    // Normalize to precisely the validated bytes so upstream cannot fall back to
81    // raw compressed data or silently omit an unresolved reference.
82    let numbers = pages(&doc, source)?;
83    let page_ids = doc.get_pages();
84    for number in numbers {
85        let id = page_ids[&number];
86        let page = doc
87            .get_dictionary(id)
88            .map_err(|_| failure(source, "invalid page"))?;
89        let mut content = Vec::new();
90        if let Ok(contents) = page.get(b"Contents") {
91            let (_, resolved) = doc
92                .dereference(contents)
93                .map_err(|_| failure(source, "unresolved page contents"))?;
94            let objects: &[pdf_extract::Object] = match resolved {
95                pdf_extract::Object::Array(items) => items,
96                pdf_extract::Object::Null => &[],
97                pdf_extract::Object::Stream(_) => std::slice::from_ref(contents),
98                _ => return Err(failure(source, "page contents must reference streams")),
99            };
100            for object in objects {
101                let (_, object) = doc
102                    .dereference(object)
103                    .map_err(|_| failure(source, "unresolved page content stream"))?;
104                let stream = object
105                    .as_stream()
106                    .map_err(|_| failure(source, "page content is not a stream"))?;
107                content.extend(strict_plain_content(stream, source)?);
108                // Match lopdf's page-stream concatenation exactly.
109                content.push(b'\n');
110            }
111        }
112        pdf_extract::content::Content::decode_strict(&content)
113            .map_err(|_| failure(source, "invalid or incomplete page content"))?;
114        let stream = doc.add_object(pdf_extract::Stream::new(
115            pdf_extract::Dictionary::new(),
116            content,
117        ));
118        doc.get_object_mut(id)
119            .and_then(pdf_extract::Object::as_dict_mut)
120            .map_err(|_| failure(source, "invalid page"))?
121            .set("Contents", stream);
122    }
123    Ok(doc)
124}
125
126fn strict_plain_content(stream: &pdf_extract::Stream, source: &ResourceId) -> Result<Vec<u8>> {
127    // Upstream Flate/LZW helpers log decompression errors and return prefixes.
128    // Content filters we cannot verify strictly are explicit unsupported errors.
129    if let Ok(params) = stream.dict.get(b"DecodeParms")
130        && !matches!(params, pdf_extract::Object::Null)
131    {
132        return Err(failure(source, "content DecodeParms are unsupported"));
133    }
134    let mut bytes = stream.content.clone();
135    if stream.dict.get(b"Filter").is_err() {
136        return Ok(bytes);
137    }
138    let filters = stream
139        .filters()
140        .map_err(|_| failure(source, "invalid content filter"))?;
141    if filters.is_empty() {
142        return Err(failure(source, "empty content filter"));
143    }
144    for filter in filters {
145        if filter != b"FlateDecode" {
146            return Err(failure(
147                source,
148                "content filter lacks strict validation support",
149            ));
150        }
151        let mut decoder = flate2::Decompress::new(true);
152        let mut decoded = Vec::new();
153        loop {
154            let mut chunk = [0u8; 8192];
155            let before_in = decoder.total_in();
156            let before_out = decoder.total_out();
157            let status = decoder
158                .decompress(
159                    &bytes[before_in as usize..],
160                    &mut chunk,
161                    flate2::FlushDecompress::None,
162                )
163                .map_err(|_| failure(source, "invalid compressed page content"))?;
164            decoded.extend_from_slice(&chunk[..(decoder.total_out() - before_out) as usize]);
165            if status == flate2::Status::StreamEnd {
166                if decoder.total_in() as usize != bytes.len() {
167                    return Err(failure(source, "trailing compressed page content"));
168                }
169                break;
170            }
171            if decoder.total_in() == before_in && decoder.total_out() == before_out {
172                return Err(failure(source, "incomplete compressed page content"));
173            }
174        }
175        bytes = decoded;
176    }
177    Ok(bytes)
178}
179
180struct LimitedText {
181    bytes: Vec<u8>,
182    exceeded: bool,
183}
184impl Write for LimitedText {
185    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
186        if bytes.len() > MAX_PAGE_TEXT_BYTES.saturating_sub(self.bytes.len()) {
187            self.exceeded = true;
188            return Err(std::io::Error::other("PDF text limit"));
189        }
190        self.bytes.extend_from_slice(bytes);
191        Ok(bytes.len())
192    }
193    fn flush(&mut self) -> std::io::Result<()> {
194        Ok(())
195    }
196}
197
198fn page_failure(source: &ResourceId, reason: &'static str) -> Error {
199    Error::PdfPageDecode {
200        resource: source.clone(),
201        reason,
202    }
203}
204
205fn decoded_text(bytes: Vec<u8>, source: &ResourceId) -> Result<String> {
206    String::from_utf8(bytes).map_err(|_| page_failure(source, "decoded text is not UTF-8"))
207}
208
209fn decode(doc: &Document, source: &ResourceId, page: u32) -> Result<String> {
210    let mut text = LimitedText {
211        bytes: Vec::new(),
212        exceeded: false,
213    };
214    let result = pdf_extract::output_doc_page(
215        doc,
216        &mut PlainTextOutput::new(&mut text as &mut dyn Write),
217        page,
218    );
219    if text.exceeded {
220        return Err(failure(source, "page text exceeds 1 MiB"));
221    }
222    result.map_err(|_| page_failure(source, "page decoding failed"))?;
223    decoded_text(text.bytes, source)
224}
225
226#[path = "pdf/panic_hook.rs"]
227mod panic_hook;
228
229fn pages(doc: &Document, source: &ResourceId) -> Result<Vec<u32>> {
230    let expected = doc
231        .catalog()
232        .and_then(|c| c.get(b"Pages"))
233        .and_then(|o| o.as_reference())
234        .and_then(|id| doc.get_dictionary(id))
235        .and_then(|d| d.get(b"Count"))
236        .and_then(|o| o.as_i64())
237        .map_err(|_| failure(source, "invalid page tree"))?;
238    if expected > MAX_PAGES as i64 {
239        return Err(failure(source, "document exceeds 512 pages"));
240    }
241    let pages: Vec<_> = doc.get_pages().into_keys().collect();
242    if expected < 0 || pages.len() as i64 != expected {
243        return Err(failure(source, "incomplete page tree"));
244    }
245    if pages.is_empty() {
246        return Err(failure(source, "document has no pages"));
247    }
248    if pages.len() > MAX_PAGES {
249        return Err(failure(source, "document exceeds 512 pages"));
250    }
251    Ok(pages)
252}
253
254fn isolated_page(result: Result<String>) -> Result<(String, Option<Error>)> {
255    Ok(match result {
256        Ok(text) => (text, None),
257        Err(error @ (Error::ParserPanicked { .. } | Error::PdfPageDecode { .. })) => {
258            (String::new(), Some(error))
259        }
260        Err(error) => return Err(error),
261    })
262}
263
264pub(crate) fn read_pages(bytes: &[u8], source: &ResourceId) -> Result<Vec<PageText>> {
265    guarded(source, || {
266        let doc = document(bytes, source)?;
267        let mut total = 0usize;
268        let mut result = Vec::new();
269        for page in pages(&doc, source)? {
270            let (text, error) = isolated_page(guarded(source, || decode(&doc, source, page)))?;
271            total += text.len();
272            if total > MAX_DOCUMENT_TEXT_BYTES {
273                return Err(failure(source, "document text exceeds 32 MiB"));
274            }
275            result.push(PageText { page, text, error });
276        }
277        Ok(result)
278    })
279}
280
281pub(crate) fn usable(text: &str) -> bool {
282    text.chars().any(char::is_alphanumeric) && !text.contains(['\0', '\u{fffd}'])
283}
284
285pub(crate) fn read_page(bytes: &[u8], source: &ResourceId, page: u32) -> Result<String> {
286    guarded(source, || {
287        let doc = document(bytes, source)?;
288        if !pages(&doc, source)?.contains(&page) {
289            return Err(failure(source, "page is out of bounds"));
290        }
291        decode(&doc, source, page)
292    })
293}
294
295pub(crate) fn slice<'a>(
296    text: &'a str,
297    source: &ResourceId,
298    locator: &ResourceLocator,
299) -> Result<&'a str> {
300    let invalid = |reason| Error::InvalidLocator {
301        resource: source.clone(),
302        locator: locator.clone(),
303        reason,
304    };
305    let ResourceLocator::PageText {
306        byte_start,
307        byte_end,
308        text_version,
309        page_text_hash,
310        ..
311    } = locator
312    else {
313        return Err(invalid("expected a PDF page text range"));
314    };
315    if text_version != TEXT_VERSION {
316        return Err(invalid("PDF text version changed; reindex"));
317    }
318    if blake3::hash(text.as_bytes()).to_hex().as_str() != page_text_hash {
319        return Err(invalid("PDF page text changed; reindex"));
320    }
321    let start =
322        usize::try_from(*byte_start).map_err(|_| invalid("byte offset exceeds platform limits"))?;
323    let end =
324        usize::try_from(*byte_end).map_err(|_| invalid("byte offset exceeds platform limits"))?;
325    text.get(start..end)
326        .ok_or_else(|| invalid("range must be ordered, in bounds and on UTF-8 boundaries"))
327}
328
329#[derive(Debug, Default)]
330pub struct PdfTextExtractor;
331impl Extractor for PdfTextExtractor {
332    fn supports(
333        &self,
334        _: &ResourceId,
335        locator: &ResourceLocator,
336        metadata: &ResourceMetadata,
337    ) -> bool {
338        metadata.modality == Modality::Document
339            && metadata.media_type.as_deref() == Some("application/pdf")
340            && matches!(locator, ResourceLocator::PageText { .. })
341    }
342    fn extract(
343        &self,
344        bytes: &[u8],
345        source: &ResourceId,
346        locator: &ResourceLocator,
347        metadata: &ResourceMetadata,
348    ) -> Result<ExtractedContent> {
349        if !self.supports(source, locator, metadata) {
350            return Err(crate::extractor::unsupported(source, locator, metadata));
351        }
352        let ResourceLocator::PageText { page, .. } = locator else {
353            unreachable!()
354        };
355        let text = read_page(bytes, source, *page)?;
356        Ok(ExtractedContent {
357            text: slice(&text, source, locator)?.to_owned(),
358            source: source.clone(),
359            locator: locator.clone(),
360            metadata: metadata.clone(),
361            provenance: Some(ExtractionProvenance {
362                speech: None,
363                method: "embedded_pdf_text".into(),
364                implementation: TEXT_VERSION.into(),
365            }),
366        })
367    }
368}
369
370#[cfg(test)]
371#[path = "../tests/support/pdf_fixture.rs"]
372pub(crate) mod fixture;
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn invalid_utf8_is_a_page_failure_and_does_not_discard_following_text() {
380        let source = "mem://utf8-pages".parse().unwrap();
381        let (text, error) = isolated_page(decoded_text(vec![0xff], &source)).unwrap();
382        assert!(text.is_empty());
383        assert!(matches!(
384            error,
385            Some(Error::PdfPageDecode {
386                reason: "decoded text is not UTF-8",
387                ..
388            })
389        ));
390        let (text, error) = isolated_page(decoded_text(b"second basil".to_vec(), &source)).unwrap();
391        assert_eq!(text, "second basil");
392        assert!(error.is_none());
393        assert!(isolated_page(Err(failure(&source, "page text exceeds 1 MiB"))).is_err());
394    }
395
396    #[test]
397    fn self_made_cmaps_reproduce_both_upstream_panics() {
398        let source = "mem://synthetic/cmap".parse().unwrap();
399        for (bad_length, message) in [
400            (false, "unexpected entry in unicode map"),
401            (true, "bad length of hexstring"),
402        ] {
403            let bytes = fixture::malformed_cmap(bad_length);
404            let doc = document(&bytes, &source).unwrap();
405            let _silence = panic_hook::Guard::new();
406            let payload =
407                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| decode(&doc, &source, 1)))
408                    .unwrap_err();
409            let actual = payload
410                .downcast_ref::<&str>()
411                .copied()
412                .or_else(|| payload.downcast_ref::<String>().map(String::as_str));
413            assert_eq!(actual, Some(message));
414        }
415    }
416
417    #[test]
418    fn page_and_document_output_caps_reject_without_truncation() {
419        let source = "mem://synthetic/limits".parse().unwrap();
420        let oversized = "a".repeat(MAX_PAGE_TEXT_BYTES + 1);
421        assert!(matches!(
422            read_pages(&fixture::pdf(&[&oversized], fixture::Kind::Plain), &source),
423            Err(Error::PdfParse {
424                reason: "page text exceeds 1 MiB",
425                ..
426            })
427        ));
428        // Reuse one stream reference across physical pages to keep input tiny;
429        // decoded output must still count on every page.
430        let text = "a".repeat(1024 * 1024 - 1024);
431        let mut doc =
432            Document::load_mem(&fixture::pdf(&vec!["a"; 33], fixture::Kind::Plain)).unwrap();
433        let body = format!("BT /F1 12 Tf 72 720 Td ({text}) Tj ET").into_bytes();
434        doc.objects.insert(
435            (6, 0),
436            pdf_extract::Object::Stream(pdf_extract::Stream::new(
437                pdf_extract::Dictionary::new(),
438                body,
439            )),
440        );
441        for id in doc.get_pages().values().copied().collect::<Vec<_>>() {
442            doc.get_object_mut(id)
443                .unwrap()
444                .as_dict_mut()
445                .unwrap()
446                .set("Contents", (6, 0));
447        }
448        let mut bytes = Vec::new();
449        doc.save_to(&mut bytes).unwrap();
450        assert!(matches!(
451            read_pages(&bytes, &source),
452            Err(Error::PdfParse {
453                reason: "document text exceeds 32 MiB",
454                ..
455            })
456        ));
457        let small = fixture::pdf(&vec!["garden basil"; MAX_PAGES], fixture::Kind::Plain);
458        assert_eq!(read_pages(&small, &source).unwrap().len(), MAX_PAGES);
459    }
460    #[test]
461    fn flate_requires_stream_end_checksum_and_complete_content() {
462        use flate2::{Compression, write::ZlibEncoder};
463        let source = "mem://synthetic/flate".parse().unwrap();
464        let body = b"BT /F1 12 Tf 72 720 Td (garden basil) Tj ET";
465        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
466        encoder.write_all(body).unwrap();
467        let compressed = encoder.finish().unwrap();
468        for variant in 0..4 {
469            let mut bytes = compressed.clone();
470            match variant {
471                1 => {
472                    bytes.truncate(bytes.len() - 2);
473                }
474                2 => {
475                    *bytes.last_mut().unwrap() ^= 1;
476                }
477                3 => bytes.push(0),
478                _ => {}
479            }
480            let mut doc =
481                Document::load_mem(&fixture::pdf(&["placeholder"], fixture::Kind::Plain)).unwrap();
482            let stream = doc.get_object_mut((6, 0)).unwrap().as_stream_mut().unwrap();
483            stream.set_content(bytes);
484            stream.dict.set("Filter", "FlateDecode");
485            let mut pdf = Vec::new();
486            doc.save_to(&mut pdf).unwrap();
487            let result = read_page(&pdf, &source, 1);
488            if variant == 0 {
489                assert!(result.unwrap().contains("garden basil"));
490            } else {
491                assert!(matches!(result, Err(Error::PdfParse { .. })));
492            }
493        }
494    }
495
496    #[test]
497    fn selected_page_validation_rejects_corruption_elsewhere() {
498        let source = "mem://synthetic/corrupt".parse().unwrap();
499        for kind in [
500            fixture::Kind::MissingContents,
501            fixture::Kind::MissingArrayContents,
502            fixture::Kind::TruncatedContents,
503            fixture::Kind::BadFlate,
504        ] {
505            let bytes = fixture::pdf(&["valid first page", "valid prefix"], kind);
506            assert!(matches!(
507                read_page(&bytes, &source, 1),
508                Err(Error::PdfParse { .. })
509            ));
510        }
511    }
512}