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}