Run PDFluent on Lambda, Docker, WASM, and in parallel

This guide shows developers how to deploy the PDFluent SDK for four specific tasks: serverless functions, containerised services, browser-based apps, and high-volume batch processing.

Run PDF processing on AWS Lambda with Rust

PDFluent has no native dependencies, which makes it ideal for Lambda. Cold start times are under 50 ms for a typical PDF handler.

rust
use lambda_runtime::{service_fn, LambdaEvent, Error};
use aws_sdk_s3::Client as S3Client;
use pdfluent::PdfDocument;
use serde_json::Value;

#[tokio::main]
async fn main() -> Result<(), Error> {
    lambda_runtime::run(service_fn(handler)).await
}

async fn handler(event: LambdaEvent<Value>) -> Result<Value, Error> {
    let bucket = event.payload["bucket"].as_str().unwrap_or_default();
    let key    = event.payload["key"].as_str().unwrap_or_default();

    let config = aws_config::load_from_env().await;
    let s3 = S3Client::new(&config);

    let resp = s3.get_object().bucket(bucket).key(key).send().await?;
    let bytes = resp.body.collect().await?.into_bytes();

    let doc = PdfDocument::from_bytes(&bytes)?;
    let page_count = doc.page_count();
    let text = doc.text()?;

    Ok(serde_json::json!({
        "pages": page_count,
        "chars": text.len()
    }))
}
  1. Set up your Cargo project for Lambda

    Use the lambda_runtime crate from AWS. Build a binary named bootstrap, which is the required name for Lambda custom runtimes.

    rust
    # Cargo.toml
    [package]
    name = "pdf-lambda"
    version = "1.0.0-beta.8"
    edition = "2021"
    
    [[bin]]
    name = "bootstrap"
    path = "src/main.rs"
    
    [dependencies]
    pdfluent = "1.0.0-beta.18"
    lambda_runtime = "0.11"
    aws-config = { version = "1", features = ["behavior-version-latest"] }
    aws-sdk-s3 = "1"
    tokio = { version = "1", features = ["full"] }
    serde_json = "1"
  2. Cross-compile for Amazon Linux

    Lambda runs on Amazon Linux 2 (x86_64 or arm64). Use cargo-lambda to cross-compile without a Linux machine.

    rust
    # Install cargo-lambda
    cargo install cargo-lambda
    
    # Build for x86_64 Lambda
    cargo lambda build --release --target x86_64-unknown-linux-musl
    
    # Or build for arm64 Lambda (Graviton2, cheaper)
    cargo lambda build --release --target aarch64-unknown-linux-musl
  3. Write the Lambda handler

    Read the S3 bucket and key from the event payload. Download the PDF bytes from S3 and pass them to PdfDocument::from_bytes.

    rust
    async fn handler(event: LambdaEvent<Value>) -> Result<Value, Error> {
        let bucket = event.payload["bucket"].as_str().unwrap_or_default();
        let key    = event.payload["key"].as_str().unwrap_or_default();
    
        let config = aws_config::load_from_env().await;
        let s3 = S3Client::new(&config);
    
        let resp = s3.get_object()
            .bucket(bucket)
            .key(key)
            .send()
            .await?;
    
        let bytes = resp.body.collect().await?.into_bytes();
        let doc = PdfDocument::from_bytes(&bytes)?;
        // ... process the document
        Ok(serde_json::json!({ "pages": doc.page_count() }))
    }
  4. Deploy with cargo-lambda

    cargo lambda deploy uploads the binary as a Lambda function with the provided.al2 runtime.

    rust
    cargo lambda deploy \
      --region eu-west-1 \
      --memory 512 \
      --timeout 30 \
      pdf-lambda
  5. Set the Lambda execution role

    The function needs GetObject permission on the S3 bucket. Attach an inline policy or a managed policy to the execution role.

    rust
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": ["s3:GetObject"],
          "Resource": "arn:aws:s3:::my-pdf-bucket/*"
        }
      ]
    }
  • PDFluent links statically via musl. The final bootstrap binary is about 4-6 MB, well under the Lambda 50 MB zipped limit.
  • Set Lambda memory to at least 256 MB for small PDFs. Large PDFs (100+ pages) may need 512-1024 MB.
  • Lambda /tmp storage is 512 MB by default (up to 10 GB configurable). Write temporary output there if you need to save before uploading to S3.
  • Cold start for a musl-linked Rust binary is typically 20-50 ms, significantly faster than JVM or Python-based PDF libraries.

