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/// Extract one page's text, zero-based.
58pub fn page_text(path: &Path, page: u32) -> anyhow::Result<String> {
59    // pdf_extract works a whole document at a time, so this extracts everything
60    // and takes the page wanted. Wasteful for a large document read page by
61    // page, and worth replacing when that becomes a real workload rather than a
62    // hypothetical one.
63    let doc = lopdf::Document::load(path)
64        .map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
65    let count = doc.get_pages().len() as u32;
66    if page >= count {
67        anyhow::bail!("page {page} is out of range; the document has {count}");
68    }
69
70    let text = pdf_extract::extract_text(path)
71        .map_err(|e| anyhow::anyhow!("extracting text from {}: {e}", path.display()))?;
72
73    // Page breaks are form feeds in pdf_extract's output. When they are absent —
74    // a single-page document, or one it did not mark — the whole text is the
75    // only sensible answer for page zero.
76    let pages: Vec<&str> = text.split('\u{c}').collect();
77    match pages.get(page as usize) {
78        Some(found) => Ok(found.to_string()),
79        None if page == 0 => Ok(text),
80        // Returning an empty string here would be a silent wrong answer: the
81        // caller would summarize nothing and report success. The page exists
82        // according to the document's own page tree, so failing to extract it is
83        // a real failure and says so.
84        None => anyhow::bail!(
85            "page {page} exists but no text could be extracted for it; \
86             the page may be scanned — check `has_text_layer` and render it instead"
87        ),
88    }
89}
90
91/// Render one page to a PNG, zero-based.
92///
93/// Runs pdfium in a **subprocess**. pdfium segfaults on input other parsers
94/// accept, and in-process that would kill the daemon and every job running
95/// alongside it rather than failing the one job holding the bad PDF. See
96/// [`crate::render_worker`].
97///
98/// Requires the `pdf-render` feature.
99#[cfg(feature = "pdf-render")]
100pub fn render_page(path: &Path, page: u32, width: u16) -> anyhow::Result<Vec<u8>> {
101    crate::render_worker::render_page(path, page, width)
102}
103
104/// Render a page in *this* process. Only the render worker should call this.
105///
106/// Kept separate so the isolation is not accidentally bypassed: anything
107/// reaching for `render_page` gets the safe path, and the unsafe one is named
108/// in a way that says why it is not the default.
109#[cfg(feature = "pdf-render")]
110pub(crate) fn render_page_in_process(
111    path: &Path,
112    page: u32,
113    width: u16,
114) -> anyhow::Result<Vec<u8>> {
115    use pdfium_render::prelude::*;
116
117    // pdfium is a shared library loaded at runtime, not linked in.
118    //
119    // `bind_to_system_library` alone searches only the platform's default
120    // loader paths, which is exactly where a Nix-provided library is *not*.
121    // `PDFIUM_DYNAMIC_LIB_PATH` is the escape hatch — set by this project's dev
122    // shell — with the system library as the fallback for a conventional
123    // install.
124    let bindings = match std::env::var("PDFIUM_DYNAMIC_LIB_PATH") {
125        Ok(dir) if !dir.is_empty() => {
126            Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path(&dir))
127                .or_else(|_| Pdfium::bind_to_system_library())
128        }
129        _ => Pdfium::bind_to_system_library(),
130    }
131    .map_err(|e| {
132        anyhow::anyhow!(
133            "could not load the pdfium shared library ({e}). It is a runtime \
134             dependency of the `pdf-render` feature, not a build-time one: \
135             install pdfium-binaries and set PDFIUM_DYNAMIC_LIB_PATH to its lib \
136             directory, or use the document's text layer instead."
137        )
138    })?;
139    let pdfium = Pdfium::new(bindings);
140
141    let document = pdfium
142        .load_pdf_from_file(path, None)
143        .map_err(|e| anyhow::anyhow!("opening {} for rendering: {e}", path.display()))?;
144
145    let pages = document.pages();
146    let count = pages.len();
147    if page >= count as u32 {
148        anyhow::bail!("page {page} is out of range; the document has {count}");
149    }
150
151    // pdfium counts pages and pixels in i32, so the conversions are its API's
152    // rather than a choice made here. The page is bound to a local because
153    // `render_with_config` borrows it — inlining the call would drop the page
154    // while the render still refers to it.
155    let target = pages
156        .get(page as i32)
157        .map_err(|e| anyhow::anyhow!("reading page {page}: {e}"))?;
158    let rendered = target
159        .render_with_config(&PdfRenderConfig::new().set_target_width(i32::from(width)))
160        .map_err(|e| anyhow::anyhow!("rendering page {page}: {e}"))?;
161
162    let mut png = std::io::Cursor::new(Vec::new());
163    rendered
164        .as_image()
165        .map_err(|e| anyhow::anyhow!("converting page {page} to an image: {e}"))?
166        .write_to(&mut png, image::ImageFormat::Png)
167        .map_err(|e| anyhow::anyhow!("encoding page {page} as PNG: {e}"))?;
168
169    Ok(png.into_inner())
170}
171
172/// Render one page to a PNG, zero-based.
173///
174/// This build has no PDF renderer; see the `pdf-render` feature.
175#[cfg(not(feature = "pdf-render"))]
176pub fn render_page(_path: &Path, _page: u32, _width: u16) -> anyhow::Result<Vec<u8>> {
177    anyhow::bail!(
178        "this build cannot render PDF pages to images: rebuild with the \
179         `pdf-render` feature, or use the document's text layer instead"
180    )
181}