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