Run PDFluent in a Docker container

PDFluent has no native dependencies. The Docker image is small and requires no additional apt packages.

rust
# Dockerfile
FROM rust:1.78-slim AS builder
WORKDIR /app
COPY . .
RUN cargo build --release

FROM debian:bookworm-slim
COPY --from=builder /app/target/release/pdf-processor /usr/local/bin/
ENTRYPOINT ["pdf-processor"]
  1. Create a minimal Rust project

    Your binary reads PDF bytes from stdin or a file path passed as an argument and writes results to stdout.

    rust
    # Cargo.toml
    [package]
    name = "pdf-processor"
    version = "1.0.0-beta.8"
    edition = "2021"
    
    [dependencies]
    pdfluent = "1.0.0-beta.18"
  2. Write a simple PDF processing binary

    Accept the file path as a command-line argument. Process the document and print results to stdout.

    rust
    // src/main.rs
    use pdfluent::PdfDocument;
    use std::env;
    
    fn main() -> pdfluent::Result<()> {
        let path = env::args().nth(1).expect("usage: pdf-processor <file.pdf>");
        let doc = PdfDocument::open(&path)?;
    
        println!("pages: {}", doc.page_count());
        println!("title: {:?}", doc.metadata().title);
        println!("chars: {}", doc.text()?.len());
    
        Ok(())
    }
  3. Write a multi-stage Dockerfile

    The builder stage compiles the binary. The final stage copies only the binary into a minimal debian image. No Rust toolchain ships in the final image.

    rust
    # Dockerfile
    FROM rust:1.78-slim AS builder
    WORKDIR /app
    
    # Cache dependencies first
    COPY Cargo.toml Cargo.lock ./
    RUN mkdir src && echo "fn main(){}" > src/main.rs
    RUN cargo build --release
    RUN rm -f target/release/deps/pdf_processor*
    
    # Build the real binary
    COPY src ./src
    RUN cargo build --release
    
    # Final image
    FROM debian:bookworm-slim
    RUN apt-get update && apt-get install -y --no-install-recommends     ca-certificates && rm -rf /var/lib/apt/lists/*
    
    COPY --from=builder /app/target/release/pdf-processor /usr/local/bin/
    ENTRYPOINT ["pdf-processor"]
  4. Build and test the image locally

    Build the image and run it with a test PDF mounted as a volume.

    rust
    docker build -t pdf-processor:latest .
    
    # Run with a local PDF
    docker run --rm   -v "$(pwd)/samples:/data"   pdf-processor:latest   /data/test.pdf
  5. Use a musl target for a fully static binary

    If your final base image is scratch or alpine, build a statically linked musl binary. This eliminates the glibc version dependency.

    rust
    # Dockerfile (scratch variant)
    FROM rust:1.78-slim AS builder
    RUN rustup target add x86_64-unknown-linux-musl
    RUN apt-get update && apt-get install -y musl-tools
    WORKDIR /app
    COPY . .
    RUN cargo build --release --target x86_64-unknown-linux-musl
    
    FROM scratch
    COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/pdf-processor /
    ENTRYPOINT ["/pdf-processor"]
  • The debian:bookworm-slim final image is about 80 MB. The scratch variant is about 5 MB (binary only).
  • PDFluent does not call fontconfig, freetype, or any system font libraries. Font data is embedded in the crate.
  • For processing PDFs that require CJK fonts, ensure the font bytes are compiled into the binary or mounted as a volume.
  • Use COPY --chown in the Dockerfile to run the binary as a non-root user in production.

Compile PDFluent to WASM and use it in the browser

Run PDF processing entirely client-side. No server round-trip, no file upload, no privacy risk.

rust
// src/lib.rs
use wasm_bindgen::prelude::*;
use pdfluent::PdfDocument;

#[wasm_bindgen]
pub fn page_count(bytes: &[u8]) -> Result<u32, JsValue> {
    let doc = PdfDocument::from_bytes(bytes)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    Ok(doc.page_count())
}

#[wasm_bindgen]
pub fn extract_text(bytes: &[u8]) -> Result<String, JsValue> {
    let doc = PdfDocument::from_bytes(bytes)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    doc.text()
        .map_err(|e| JsValue::from_str(&e.to_string()))
}
  1. Add the wasm feature and wasm-bindgen

    PDFluent has a wasm feature that disables std-only dependencies. wasm-bindgen generates the JS glue code.

    rust
    # Cargo.toml
    [package]
    name = "pdf-wasm"
    version = "0.1.0"
    edition = "2021"
    
    [lib]
    crate-type = ["cdylib"]
    
    [dependencies]
    pdfluent = { version = "0.9", features = ["wasm"] }
    wasm-bindgen = "0.2"
  2. Expose Rust functions to JavaScript

    Mark each function with #[wasm_bindgen]. Functions that receive PDF bytes take &[u8]. Return JsValue errors so JavaScript can catch them.

    rust
    use wasm_bindgen::prelude::*;
    use pdfluent::PdfDocument;
    
    #[wasm_bindgen]
    pub fn page_count(bytes: &[u8]) -> Result<u32, JsValue> {
        let doc = PdfDocument::from_bytes(bytes)
            .map_err(|e| JsValue::from_str(&e.to_string()))?;
        Ok(doc.page_count())
    }
  3. Build the WASM module

    Install wasm-pack and run the build command. This produces a pkg/ directory with .wasm and JS binding files.

    rust
    # Install wasm-pack
    cargo install wasm-pack
    
    # Build for the browser (ES module output)
    wasm-pack build --target web --out-dir pkg
    
    # Or build for Node.js
    wasm-pack build --target nodejs --out-dir pkg
  4. Import the module in JavaScript

    Load the WASM module asynchronously, then pass ArrayBuffer data from a file input or fetch.

    rust
    import init, { page_count, extract_text } from './pkg/pdf_wasm.js';
    
    await init();
    
    const input = document.getElementById('file-input');
    input.addEventListener('change', async (e) => {
        const file = e.target.files[0];
        const buffer = await file.arrayBuffer();
        const bytes = new Uint8Array(buffer);
    
        const pages = page_count(bytes);
        const text  = extract_text(bytes);
    
        console.log(`Pages: ${pages}`);
        console.log(`Text: ${text.slice(0, 200)}`);
    });
  5. Serve with correct MIME type

    Browsers require .wasm files to be served with Content-Type: application/wasm. Most dev servers handle this automatically. In production, verify your CDN or server config.

    rust
    # Vite, webpack, and Parcel all handle WASM automatically.
    # For a plain nginx config:
    # types {
    #     application/wasm wasm;
    # }
    
    # Quick local test with wasm-pack's built-in server:
    npx serve .
  • The WASM binary for a typical PDFluent build is around 1.5-2.5 MB. Use wasm-opt from binaryen to reduce it by 15-30%.
  • WASM runs in a single thread by default. For parallel operations in the browser, use Web Workers and pass PDF bytes via postMessage.
  • PDFluent in WASM mode disables features that require OS calls, such as file I/O and system fonts. All input must come through the &[u8] parameter.
  • SharedArrayBuffer (required for wasm-bindgen threads) needs the COOP and COEP headers. If you do not need threads, standard ArrayBuffer works everywhere.

Process thousands of PDFs in parallel with Rust

Use rayon and PDFluent together to process a folder of PDFs across all CPU cores. No thread pool setup required.

rust
use rayon::prelude::*;
use std::fs;
use pdfluent::PdfDocument;

fn main() -> anyhow::Result<()> {
    let files: Vec<_> = fs::read_dir("./input")?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.extension().map_or(false, |e| e == "pdf"))
        .collect();

    files.par_iter().for_each(|path| {
        match process_file(path) {
            Ok(_) => println!("OK  {:?}", path.file_name().unwrap()),
            Err(e) => eprintln!("ERR {:?}: {}", path.file_name().unwrap(), e),
        }
    });

    Ok(())
}

fn process_file(path: &std::path::Path) -> pdfluent::Result<()> {
    let doc = PdfDocument::open(path)?;
    let text = doc.text()?;
    let out = path.with_extension("txt");
    fs::write(out, text)?;
    Ok(())
}
  1. Add PDFluent and rayon to Cargo.toml

    rayon provides a parallel iterator that distributes work across all available CPU cores automatically.

    rust
    # Cargo.toml
    [dependencies]
    pdfluent = "1.0.0-beta.18"
    rayon = "1.10"
    anyhow = "1"
  2. Collect the list of PDF files

    Use std::fs::read_dir to walk the input directory. Filter by extension to skip non-PDF files.

    rust
    use std::fs;
    use std::path::PathBuf;
    
    let files: Vec<PathBuf> = fs::read_dir("./input")?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.extension().map_or(false, |ext| ext == "pdf"))
        .collect();
    
    println!("Found {} PDF files", files.len());
  3. Process files in parallel with rayon

    Replace .iter() with .par_iter() to run each file on a separate thread. PdfDocument::open is Send, so it works safely across rayon threads.

    rust
    use rayon::prelude::*;
    
    files.par_iter().for_each(|path| {
        match process_file(path) {
            Ok(_) => println!("OK  {}", path.display()),
            Err(e) => eprintln!("ERR {}: {}", path.display(), e),
        }
    });
  4. Write the per-file processing function

    Keep the function focused on one document. Open, process, save. Errors are returned and logged by the caller.

    rust
    use pdfluent::{PdfDocument, WatermarkOptions};
    
    fn process_file(path: &std::path::Path) -> pdfluent::Result<()> {
        let mut doc = PdfDocument::open(path)?;
    
        // Example: stamp a watermark across the document
        doc.add_watermark("CONFIDENTIAL", WatermarkOptions::centered())?;
    
        let out_path = std::path::Path::new("./output")
            .join(path.file_name().unwrap());
        doc.save(out_path)?;
        Ok(())
    }
  5. Collect results for a summary report

    Use par_iter().map() instead of par_iter().for_each() when you need to collect success and error counts.

    rust
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;
    
    let ok_count = Arc::new(AtomicUsize::new(0));
    let err_count = Arc::new(AtomicUsize::new(0));
    
    files.par_iter().for_each(|path| {
        let ok = ok_count.clone();
        let err = err_count.clone();
        match process_file(path) {
            Ok(_) => { ok.fetch_add(1, Ordering::Relaxed); }
            Err(e) => {
                eprintln!("ERR {}: {}", path.display(), e);
                err.fetch_add(1, Ordering::Relaxed);
            }
        }
    });
    
    println!(
        "Done. {} ok, {} errors",
        ok_count.load(Ordering::Relaxed),
        err_count.load(Ordering::Relaxed)
    );
  • rayon uses one thread per logical CPU by default. Set RAYON_NUM_THREADS=4 to cap concurrency, which is useful on memory-constrained servers.
  • PdfDocument::open reads the full document into memory. For very large files (>500 MB), set RAYON_NUM_THREADS equal to the number of files you can fit in RAM simultaneously.
  • Output directory must exist before you start. Create it with fs::create_dir_all("./output")? near the top of main().
  • For recursive directory traversal use the walkdir crate alongside rayon::iter::ParallelBridge.