1use 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
43fn 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 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 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 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 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 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}