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//!
10//! The anydoc markdown path classifies a page before it extracts, and
11//! declines a page whose live text is small next to its vector artwork
12//! as image-based: it returns `Unsupported` and drops the page. Some of
13//! those pages carry a real, if small, text layer. When the markdown
14//! path declines a PDF, this module attempts a direct text extraction
15//! from the same pinned parser. Recovered text is emitted as a
16//! content-bearing conversion under a converter id that names the
17//! recovery path, always beside the stable `pdf_partial_text` warning
18//! so the file is still routed to OCR review. Only when the direct
19//! extraction also finds nothing does the genuine `pdf_no_text_layer`
20//! failure stand.
21
22use crate::segments::Segment;
23
24use super::anydoc_log::capture_anydoc;
25use super::{ConvertError, is_meaningful, markdown_to_plain, normalize_text};
26
27/// A successful PDF conversion: text, warnings, and structure spans.
28#[derive(Debug)]
29pub struct PdfConversion {
30    /// The converted text, UTF-8, NFC, LF line endings.
31    pub text: String,
32    /// Non-fatal notes, such as `pdf_partial_text`.
33    pub warnings: Vec<String>,
34    /// Structure spans over the text.
35    pub segments: Vec<Segment>,
36    /// Whether the text came from the direct recovery extractor rather
37    /// than the anydoc markdown path. The parent adapter maps this to
38    /// the manifest converter id, so a recovered document is never
39    /// recorded under the markdown path's id.
40    pub recovered: bool,
41}
42
43/// The stable warning a recovered page carries after the markdown path
44/// declined it. The `pdf_partial_text:` prefix is the same prefix the
45/// anydoc pages-need-OCR warning uses, so downstream tooling routes
46/// both to OCR with one rule. Only the message after the prefix names
47/// the vector-artwork case.
48const RECOVERED_TEXT_WARNING: &str = "pdf_partial_text: text recovered from a page reported as \
49image-based; route to OCR to confirm completeness";
50
51/// Maps an anydoc PDF error to the stable reason code. A scanned or
52/// image-only PDF is `pdf_no_text_layer`, so it converts once an OCR
53/// converter lands.
54fn error_code(error: &anydoc::ConvertError) -> &'static str {
55    if matches!(error, anydoc::ConvertError::Unsupported(_)) {
56        return "pdf_no_text_layer";
57    }
58    match error.code() {
59        "unsupported" => "unsupported",
60        "malformed" => "malformed",
61        "encrypted" => "encrypted",
62        "resourceLimit" => "resourceLimit",
63        "missingPart" => "missingPart",
64        _ => "io",
65    }
66}
67
68/// Promotes a captured anydoc message to a manifest warning. The
69/// partial-text case keeps its stable prefix so downstream tooling can
70/// route those files to OCR.
71fn promote_warning(message: &str) -> String {
72    if message.contains("pages need OCR") {
73        let head = message.split(" and ").next().unwrap_or(message);
74        return format!("pdf_partial_text: {head}");
75    }
76    format!("anydoc_recovery: {message}")
77}
78
79/// Attempts a direct text extraction from the pinned PDF parser,
80/// bypassing the markdown classifier. Returns the normalized text when a
81/// real text layer is present, or `None` when the page yields nothing a
82/// reader would see. Recovery stands only if at least one meaningful
83/// character remains: a page whose only recovered code points are
84/// whitespace, control characters, or invisible format characters (for
85/// example a font whose glyphs map to `U+0007` or a page of `U+200B`)
86/// has nothing to recover and still fails closed. The check gates on the
87/// presence of meaningful text; it does not scrub embedded control
88/// characters out of otherwise-real text, which stays a cross-converter
89/// question. This is the recovery path: it runs only after the markdown
90/// path has declined the PDF as image-based.
91fn recover_text_layer(source: &[u8]) -> Option<String> {
92    let raw = pdf_inspector::extractor::extract_text_mem(source).ok()?;
93    let text = normalize_text(&raw);
94    if text.chars().any(is_meaningful) {
95        Some(text)
96    } else {
97        None
98    }
99}
100
101/// Converts PDF bytes to text through the anydoc text-layer path, with
102/// a direct-extraction recovery when the markdown path declines a page
103/// as image-based.
104pub fn convert_pdf(source: &[u8]) -> Result<PdfConversion, ConvertError> {
105    let (converted, captured) =
106        capture_anydoc(|| anydoc::to_markdown_bytes(source, anydoc::Format::Pdf));
107    match converted {
108        Ok(markdown) => {
109            let text = normalize_text(&markdown_to_plain(&markdown));
110            if !source.is_empty() && text.is_empty() {
111                return Err(empty_output(source.len()));
112            }
113            let warnings = captured
114                .iter()
115                .map(|message| promote_warning(message))
116                .collect();
117            let segments = vec![Segment::span(0, text.len(), "document")];
118            Ok(PdfConversion {
119                text,
120                warnings,
121                segments,
122                recovered: false,
123            })
124        }
125        // The markdown path declined this PDF as image-based or
126        // low-text. Attempt a direct extraction: if a real text layer
127        // is present, the classifier and the extractor disagree, and
128        // that disagreement is the sole signal to recover rather than
129        // fail closed. No ratio or threshold gates the recovery.
130        Err(error) if matches!(error, anydoc::ConvertError::Unsupported(_)) => {
131            match recover_text_layer(source) {
132                Some(text) => {
133                    let mut warnings: Vec<String> = captured
134                        .iter()
135                        .map(|message| promote_warning(message))
136                        .collect();
137                    // Bias: the markdown path disputed this page, so it
138                    // is always routed to OCR review, never returned as
139                    // bare success. The captured log usually already
140                    // carries the pages-need-OCR warning; add the
141                    // recovery warning when it does not.
142                    if !warnings
143                        .iter()
144                        .any(|warning| warning.starts_with("pdf_partial_text:"))
145                    {
146                        warnings.push(RECOVERED_TEXT_WARNING.to_string());
147                    }
148                    let segments = vec![Segment::span(0, text.len(), "document")];
149                    Ok(PdfConversion {
150                        text,
151                        warnings,
152                        segments,
153                        recovered: true,
154                    })
155                }
156                None => Err(ConvertError {
157                    code: "pdf_no_text_layer",
158                    message: error.to_string(),
159                }),
160            }
161        }
162        Err(error) => Err(ConvertError {
163            code: error_code(&error),
164            message: error.to_string(),
165        }),
166    }
167}
168
169fn empty_output(source_len: usize) -> ConvertError {
170    ConvertError {
171        code: "empty_output",
172        message: format!("source is {source_len} bytes but conversion produced no text"),
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    // --- Synthetic PDF fixture generator ---------------------------------
181    //
182    // These build minimal one-page PDFs in bytes, with no copied files.
183    // A fixture pairs a run of path-drawing operators (vector artwork)
184    // with an optional text block, so a page can carry heavy vector
185    // paths next to a small live-text layer — the shape the classifier
186    // reports as image-based while a real text layer is still present.
187
188    /// Assembles a single-page PDF around one content stream, with a
189    /// Helvetica Type1 font so any text layer is extractable.
190    fn build_pdf(content: &str) -> Vec<u8> {
191        let objects = [
192            "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
193            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
194            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
195             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
196                .to_string(),
197            format!(
198                "<< /Length {} >>\nstream\n{content}\nendstream",
199                content.len()
200            ),
201            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
202             /Encoding /WinAnsiEncoding >>"
203                .to_string(),
204        ];
205        let mut pdf = String::from("%PDF-1.6\n");
206        let mut offsets = Vec::new();
207        for (index, body) in objects.iter().enumerate() {
208            offsets.push(pdf.len());
209            pdf.push_str(&format!("{} 0 obj\n{body}\nendobj\n", index + 1));
210        }
211        let xref_at = pdf.len();
212        pdf.push_str(&format!("xref\n0 {}\n", objects.len() + 1));
213        pdf.push_str("0000000000 65535 f \n");
214        for offset in &offsets {
215            pdf.push_str(&format!("{offset:010} 00000 n \n"));
216        }
217        pdf.push_str(&format!(
218            "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n",
219            objects.len() + 1
220        ));
221        pdf.into_bytes()
222    }
223
224    /// A run of `count` path operators: each iteration draws and strokes
225    /// one line (`m l S`), the high-volume operators of vector artwork.
226    fn vector_paths(count: usize) -> String {
227        let mut stream = String::new();
228        for step in 0..count {
229            let n = (step % 500) + 50;
230            stream.push_str(&format!("{n} {n} m {n} {n} l S\n"));
231        }
232        stream
233    }
234
235    /// A text block: one `Tj` per line under the F1 font.
236    fn text_block(lines: &[&str]) -> String {
237        let mut stream = String::from("BT /F1 12 Tf 72 700 Td\n");
238        for (index, line) in lines.iter().enumerate() {
239            if index > 0 {
240                stream.push_str("0 -14 Td\n");
241            }
242            stream.push_str(&format!("({line}) Tj\n"));
243        }
244        stream.push_str("ET\n");
245        stream
246    }
247
248    /// A single-page PDF whose one glyph decodes, through a Type0
249    /// Identity-H font's `ToUnicode` CMap, to `dst` (a four-hex-digit
250    /// UTF-16 code point). Paired with heavy vector paths so the page is
251    /// reported image-based. The extractor returns exactly that code
252    /// point, so it stands in for a page whose only recovered character
253    /// is invisible: the markdown path declines the page, and the direct
254    /// extraction recovers a code point with no readable content.
255    fn image_based_with_glyph_decoding_to(dst: &str) -> Vec<u8> {
256        let cmap = format!(
257            "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
258             /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
259             /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
260             1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n\
261             1 beginbfchar\n<0001> <{dst}>\nendbfchar\nendcmap\n\
262             CMapName currentdict /CMap defineresource pop\nend\nend"
263        );
264        let content = format!("{}BT /F1 12 Tf 72 700 Td <0001> Tj ET\n", vector_paths(600));
265        let objects = [
266            "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
267            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
268            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
269             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
270                .to_string(),
271            format!(
272                "<< /Length {} >>\nstream\n{content}\nendstream",
273                content.len()
274            ),
275            "<< /Type /Font /Subtype /Type0 /BaseFont /ABCDEE+Test \
276             /Encoding /Identity-H /DescendantFonts [7 0 R] /ToUnicode 6 0 R >>"
277                .to_string(),
278            format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()),
279            "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /ABCDEE+Test \
280             /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
281             /FontDescriptor 8 0 R /CIDToGIDMap /Identity /DW 1000 >>"
282                .to_string(),
283            "<< /Type /FontDescriptor /FontName /ABCDEE+Test /Flags 4 \
284             /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 \
285             /CapHeight 1000 /StemV 80 >>"
286                .to_string(),
287        ];
288        let mut pdf = String::from("%PDF-1.6\n");
289        let mut offsets = Vec::new();
290        for (index, body) in objects.iter().enumerate() {
291            offsets.push(pdf.len());
292            pdf.push_str(&format!("{} 0 obj\n{body}\nendobj\n", index + 1));
293        }
294        let xref_at = pdf.len();
295        pdf.push_str(&format!("xref\n0 {}\n", objects.len() + 1));
296        pdf.push_str("0000000000 65535 f \n");
297        for offset in &offsets {
298            pdf.push_str(&format!("{offset:010} 00000 n \n"));
299        }
300        pdf.push_str(&format!(
301            "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n",
302            objects.len() + 1
303        ));
304        pdf.into_bytes()
305    }
306
307    /// (a) Heavy vector paths beside a small live-text layer, no images.
308    /// The classifier reports this image-based and the markdown path
309    /// declines it, but the text layer is real.
310    fn image_based_with_text_layer() -> Vec<u8> {
311        build_pdf(&format!(
312            "{}{}",
313            vector_paths(600),
314            text_block(&["Small text layer only"])
315        ))
316    }
317
318    /// (b) A text-rich page with some vector paths. The classifier
319    /// reports this text-based and the markdown path extracts it.
320    fn text_rich_with_vector_paths() -> Vec<u8> {
321        let lines = [
322            "Quarterly report of the working group on data handling",
323            "The committee reviewed the summary and approved the plan",
324            "Revenue increased while costs held steady this period",
325            "Next review is scheduled for the following fiscal quarter",
326            "Signatures and approvals are recorded on the final page",
327        ];
328        build_pdf(&format!("{}{}", vector_paths(80), text_block(&lines)))
329    }
330
331    /// (c) Vector paths with no text operators at all: a genuine no-text
332    /// page that must still fail closed.
333    fn vector_only_no_text() -> Vec<u8> {
334        build_pdf(&vector_paths(300))
335    }
336
337    #[test]
338    fn partial_pdf_warnings_keep_their_prefix() {
339        assert_eq!(
340            promote_warning("1 of 2 pages need OCR and were not extracted"),
341            "pdf_partial_text: 1 of 2 pages need OCR"
342        );
343        assert_eq!(
344            promote_warning("recovered a broken xref"),
345            "anydoc_recovery: recovered a broken xref"
346        );
347    }
348
349    #[test]
350    fn image_based_page_with_text_layer_recovers_with_the_ocr_warning() {
351        let conversion = convert_pdf(&image_based_with_text_layer())
352            .expect("a real text layer behind vector artwork is recovered, not failed closed");
353        assert!(conversion.recovered, "the recovery path produced the text");
354        assert!(
355            conversion.text.contains("Small text layer only"),
356            "recovered text: {:?}",
357            conversion.text
358        );
359        assert!(
360            conversion
361                .warnings
362                .iter()
363                .any(|warning| warning.starts_with("pdf_partial_text:")),
364            "a disputed page is always routed to OCR: {:?}",
365            conversion.warnings
366        );
367    }
368
369    #[test]
370    fn text_rich_page_converts_without_warning_spam() {
371        let conversion = convert_pdf(&text_rich_with_vector_paths())
372            .expect("a text-based page converts through the markdown path");
373        assert!(
374            !conversion.recovered,
375            "a text-based page does not use the recovery path"
376        );
377        assert!(
378            conversion.text.contains("Quarterly report"),
379            "text: {:?}",
380            conversion.text
381        );
382        assert!(
383            conversion.warnings.is_empty(),
384            "a text-rich page carries no OCR-routing warning: {:?}",
385            conversion.warnings
386        );
387    }
388
389    #[test]
390    fn vector_only_page_without_text_fails_closed() {
391        let error = convert_pdf(&vector_only_no_text())
392            .expect_err("a page with no text layer has nothing to recover");
393        assert_eq!(
394            error.code, "pdf_no_text_layer",
395            "the genuine no-text class still fails closed: {error:?}"
396        );
397    }
398
399    #[test]
400    fn a_page_whose_glyphs_decode_to_control_only_fails_closed() {
401        // The extractor recovers one code point, U+0007 (BELL), a
402        // control character with no readable content. The meaningful-text
403        // floor rejects it, so the page fails closed rather than
404        // converting to a control character masquerading as recovered
405        // text.
406        let error = convert_pdf(&image_based_with_glyph_decoding_to("0007"))
407            .expect_err("a page of control characters has nothing meaningful to recover");
408        assert_eq!(error.code, "pdf_no_text_layer", "error: {error:?}");
409    }
410
411    #[test]
412    fn a_page_of_zero_width_spaces_only_fails_closed() {
413        // The extractor recovers one code point, U+200B (zero-width
414        // space), an invisible format character. The meaningful-text
415        // floor rejects it, so the page fails closed.
416        let error = convert_pdf(&image_based_with_glyph_decoding_to("200B"))
417            .expect_err("a page of zero-width spaces has nothing meaningful to recover");
418        assert_eq!(error.code, "pdf_no_text_layer", "error: {error:?}");
419    }
420
421    #[test]
422    fn outcomes_are_byte_reproducible() {
423        // The fixtures build identically and each class converts to the
424        // same bytes and warnings on a second pass.
425        assert_eq!(
426            image_based_with_text_layer(),
427            image_based_with_text_layer(),
428            "the fixture generator is deterministic"
429        );
430        let first = convert_pdf(&image_based_with_text_layer()).unwrap();
431        let second = convert_pdf(&image_based_with_text_layer()).unwrap();
432        assert_eq!(first.text, second.text);
433        assert_eq!(first.warnings, second.warnings);
434        assert_eq!(first.recovered, second.recovered);
435        assert_eq!(first.segments.len(), second.segments.len());
436    }
437}