Make scanned PDFs searchable and detect text

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.

Add PDFluent to Cargo.toml

No additional features are required. Page inspection is part of the base crate.

toml
# Cargo.toml
[dependencies]
pdfluent = "1.0.0-beta.18"

Make scanned PDFs searchable with AWS Textract

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.

rust
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(())
}
  1. Add dependencies

    You need PDFluent, the AWS SDK for Rust, tokio for async, and anyhow for error handling.

    rust
    # 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"
  2. Configure AWS credentials

    Textract uses standard AWS credential resolution. Set environment variables or use an IAM role if running on EC2 or Lambda.

    rust
    export AWS_ACCESS_KEY_ID=your_key_id
    export AWS_SECRET_ACCESS_KEY=your_secret
    export AWS_REGION=us-east-1
  3. Open the PDF and identify scanned pages

    PDFluent detects pages that have no text layer. Only those pages need OCR — pages with existing text are passed through unchanged.

    rust
    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());
  4. Render each page and call Textract DetectDocumentText

    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.

    rust
    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?;
  5. Use the async job API for large documents

    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.

    rust
    // 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,
        }
    }
  6. Convert Textract WORD blocks to PDFluent OcrWord list

    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.

    rust
    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);
  7. Finish and save the searchable PDF

    Call builder.finish() with the layer options to produce a new PDF with the invisible text overlay applied to all processed pages.

    rust
    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")?;
  • Textract DetectDocumentText supports PNG and JPEG. PDFluent renders to PNG by default.
  • The synchronous API accepts documents up to 10 MB. For larger pages, reduce DPI (150 DPI is usually sufficient for printed text) or use the S3-backed async API.
  • Textract bounding boxes are normalized fractions of image width/height. PDFluent's add_page_words() expects the same format, so no coordinate conversion is needed.
  • Textract charges per page. As of 2024: $0.0015 per page for DetectDocumentText, $0.015 per page for AnalyzeDocument with FORMS or TABLES.
  • If you only need text extraction (not table structure), DetectDocumentText is 10x cheaper than AnalyzeDocument.

Make scanned PDFs searchable with Azure Document Intelligence

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.

rust
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(())
}
  1. Add dependencies

    You need PDFluent, reqwest for HTTP calls, serde_json, base64, and tokio.

    rust
    # 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"
  2. Get your Azure Document Intelligence credentials

    Create an Azure AI Document Intelligence resource in the Azure portal. You need the endpoint URL and one of the subscription keys.

    rust
    export AZURE_FORM_RECOGNIZER_ENDPOINT=https://your-resource.cognitiveservices.azure.com
    export AZURE_FORM_RECOGNIZER_KEY=your_subscription_key
  3. Open the PDF and identify scanned pages

    PDFluent finds pages with no text content stream. Pages that already have selectable text are left unchanged.

    rust
    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());
  4. Submit a page image to prebuilt-read

    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.

    rust
    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();
  5. Poll the Operation-Location URL until succeeded

    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.

    rust
    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;
            }
        }
    };
  6. Parse the result and convert word polygons to OcrWord entries

    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.

    rust
    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)
    }
  7. Write the text layer and save

    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.

    rust
    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")?;
  • The prebuilt-read model supports printed and handwritten text in 164 languages as of API version 2023-07-31.
  • Azure Document Intelligence pricing (as of 2024): $1.50 per 1,000 pages for the Read model. The first 500 pages per month are free.
  • For invoice field extraction (total, vendor name, line items), switch from prebuilt-read to prebuilt-invoice. The polling pattern is identical; only the model name in the URL changes.
  • Azure returns polygon coordinates in the unit specified by the unit field in the page object — typically "inch" or "pixel". The width and height fields are in the same unit. Always divide by page width/height to normalize.
  • For multi-page PDFs, you can send the entire PDF base64-encoded to the endpoint instead of individual page images. The response pages array will contain one entry per PDF page.

Make scanned PDFs searchable with Google Cloud Vision

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.

rust
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(())
}
  1. Add dependencies

    You need PDFluent, reqwest for the Vision API call, serde_json, base64, and tokio.

    rust
    # 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"
  2. Set up Google Cloud Vision authentication

    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.

    rust
    # 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 crates
  3. Open the PDF and identify scanned pages

    PDFluent detects pages with no text layer. Pages that already have selectable text are skipped.

    rust
    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());
  4. Render each page and call DOCUMENT_TEXT_DETECTION

    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.

    rust
    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?;
  5. Parse the Vision API response into OcrWord entries

    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.

    rust
    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)
    }
  6. Write the text layer and save the searchable PDF

    Pass the collected words to the layer builder, call finish(), and save. The text is invisible at render time but fully searchable and copyable.

    rust
    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")?;
  • DOCUMENT_TEXT_DETECTION is better than TEXT_DETECTION for dense text. It returns a layout-aware annotation with word groupings.
  • Google Cloud Vision pricing (as of 2024): first 1,000 units/month free, then $1.50 per 1,000 images for text detection.
  • normalizedVertices are always available for DOCUMENT_TEXT_DETECTION. Regular boundingPoly vertices are in raw pixels and require dividing by image width/height to normalize.
  • If the image is rotated, Vision returns rotated bounding boxes. PDFluent expects axis-aligned boxes. For rotated documents, normalize the rotation with doc.rotate_page() before rendering.
  • For long PDFs, batch requests: the Vision API annotate endpoint accepts up to 16 images per request in a single HTTP call.

Detect whether a PDF is scanned or contains selectable text

Before running text extraction, check whether the PDF was digitally created or is a scan of a physical document.

rust
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(())
}
  1. Open the document and iterate pages

    Use doc.pages() to get an iterator over all pages. Each Page gives you access to content stream analysis.

    rust
    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);
    }
  2. Check for selectable text and raster images

    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.

    rust
    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);
        }
    }
  3. Get a document-level scan score

    Count pages without text. A score above 80% is a strong indicator that the document is a scan or a mix.

    rust
    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.");
    }
  4. Check whether text is hidden (OCR layer)

    Some scanned PDFs have a hidden text layer added by OCR software. Use has_invisible_text() to detect this.

    rust
    // 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.");
    }
  • A page with a background image and no text operators is the most common scan pattern. This method has low false-positive rates.
  • PDFs created by scanning software like Adobe Scan often include a hidden OCR text layer. has_invisible_text() detects this.
  • Vector PDFs with no images and no text (diagrams, flowcharts) return false for both flags. Use has_vector_content() for those.
  • Text that is covered by a white rectangle may still be detected as selectable text. Pixel-level analysis requires rasterizing the page.