Skip to main content

text_mirror/convert/
pdf.rs

1//! The PDF text-layer conversion, shared by the sandbox worker.
2//!
3//! This is the anydoc PDF path that once lived in the in-process
4//! document adapter. It now runs inside the subprocess worker, so
5//! hostile PDF bytes crash a jailed child and never the pipeline. The
6//! outcome shape, the `pdf_partial_text` warning, and the failure
7//! reasons such as `pdf_no_text_layer` are unchanged, so a converted
8//! PDF's manifest record differs only in its converter id.
9
10use crate::segments::Segment;
11
12use super::anydoc_log::capture_anydoc;
13use super::{ConvertError, markdown_to_plain, normalize_text};
14
15/// A successful PDF conversion: text, warnings, and structure spans.
16pub struct PdfConversion {
17    /// The converted text, UTF-8, NFC, LF line endings.
18    pub text: String,
19    /// Non-fatal notes, such as `pdf_partial_text`.
20    pub warnings: Vec<String>,
21    /// Structure spans over the text.
22    pub segments: Vec<Segment>,
23}
24
25/// Maps an anydoc PDF error to the stable reason code. A scanned or
26/// image-only PDF is `pdf_no_text_layer`, so it converts once an OCR
27/// converter lands.
28fn error_code(error: &anydoc::ConvertError) -> &'static str {
29    if matches!(error, anydoc::ConvertError::Unsupported(_)) {
30        return "pdf_no_text_layer";
31    }
32    match error.code() {
33        "unsupported" => "unsupported",
34        "malformed" => "malformed",
35        "encrypted" => "encrypted",
36        "resourceLimit" => "resourceLimit",
37        "missingPart" => "missingPart",
38        _ => "io",
39    }
40}
41
42/// Promotes a captured anydoc message to a manifest warning. The
43/// partial-text case keeps its stable prefix so downstream tooling can
44/// route those files to OCR.
45fn promote_warning(message: &str) -> String {
46    if message.contains("pages need OCR") {
47        let head = message.split(" and ").next().unwrap_or(message);
48        return format!("pdf_partial_text: {head}");
49    }
50    format!("anydoc_recovery: {message}")
51}
52
53/// Converts PDF bytes to text through the anydoc text-layer path.
54pub fn convert_pdf(source: &[u8]) -> Result<PdfConversion, ConvertError> {
55    let (converted, captured) =
56        capture_anydoc(|| anydoc::to_markdown_bytes(source, anydoc::Format::Pdf));
57    let markdown = converted.map_err(|error| ConvertError {
58        code: error_code(&error),
59        message: error.to_string(),
60    })?;
61    let text = normalize_text(&markdown_to_plain(&markdown));
62    if !source.is_empty() && text.is_empty() {
63        return Err(ConvertError {
64            code: "empty_output",
65            message: format!(
66                "source is {} bytes but conversion produced no text",
67                source.len()
68            ),
69        });
70    }
71    let warnings = captured
72        .iter()
73        .map(|message| promote_warning(message))
74        .collect();
75    let segments = vec![Segment::span(0, text.len(), "document")];
76    Ok(PdfConversion {
77        text,
78        warnings,
79        segments,
80    })
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn partial_pdf_warnings_keep_their_prefix() {
89        assert_eq!(
90            promote_warning("1 of 2 pages need OCR and were not extracted"),
91            "pdf_partial_text: 1 of 2 pages need OCR"
92        );
93        assert_eq!(
94            promote_warning("recovered a broken xref"),
95            "anydoc_recovery: recovered a broken xref"
96        );
97    }
98}