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 anyhow::anyhow!(
174 "could not load the pdfium shared library ({e}). It is a runtime \
175 dependency of the `pdf-render` feature, not a build-time one: \
176 install pdfium-binaries and set PDFIUM_DYNAMIC_LIB_PATH to its lib \
177 directory, or use the document's text layer instead."
178 )
179 })?;
180 let pdfium = Pdfium::new(bindings);
181
182 let document = pdfium
183 .load_pdf_from_file(path, None)
184 .map_err(|e| anyhow::anyhow!("opening {} for rendering: {e}", path.display()))?;
185
186 let pages = document.pages();
187 let count = pages.len();
188 if page >= count as u32 {
189 anyhow::bail!("page {page} is out of range; the document has {count}");
190 }
191
192 // pdfium counts pages and pixels in i32, so the conversions are its API's
193 // rather than a choice made here. The page is bound to a local because
194 // `render_with_config` borrows it — inlining the call would drop the page
195 // while the render still refers to it.
196 let target = pages
197 .get(page as i32)
198 .map_err(|e| anyhow::anyhow!("reading page {page}: {e}"))?;
199 let rendered = target
200 .render_with_config(&PdfRenderConfig::new().set_target_width(i32::from(width)))
201 .map_err(|e| anyhow::anyhow!("rendering page {page}: {e}"))?;
202
203 let mut png = std::io::Cursor::new(Vec::new());
204 rendered
205 .as_image()
206 .map_err(|e| anyhow::anyhow!("converting page {page} to an image: {e}"))?
207 .write_to(&mut png, image::ImageFormat::Png)
208 .map_err(|e| anyhow::anyhow!("encoding page {page} as PNG: {e}"))?;
209
210 Ok(png.into_inner())
211}
212
213/// Render one page to a PNG, zero-based.
214///
215/// This build has no PDF renderer; see the `pdf-render` feature.
216#[cfg(not(feature = "pdf-render"))]
217pub fn render_page(_path: &Path, _page: u32, _width: u16) -> anyhow::Result<Vec<u8>> {
218 anyhow::bail!(
219 "this build cannot render PDF pages to images: rebuild with the \
220 `pdf-render` feature, or use the document's text layer instead"
221 )
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn a_document_the_extractor_never_split_is_all_of_segment_zero() {
230 // The shape of every PDF in the CMS 1115 corpus: real text, no form
231 // feeds. Page zero must be the whole thing rather than nothing.
232 let text = "227 pages of policy with no page breaks at all";
233 assert_eq!(text_segments(text), 1);
234 assert_eq!(page_text_from(text, 0, 227).unwrap(), text);
235 }
236
237 #[test]
238 fn asking_past_the_last_segment_names_both_counts_and_neither_blames_a_scan() {
239 // The message this replaces said the page "may be scanned" and to
240 // check `has_text_layer` — which was `true`, with the text sitting
241 // in segment zero. A real user followed that advice toward replacing
242 // the extractor entirely.
243 let err = page_text_from("one segment only", 1, 227)
244 .expect_err("page 1 of a single-segment document must fail");
245 let message = err.to_string();
246
247 assert!(message.contains("1 addressable text segment"), "{message}");
248 assert!(message.contains("227 page(s)"), "{message}");
249 assert!(
250 message.contains("document_text"),
251 "the remedy must be the one that works: {message}"
252 );
253 assert!(
254 !message.contains("has_text_layer"),
255 "must not send the reader back to a flag that is already true: {message}"
256 );
257 }
258
259 #[test]
260 fn a_document_with_real_page_breaks_still_indexes_by_page() {
261 // The case the old code got right, which must keep working.
262 let text = "first\u{c}second\u{c}third";
263 assert_eq!(text_segments(text), 3);
264 assert_eq!(page_text_from(text, 0, 3).unwrap(), "first");
265 assert_eq!(page_text_from(text, 2, 3).unwrap(), "third");
266 assert!(page_text_from(text, 3, 3).is_err());
267 }
268}