Skip to main content

cuttlefish_host/
documents.rs

1//! Reading paged documents — PDFs today.
2//!
3//! A document can be read two ways, and which is right depends on the document
4//! rather than on preference:
5//!
6//! - **Extract its text layer.** Cheap, exact, and works with any text model.
7//!   Useless for a scanned page, which has no text layer at all.
8//! - **Render pages to images** for a vision model. Works on anything a human
9//!   could read, including scans, and preserves layout, tables, and figures that
10//!   extraction flattens or drops. Far slower and needs a vision model.
11//!
12//! So the host reports what a document *offers* — page count, and whether a text
13//! layer exists — and the block decides. That is why
14//! [`MediaKind::Document`](cuttlefish_abi::MediaKind::Document) carries
15//! `has_text_layer`: a block that checks it can take the cheap path when it
16//! exists and the expensive one when it must, instead of silently extracting
17//! nothing from a scan and summarizing the empty string.
18//!
19//! # Why rendering is optional
20//!
21//! Text extraction is pure Rust and always available. Rasterizing needs a PDF
22//! renderer, which is a large native dependency, so it sits behind the
23//! `pdf-render` feature. Without it, [`render_page`] fails with a message saying
24//! exactly that rather than pretending the page is blank.
25
26use std::path::Path;
27
28/// What a document offers a block.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct DocumentInfo {
31    /// How many pages it has.
32    pub pages: u32,
33    /// Whether any page carries extractable text.
34    pub has_text_layer: bool,
35}
36
37/// Inspect a PDF without committing to reading all of it.
38pub fn inspect(path: &Path) -> anyhow::Result<DocumentInfo> {
39    let doc = lopdf::Document::load(path)
40        .map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
41    let pages = doc.get_pages().len() as u32;
42
43    // "Has a text layer" is answered by extracting and looking, because the
44    // alternative — inspecting font dictionaries — says a page *could* contain
45    // text without saying whether it does. A scanned page often still carries
46    // font resources from whatever produced it.
47    let has_text_layer = pdf_extract::extract_text(path)
48        .map(|text| text.chars().any(|c| c.is_alphanumeric()))
49        .unwrap_or(false);
50
51    Ok(DocumentInfo {
52        pages,
53        has_text_layer,
54    })
55}
56
57/// How many pages the PDF's own page tree reports.
58///
59/// Separate from [`inspect`] on purpose: `inspect` also answers
60/// `has_text_layer`, and the only honest way to answer that is to extract
61/// the text and look. Calling it merely to learn a page count therefore
62/// costs a full extraction — which is exactly the trap that made a page
63/// walk quadratic even *after* the text itself was cached.
64pub fn page_count(path: &Path) -> anyhow::Result<u32> {
65    let doc = lopdf::Document::load(path)
66        .map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
67    Ok(doc.get_pages().len() as u32)
68}
69
70/// Every character of text in the document, in one call.
71///
72/// This is what `pdf_extract` produces internally and what most callers
73/// actually want. It exists as its own entry point because the only way to
74/// ask for it used to be `page_text(handle, 0)`, which *reads* like "give me
75/// the first page" and silently meant something else whenever the extractor
76/// emitted no page breaks.
77pub fn document_text(path: &Path) -> anyhow::Result<String> {
78    pdf_extract::extract_text(path)
79        .map_err(|e| anyhow::anyhow!("extracting text from {}: {e}", path.display()))
80}
81
82/// How many text segments [`page_text_from`] can actually address.
83///
84/// Deliberately not the same number as [`DocumentInfo::pages`], and that is
85/// the entire point. `pages` comes from the PDF's page tree; this comes from
86/// splitting the extracted text on form feeds, which is all `pdf_extract`
87/// gives us to locate a page boundary with. For many real documents — every
88/// PDF in the CMS section 1115 corpus, for instance — the extractor emits no
89/// form feeds at all, so a 227-page document has exactly one addressable
90/// segment.
91///
92/// Two numbers with the same name meaning different things is what made the
93/// old failure so confusing. Now both are computable, so an error can name
94/// them both.
95pub fn text_segments(text: &str) -> usize {
96    text.split('\u{c}').count()
97}
98
99/// Take one segment of already-extracted text, zero-based.
100///
101/// Separated from extraction so a caller reading a document page by page
102/// pays for the extraction once rather than once per page. The old shape
103/// re-extracted the whole document on every call, which made the natural
104/// page-walk quadratic — a 342-page filing meant 342 full extractions, and
105/// on a corpus of thousands that is not slow, it is unrunnable.
106pub fn page_text_from(text: &str, page: u32, page_tree_count: u32) -> anyhow::Result<String> {
107    let segments: Vec<&str> = text.split('\u{c}').collect();
108    match segments.get(page as usize) {
109        Some(found) => Ok(found.to_string()),
110        // A document the extractor never split still has all its text, and
111        // page zero is the only sensible place to hand it back.
112        None if page == 0 => Ok(text.to_string()),
113        // Returning an empty string here would be a silent wrong answer: the
114        // caller would summarize nothing and report success.
115        //
116        // The message names both counts, because the difference between them
117        // *is* the problem. Its predecessor said the page "may be scanned"
118        // and to check `has_text_layer` — advice that sent at least one real
119        // user toward replacing the extractor, when `has_text_layer` was
120        // `true` and the text was right there in segment zero.
121        None => anyhow::bail!(
122            "page {page} is out of range for extracted text: this document exposes \
123             {} addressable text segment(s), though its page tree reports {page_tree_count} \
124             page(s). The extractor emitted no page break there, so the text for page \
125             {page} is not separately addressable — read the whole document with \
126             `document_text` instead, or render the page if it is genuinely scanned.",
127            segments.len()
128        ),
129    }
130}
131
132/// Render one page to a PNG, zero-based.
133///
134/// Runs pdfium in a **subprocess**. pdfium segfaults on input other parsers
135/// accept, and in-process that would kill the daemon and every job running
136/// alongside it rather than failing the one job holding the bad PDF. See
137/// [`crate::render_worker`].
138///
139/// Requires the `pdf-render` feature.
140#[cfg(feature = "pdf-render")]
141pub fn render_page(path: &Path, page: u32, width: u16) -> anyhow::Result<Vec<u8>> {
142    crate::render_worker::render_page(path, page, width)
143}
144
145/// Render a page in *this* process. Only the render worker should call this.
146///
147/// Kept separate so the isolation is not accidentally bypassed: anything
148/// reaching for `render_page` gets the safe path, and the unsafe one is named
149/// in a way that says why it is not the default.
150#[cfg(feature = "pdf-render")]
151pub(crate) fn render_page_in_process(
152    path: &Path,
153    page: u32,
154    width: u16,
155) -> anyhow::Result<Vec<u8>> {
156    use pdfium_render::prelude::*;
157
158    // pdfium is a shared library loaded at runtime, not linked in.
159    //
160    // `bind_to_system_library` alone searches only the platform's default
161    // loader paths, which is exactly where a Nix-provided library is *not*.
162    // `PDFIUM_DYNAMIC_LIB_PATH` is the escape hatch — set by this project's dev
163    // shell — with the system library as the fallback for a conventional
164    // install.
165    let bindings = match std::env::var("PDFIUM_DYNAMIC_LIB_PATH") {
166        Ok(dir) if !dir.is_empty() => {
167            Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path(&dir))
168                .or_else(|_| Pdfium::bind_to_system_library())
169        }
170        _ => Pdfium::bind_to_system_library(),
171    }
172    .map_err(|e| {
173        // The instruction comes first and the loader's own error last, on
174        // purpose. `dlopen` failures print every path they tried, which runs
175        // to hundreds of characters and pushes the actionable part out of
176        // any truncated view — so the reader sees a wall of paths and no
177        // remedy. Lead with what to do.
178        anyhow::anyhow!(
179            "pdfium is missing. It is a *runtime* dependency of the `pdf-render` feature, \
180             not a build-time one, so building with the feature is not enough — the shared \
181             library has to be on the system separately.\n\
182             \n\
183             Fix: download a prebuilt pdfium for this platform from \
184             https://github.com/bblanchon/pdfium-binaries/releases, then point cuttlefish \
185             at the directory holding it:\n\
186             \n    export PDFIUM_DYNAMIC_LIB_PATH=/path/to/pdfium/lib\n\
187             \n\
188             Or avoid rendering altogether: a document with a text layer needs none of \
189             this — check `has_text_layer` and use `document_text`.\n\
190             \n\
191             The loader reported: {e}"
192        )
193    })?;
194    let pdfium = Pdfium::new(bindings);
195
196    let document = pdfium
197        .load_pdf_from_file(path, None)
198        .map_err(|e| anyhow::anyhow!("opening {} for rendering: {e}", path.display()))?;
199
200    let pages = document.pages();
201    let count = pages.len();
202    if page >= count as u32 {
203        anyhow::bail!("page {page} is out of range; the document has {count}");
204    }
205
206    // pdfium counts pages and pixels in i32, so the conversions are its API's
207    // rather than a choice made here. The page is bound to a local because
208    // `render_with_config` borrows it — inlining the call would drop the page
209    // while the render still refers to it.
210    let target = pages
211        .get(page as i32)
212        .map_err(|e| anyhow::anyhow!("reading page {page}: {e}"))?;
213    let rendered = target
214        .render_with_config(&PdfRenderConfig::new().set_target_width(i32::from(width)))
215        .map_err(|e| anyhow::anyhow!("rendering page {page}: {e}"))?;
216
217    let mut png = std::io::Cursor::new(Vec::new());
218    rendered
219        .as_image()
220        .map_err(|e| anyhow::anyhow!("converting page {page} to an image: {e}"))?
221        .write_to(&mut png, image::ImageFormat::Png)
222        .map_err(|e| anyhow::anyhow!("encoding page {page} as PNG: {e}"))?;
223
224    Ok(png.into_inner())
225}
226
227/// Render one page to a PNG, zero-based.
228///
229/// This build has no PDF renderer; see the `pdf-render` feature.
230#[cfg(not(feature = "pdf-render"))]
231pub fn render_page(_path: &Path, _page: u32, _width: u16) -> anyhow::Result<Vec<u8>> {
232    anyhow::bail!(
233        "this build cannot render PDF pages to images: rebuild with the \
234         `pdf-render` feature, or use the document's text layer instead"
235    )
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn a_document_the_extractor_never_split_is_all_of_segment_zero() {
244        // The shape of every PDF in the CMS 1115 corpus: real text, no form
245        // feeds. Page zero must be the whole thing rather than nothing.
246        let text = "227 pages of policy with no page breaks at all";
247        assert_eq!(text_segments(text), 1);
248        assert_eq!(page_text_from(text, 0, 227).unwrap(), text);
249    }
250
251    #[test]
252    fn asking_past_the_last_segment_names_both_counts_and_neither_blames_a_scan() {
253        // The message this replaces said the page "may be scanned" and to
254        // check `has_text_layer` — which was `true`, with the text sitting
255        // in segment zero. A real user followed that advice toward replacing
256        // the extractor entirely.
257        let err = page_text_from("one segment only", 1, 227)
258            .expect_err("page 1 of a single-segment document must fail");
259        let message = err.to_string();
260
261        assert!(message.contains("1 addressable text segment"), "{message}");
262        assert!(message.contains("227 page(s)"), "{message}");
263        assert!(
264            message.contains("document_text"),
265            "the remedy must be the one that works: {message}"
266        );
267        assert!(
268            !message.contains("has_text_layer"),
269            "must not send the reader back to a flag that is already true: {message}"
270        );
271    }
272
273    #[test]
274    fn a_document_with_real_page_breaks_still_indexes_by_page() {
275        // The case the old code got right, which must keep working.
276        let text = "first\u{c}second\u{c}third";
277        assert_eq!(text_segments(text), 3);
278        assert_eq!(page_text_from(text, 0, 3).unwrap(), "first");
279        assert_eq!(page_text_from(text, 2, 3).unwrap(), "third");
280        assert!(page_text_from(text, 3, 3).is_err());
281    }
282}