use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentInfo {
pub pages: u32,
pub has_text_layer: bool,
}
pub fn inspect(path: &Path) -> anyhow::Result<DocumentInfo> {
let doc = lopdf::Document::load(path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
let pages = doc.get_pages().len() as u32;
let has_text_layer = pdf_extract::extract_text(path)
.map(|text| text.chars().any(|c| c.is_alphanumeric()))
.unwrap_or(false);
Ok(DocumentInfo {
pages,
has_text_layer,
})
}
pub fn page_count(path: &Path) -> anyhow::Result<u32> {
let doc = lopdf::Document::load(path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
Ok(doc.get_pages().len() as u32)
}
pub fn document_text(path: &Path) -> anyhow::Result<String> {
pdf_extract::extract_text(path)
.map_err(|e| anyhow::anyhow!("extracting text from {}: {e}", path.display()))
}
pub fn text_segments(text: &str) -> usize {
text.split('\u{c}').count()
}
pub fn page_text_from(text: &str, page: u32, page_tree_count: u32) -> anyhow::Result<String> {
let segments: Vec<&str> = text.split('\u{c}').collect();
match segments.get(page as usize) {
Some(found) => Ok(found.to_string()),
None if page == 0 => Ok(text.to_string()),
None => anyhow::bail!(
"page {page} is out of range for extracted text: this document exposes \
{} addressable text segment(s), though its page tree reports {page_tree_count} \
page(s). The extractor emitted no page break there, so the text for page \
{page} is not separately addressable — read the whole document with \
`document_text` instead, or render the page if it is genuinely scanned.",
segments.len()
),
}
}
#[cfg(feature = "pdf-render")]
pub fn render_page(path: &Path, page: u32, width: u16) -> anyhow::Result<Vec<u8>> {
crate::render_worker::render_page(path, page, width)
}
#[cfg(feature = "pdf-render")]
pub(crate) fn render_page_in_process(
path: &Path,
page: u32,
width: u16,
) -> anyhow::Result<Vec<u8>> {
use pdfium_render::prelude::*;
let bindings = match std::env::var("PDFIUM_DYNAMIC_LIB_PATH") {
Ok(dir) if !dir.is_empty() => {
Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path(&dir))
.or_else(|_| Pdfium::bind_to_system_library())
}
_ => Pdfium::bind_to_system_library(),
}
.map_err(|e| {
anyhow::anyhow!(
"pdfium is missing. It is a *runtime* dependency of the `pdf-render` feature, \
not a build-time one, so building with the feature is not enough — the shared \
library has to be on the system separately.\n\
\n\
Fix: download a prebuilt pdfium for this platform from \
https://github.com/bblanchon/pdfium-binaries/releases, then point cuttlefish \
at the directory holding it:\n\
\n export PDFIUM_DYNAMIC_LIB_PATH=/path/to/pdfium/lib\n\
\n\
Or avoid rendering altogether: a document with a text layer needs none of \
this — check `has_text_layer` and use `document_text`.\n\
\n\
The loader reported: {e}"
)
})?;
let pdfium = Pdfium::new(bindings);
let document = pdfium
.load_pdf_from_file(path, None)
.map_err(|e| anyhow::anyhow!("opening {} for rendering: {e}", path.display()))?;
let pages = document.pages();
let count = pages.len();
if page >= count as u32 {
anyhow::bail!("page {page} is out of range; the document has {count}");
}
let target = pages
.get(page as i32)
.map_err(|e| anyhow::anyhow!("reading page {page}: {e}"))?;
let rendered = target
.render_with_config(&PdfRenderConfig::new().set_target_width(i32::from(width)))
.map_err(|e| anyhow::anyhow!("rendering page {page}: {e}"))?;
let mut png = std::io::Cursor::new(Vec::new());
rendered
.as_image()
.map_err(|e| anyhow::anyhow!("converting page {page} to an image: {e}"))?
.write_to(&mut png, image::ImageFormat::Png)
.map_err(|e| anyhow::anyhow!("encoding page {page} as PNG: {e}"))?;
Ok(png.into_inner())
}
#[cfg(not(feature = "pdf-render"))]
pub fn render_page(_path: &Path, _page: u32, _width: u16) -> anyhow::Result<Vec<u8>> {
anyhow::bail!(
"this build cannot render PDF pages to images: rebuild with the \
`pdf-render` feature, or use the document's text layer instead"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_document_the_extractor_never_split_is_all_of_segment_zero() {
let text = "227 pages of policy with no page breaks at all";
assert_eq!(text_segments(text), 1);
assert_eq!(page_text_from(text, 0, 227).unwrap(), text);
}
#[test]
fn asking_past_the_last_segment_names_both_counts_and_neither_blames_a_scan() {
let err = page_text_from("one segment only", 1, 227)
.expect_err("page 1 of a single-segment document must fail");
let message = err.to_string();
assert!(message.contains("1 addressable text segment"), "{message}");
assert!(message.contains("227 page(s)"), "{message}");
assert!(
message.contains("document_text"),
"the remedy must be the one that works: {message}"
);
assert!(
!message.contains("has_text_layer"),
"must not send the reader back to a flag that is already true: {message}"
);
}
#[test]
fn a_document_with_real_page_breaks_still_indexes_by_page() {
let text = "first\u{c}second\u{c}third";
assert_eq!(text_segments(text), 3);
assert_eq!(page_text_from(text, 0, 3).unwrap(), "first");
assert_eq!(page_text_from(text, 2, 3).unwrap(), "third");
assert!(page_text_from(text, 3, 3).is_err());
}
}