This guide shows developers how to use PDFluent to process PDFs with AWS Textract, Azure Document Intelligence, and Google Cloud Vision, and to detect if a PDF is scanned.
No additional features are required. Page inspection is part of the base crate.
# Cargo.toml
[dependencies]
pdfluent = "1.0.0-beta.18"Extract each page as an image with PDFluent, send it to AWS Textract for OCR, then write back an invisible text layer. Works for printed text, tables, and forms.
use aws_sdk_textract::Client;
use pdfluent::{Sdk, ocr::{OcrLayerOptions, OcrWord}};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let sdk = Sdk::new()?;
let doc = sdk.open("scanned_invoice.pdf")?;
let config = aws_config::load_from_env().await;
let textract = Client::new(&config);
let mut builder = doc.add_ocr_layer();
for page in doc.pages().filter(|p| p.is_image_only()) {
// Render page to PNG bytes at 300 DPI
let png_bytes = doc.render_page_to_bytes(page.index(), 300)?;
// Call Textract synchronous DetectDocumentText
let resp = textract
.detect_document_text()
.document(
aws_sdk_textract::types::Document::builder()
.bytes(aws_sdk_textract::primitives::Blob::new(png_bytes.clone()))
.build(),
)
.send()
.await?;
// Convert Textract blocks to PDFluent OcrWord list
let words: Vec<OcrWord> = resp
.blocks()
.iter()
.filter(|b| b.block_type() == Some(&aws_sdk_textract::types::BlockType::Word))
.filter_map(|b| {
let bbox = b.geometry()?.bounding_box()?;
let text = b.text()?.to_string();
Some(OcrWord {
text,
// Textract returns fractions of page width/height
left: bbox.left() as f64,
top: bbox.top() as f64,
width: bbox.width() as f64,
height: bbox.height() as f64,
confidence: b.confidence().map(|c| c as f64),
})
})
.collect();
builder.add_page_words(page.index(), words);
}
let opts = OcrLayerOptions::builder()
.text_rendering_mode(pdfluent::ocr::TextRenderingMode::Invisible)
.build();
let searchable = builder.finish(opts)?;
searchable.save("invoice_searchable.pdf")?;
println!("Done.");
Ok(())
}You need PDFluent, the AWS SDK for Rust, tokio for async, and anyhow for error handling.
# Cargo.toml
[dependencies]
pdfluent = "1.0.0-beta.18"
aws-config = { version = "1", features = ["behavior-version-latest"] }
aws-sdk-textract = "1"
tokio = { version = "1", features = ["full"] }
anyhow = "1"Textract uses standard AWS credential resolution. Set environment variables or use an IAM role if running on EC2 or Lambda.
export AWS_ACCESS_KEY_ID=your_key_id
export AWS_SECRET_ACCESS_KEY=your_secret
export AWS_REGION=us-east-1PDFluent detects pages that have no text layer. Only those pages need OCR — pages with existing text are passed through unchanged.
let sdk = Sdk::new()?;
let doc = sdk.open("scanned_invoice.pdf")?;
let scanned: Vec<u32> = doc.pages()
.filter(|p| p.is_image_only())
.map(|p| p.index())
.collect();
println!("{} pages need OCR", scanned.len());Render the page to PNG bytes at 300 DPI and send them directly to Textract. The synchronous DetectDocumentText call works for pages up to 10 MB; use StartDocumentTextDetection for larger documents.
let png_bytes = doc.render_page_to_bytes(page_index, 300)?;
let resp = textract
.detect_document_text()
.document(
aws_sdk_textract::types::Document::builder()
.bytes(aws_sdk_textract::primitives::Blob::new(png_bytes))
.build(),
)
.send()
.await?;For multi-page PDFs over 10 MB, or when you want table and form field detection, use StartDocumentAnalysis. It processes the document asynchronously and returns a JobId you poll until the status is SUCCEEDED.
// Start async job (supports TABLES and FORMS feature types)
let start_resp = textract
.start_document_analysis()
.document_location(
aws_sdk_textract::types::DocumentLocation::builder()
.s3_object(
aws_sdk_textract::types::S3Object::builder()
.bucket("my-bucket")
.name("scanned_invoice.pdf")
.build(),
)
.build(),
)
.feature_types(aws_sdk_textract::types::FeatureType::Tables)
.feature_types(aws_sdk_textract::types::FeatureType::Forms)
.send()
.await?;
let job_id = start_resp.job_id().unwrap();
// Poll until complete
loop {
let status_resp = textract
.get_document_analysis()
.job_id(job_id)
.send()
.await?;
match status_resp.job_status() {
Some(aws_sdk_textract::types::JobStatus::Succeeded) => break,
Some(aws_sdk_textract::types::JobStatus::Failed) => anyhow::bail!("Textract job failed"),
_ => tokio::time::sleep(std::time::Duration::from_secs(2)).await,
}
}Textract returns bounding boxes as fractions of the page (0.0–1.0). PDFluent accepts this format directly in OcrWord. Filter for BlockType::Word to get word-level entries.
let words: Vec<OcrWord> = resp
.blocks()
.iter()
.filter(|b| b.block_type() == Some(&aws_sdk_textract::types::BlockType::Word))
.filter_map(|b| {
let bbox = b.geometry()?.bounding_box()?;
Some(OcrWord {
text: b.text()?.to_string(),
left: bbox.left() as f64,
top: bbox.top() as f64,
width: bbox.width() as f64,
height: bbox.height() as f64,
confidence: b.confidence().map(|c| c as f64),
})
})
.collect();
builder.add_page_words(page_index, words);Call builder.finish() with the layer options to produce a new PDF with the invisible text overlay applied to all processed pages.
let opts = OcrLayerOptions::builder()
.text_rendering_mode(pdfluent::ocr::TextRenderingMode::Invisible)
.conform_to_pdfa2b(true) // optional: archive-safe output
.build();
let searchable = builder.finish(opts)?;
searchable.save("invoice_searchable.pdf")?;Send scanned PDF pages to Azure AI Document Intelligence (formerly Form Recognizer) and write the OCR results back as a searchable text layer using PDFluent.
use pdfluent::{Sdk, ocr::{OcrLayerOptions, OcrWord}};
use reqwest::Client;
use serde_json::Value;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let endpoint = std::env::var("AZURE_FORM_RECOGNIZER_ENDPOINT")?;
let api_key = std::env::var("AZURE_FORM_RECOGNIZER_KEY")?;
let sdk = Sdk::new()?;
let doc = sdk.open("scanned_invoice.pdf")?;
let http = Client::new();
let mut builder = doc.add_ocr_layer();
for page in doc.pages().filter(|p| p.is_image_only()) {
let png_bytes = doc.render_page_to_bytes(page.index(), 300)?;
// Submit image to the prebuilt-read model
let submit_url = format!(
"{endpoint}/formrecognizer/documentModels/prebuilt-read:analyze ?api-version=2023-07-31"
);
let submit_resp = http
.post(&submit_url)
.header("Ocp-Apim-Subscription-Key", &api_key)
.header("Content-Type", "image/png")
.body(png_bytes)
.send()
.await?;
// Get the polling URL from Operation-Location header
let operation_url = submit_resp
.headers()
.get("operation-location")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow::anyhow!("No operation-location header"))?
.to_string();
// Poll until the analysis is complete
let result: Value = loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let poll: Value = http
.get(&operation_url)
.header("Ocp-Apim-Subscription-Key", &api_key)
.send()
.await?
.json()
.await?;
match poll["status"].as_str() {
Some("succeeded") => break poll,
Some("failed") => anyhow::bail!("Azure analysis failed"),
_ => continue,
}
};
let words = extract_words(&result, page.index())?;
builder.add_page_words(page.index(), words);
}
let opts = OcrLayerOptions::builder()
.text_rendering_mode(pdfluent::ocr::TextRenderingMode::Invisible)
.build();
let searchable = builder.finish(opts)?;
searchable.save("invoice_searchable.pdf")?;
println!("Done.");
Ok(())
}You need PDFluent, reqwest for HTTP calls, serde_json, base64, and tokio.
# Cargo.toml
[dependencies]
pdfluent = "1.0.0-beta.18"
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
base64 = "0.22"
tokio = { version = "1", features = ["full"] }
anyhow = "1"Create an Azure AI Document Intelligence resource in the Azure portal. You need the endpoint URL and one of the subscription keys.
export AZURE_FORM_RECOGNIZER_ENDPOINT=https://your-resource.cognitiveservices.azure.com
export AZURE_FORM_RECOGNIZER_KEY=your_subscription_keyPDFluent finds pages with no text content stream. Pages that already have selectable text are left unchanged.
let sdk = Sdk::new()?;
let doc = sdk.open("scanned_invoice.pdf")?;
let scanned: Vec<u32> = doc.pages()
.filter(|p| p.is_image_only())
.map(|p| p.index())
.collect();
println!("{} pages need OCR", scanned.len());Render the page to PNG bytes and POST them to the Document Intelligence analyze endpoint. The prebuilt-read model handles printed and handwritten text. The API responds with 202 Accepted and an Operation-Location header for polling.
let png_bytes = doc.render_page_to_bytes(page_index, 300)?;
let submit_url = format!(
"{endpoint}/formrecognizer/documentModels/prebuilt-read:analyze ?api-version=2023-07-31"
);
let submit_resp = http
.post(&submit_url)
.header("Ocp-Apim-Subscription-Key", &api_key)
.header("Content-Type", "image/png")
.body(png_bytes)
.send()
.await?;
// The polling URL is in the Operation-Location response header
let operation_url = submit_resp
.headers()
.get("operation-location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();Azure Document Intelligence processes requests asynchronously. Poll the operation URL until status is "succeeded" or "failed". A simple 1-second sleep between polls is sufficient for single-page images.
let result: Value = loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let poll: Value = http
.get(&operation_url)
.header("Ocp-Apim-Subscription-Key", &api_key)
.send()
.await?
.json()
.await?;
match poll["status"].as_str() {
Some("succeeded") => break poll,
Some("failed") => anyhow::bail!("Azure analysis failed: {:?}", poll["error"]),
_ => {
// "running" or "notStarted" — keep polling
continue;
}
}
};The result contains pages > words with polygon coordinates. Azure returns polygons as [x0,y0, x1,y1, ...] in points (1/72 inch). Normalize by dividing by the page width and height returned in the same response.
fn extract_words(result: &Value, _page_index: u32) -> anyhow::Result<Vec<OcrWord>> {
let mut words = Vec::new();
let pages = result["analyzeResult"]["pages"].as_array().unwrap_or(&vec![]);
for page in pages {
let page_width = page["width"].as_f64().unwrap_or(1.0);
let page_height = page["height"].as_f64().unwrap_or(1.0);
for word in page["words"].as_array().unwrap_or(&vec![]) {
let text = word["content"].as_str().unwrap_or("").to_string();
if text.is_empty() { continue; }
// polygon is [x0,y0, x1,y1, x2,y2, x3,y3] in points
let poly = word["polygon"].as_array().unwrap_or(&vec![]);
if poly.len() < 8 { continue; }
let x_vals: Vec<f64> = poly.iter().step_by(2)
.filter_map(|v| v.as_f64()).collect();
let y_vals: Vec<f64> = poly.iter().skip(1).step_by(2)
.filter_map(|v| v.as_f64()).collect();
let x_min = x_vals.iter().cloned().fold(f64::INFINITY, f64::min);
let y_min = y_vals.iter().cloned().fold(f64::INFINITY, f64::min);
let x_max = x_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let y_max = y_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
words.push(OcrWord {
text,
// Normalize to 0.0–1.0 fractions
left: x_min / page_width,
top: y_min / page_height,
width: (x_max - x_min) / page_width,
height: (y_max - y_min) / page_height,
confidence: word["confidence"].as_f64(),
});
}
}
Ok(words)
}Add the words to the layer builder for each page, then finish and save. The resulting PDF has invisible text positioned over each word for search and copy.
builder.add_page_words(page_index, words);
// After all pages:
let opts = OcrLayerOptions::builder()
.text_rendering_mode(pdfluent::ocr::TextRenderingMode::Invisible)
.conform_to_pdfa2b(true)
.build();
let searchable = builder.finish(opts)?;
searchable.save("invoice_searchable.pdf")?;Render PDF pages to images with PDFluent, send them to Google Cloud Vision for OCR, and write the results back as an invisible text layer.
use pdfluent::{Sdk, ocr::{OcrLayerOptions, OcrWord}};
use reqwest::Client;
use serde_json::{json, Value};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let api_key = std::env::var("GCP_VISION_API_KEY")?;
let sdk = Sdk::new()?;
let doc = sdk.open("scanned_contract.pdf")?;
let http = Client::new();
let mut builder = doc.add_ocr_layer();
for page in doc.pages().filter(|p| p.is_image_only()) {
// Render page to PNG bytes at 300 DPI
let png_bytes = doc.render_page_to_bytes(page.index(), 300)?;
let b64 = BASE64.encode(&png_bytes);
let body = json!({
"requests": [{
"image": { "content": b64 },
"features": [{ "type": "DOCUMENT_TEXT_DETECTION" }]
}]
});
let resp: Value = http
.post(format!(
"https://vision.googleapis.com/v1/images:annotate?key={api_key}"
))
.json(&body)
.send()
.await?
.json()
.await?;
let words = extract_words(&resp, page.index())?;
builder.add_page_words(page.index(), words);
}
let opts = OcrLayerOptions::builder()
.text_rendering_mode(pdfluent::ocr::TextRenderingMode::Invisible)
.build();
let searchable = builder.finish(opts)?;
searchable.save("contract_searchable.pdf")?;
println!("Done.");
Ok(())
}You need PDFluent, reqwest for the Vision API call, serde_json, base64, and tokio.
# Cargo.toml
[dependencies]
pdfluent = "1.0.0-beta.18"
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
base64 = "0.22"
tokio = { version = "1", features = ["full"] }
anyhow = "1"The quickest approach for testing is an API key. For production, use a service account with the Cloud Vision API role and the Application Default Credentials flow.
# Option 1: API key (development/testing)
export GCP_VISION_API_KEY=AIzaSy...
# Option 2: Service account (production)
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
# Then use the OAuth2 token endpoint or the google-cloud Rust cratesPDFluent detects pages with no text layer. Pages that already have selectable text are skipped.
let sdk = Sdk::new()?;
let doc = sdk.open("scanned_contract.pdf")?;
let scanned_count = doc.pages().filter(|p| p.is_image_only()).count();
println!("{} of {} pages are scanned", scanned_count, doc.page_count());Use DOCUMENT_TEXT_DETECTION rather than TEXT_DETECTION. The DOCUMENT variant returns symbols grouped into words, lines, and paragraphs, which gives better word-level bounding boxes for the PDFluent overlay.
let png_bytes = doc.render_page_to_bytes(page.index(), 300)?;
let b64 = BASE64.encode(&png_bytes);
let body = json!({
"requests": [{
"image": { "content": b64 },
"features": [{ "type": "DOCUMENT_TEXT_DETECTION" }]
}]
});
let resp: Value = http
.post(format!("https://vision.googleapis.com/v1/images:annotate?key={api_key}"))
.json(&body)
.send()
.await?
.json()
.await?;The DOCUMENT_TEXT_DETECTION response returns a fullTextAnnotation with pages > blocks > paragraphs > words. Each word has a boundingBox with normalizedVertices. PDFluent needs the bounding box as left/top/width/height fractions.
fn extract_words(resp: &Value, _page_index: u32) -> anyhow::Result<Vec<OcrWord>> {
let mut words = Vec::new();
let annotation = &resp["responses"][0]["fullTextAnnotation"];
let pages = annotation["pages"].as_array().unwrap_or(&vec![]);
for page in pages {
for block in page["blocks"].as_array().unwrap_or(&vec![]) {
for para in block["paragraphs"].as_array().unwrap_or(&vec![]) {
for word in para["words"].as_array().unwrap_or(&vec![]) {
// Reconstruct word text from symbols
let text: String = word["symbols"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|s| s["text"].as_str())
.collect();
if text.is_empty() { continue; }
// normalizedVertices are fractions of image width/height
let verts = &word["boundingBox"]["normalizedVertices"];
if let (Some(v0), Some(v2)) = (verts.get(0), verts.get(2)) {
let left = v0["x"].as_f64().unwrap_or(0.0);
let top = v0["y"].as_f64().unwrap_or(0.0);
let right = v2["x"].as_f64().unwrap_or(0.0);
let bottom = v2["y"].as_f64().unwrap_or(0.0);
words.push(OcrWord {
text,
left,
top,
width: right - left,
height: bottom - top,
confidence: word["confidence"].as_f64(),
});
}
}
}
}
}
Ok(words)
}Pass the collected words to the layer builder, call finish(), and save. The text is invisible at render time but fully searchable and copyable.
builder.add_page_words(page.index(), words);
// After processing all pages:
let opts = OcrLayerOptions::builder()
.text_rendering_mode(pdfluent::ocr::TextRenderingMode::Invisible)
.conform_to_pdfa2b(true) // optional: PDF/A-2b for archival
.build();
let searchable = builder.finish(opts)?;
searchable.save("contract_searchable.pdf")?;Before running text extraction, check whether the PDF was digitally created or is a scan of a physical document.
use pdfluent::PdfDocument;
fn main() -> pdfluent::Result<()> {
let doc = PdfDocument::open("file.pdf")?;
let text = doc.extract_text()?;
if text.trim().len() < 20 {
println!("likely scanned (no text layer)");
}
Ok(())
}Use doc.pages() to get an iterator over all pages. Each Page gives you access to content stream analysis.
use pdfluent::PdfDocument;
let doc = PdfDocument::open("document.pdf")?;
for i in 0..doc.page_count() {
let has_text = !doc.page(i)?.text()?.trim().is_empty();
println!("Page {}: selectable text = {}", i + 1, has_text);
}has_selectable_text() returns true if the page content stream contains any text operators. has_raster_images() returns true if the page contains XObject images.
for i in 0..doc.page_count() {
let text = doc.page(i)?.text()?;
if text.trim().is_empty() {
println!("Page {} appears to be a scan (no selectable text).", i + 1);
}
}Count pages without text. A score above 80% is a strong indicator that the document is a scan or a mix.
let total = doc.page_count() as f32;
let mut no_text = 0f32;
for i in 0..doc.page_count() {
if doc.page(i)?.text()?.trim().is_empty() {
no_text += 1.0;
}
}
let scan_ratio = no_text / total;
println!("Scan ratio: {:.0}%", scan_ratio * 100.0);
if scan_ratio > 0.8 {
println!("Likely a scanned document. Consider running OCR.");
}Some scanned PDFs have a hidden text layer added by OCR software. Use has_invisible_text() to detect this.
// An image-only PDF yields little or no extractable text
let extracted = doc.extract_text()?;
if extracted.trim().is_empty() {
println!("No extractable text layer - the document is image-only.");
}