Elixir bindings for pdfium — Google's
Chromium PDF engine — via the Rust
pdfium-render crate, shipped as a
precompiled NIF with rustler_precompiled.
No Rust toolchain. No separately-installed pdfium. Add the dep and go.
A PDF read, extract, and authoring toolkit. Open, render, extract and search text, inspect metadata and structure, assemble or rotate pages, create PDFs, draw text/shapes/images, author annotations, and save the result.
Why
The native PDF-rendering gap in Elixir: Vix/Image (libvips) ships without PDF
support, so rasterizing a PDF normally means building libvips from source with
poppler/pdfium. ExPdfium fills that gap with a precompiled pdfium binding —
rendering, plus text extraction and metadata that pure-libvips can't give you.
This is a ground-up Rust rewrite of the older
gmile/pdfium C++ NIF, adopting the
rustler_precompiled release model so every supported OTP (27/28/29+) gets a
precompiled binary from one build matrix.
Installation
def deps do
[{:ex_pdfium, "~> 0.4"}]
endUsage
{:ok, doc} = ExPdfium.open("file.pdf") # or open(<<"%PDF...">> = bytes)
{:ok, n} = ExPdfium.page_count(doc)
:ok = ExPdfium.close(doc)
# Encrypted documents:
{:ok, doc} = ExPdfium.open("secret.pdf", password: "hunter2")Documents are closed automatically on garbage collection; call
ExPdfium.close/1 to release pdfium memory early. open/2 returns
{:error, reason} for problems like :enoent, :invalid_pdf, or
:password_error.
Rendering
{:ok, %ExPdfium.Bitmap{data: data, width: w, height: h}} =
ExPdfium.render_page(doc, 0, dpi: 300) # or scale:, or width:/height:
# Hand the raw RGBA buffer straight to Vix/Image:
{:ok, image} = Vix.Vips.Image.new_from_binary(data, w, h, 4, :VIPS_FORMAT_UCHAR)
Image.write(image, "page.png")render_page/3 takes :dpi / :scale / :width / :height for sizing,
format: :rgba | :bgra, background: :white | :transparent, plus grayscale:,
annotations:, and form_fields: toggles. The bitmap is an uncompressed 4-channel
buffer (width * height * 4 bytes).
# Suppress the annotation overlay, in grayscale:
{:ok, bmp} = ExPdfium.render_page(doc, 0, dpi: 150, grayscale: true, annotations: false)
# One small bitmap per page (defaults to width 200):
{:ok, thumbs} = ExPdfium.thumbnails(doc, width: 160)Text & search
{:ok, text} = ExPdfium.extract_text(doc, 0) # one page
{:ok, text} = ExPdfium.extract_text(doc) # whole document
# Text runs with bounding boxes (PDF points, origin bottom-left):
{:ok, segments} = ExPdfium.text_segments(doc, 0)
# => [%{text: "Hello", bounds: %{left: 41.9, bottom: 115.2, right: 89.0, top: 137.5}}, ...]
# Per-character boxes, font size + baseline origin — the primitive for layout
# analysis (line/word grouping, column detection), where runs are too coarse:
{:ok, chars} = ExPdfium.chars(doc, 0)
# => [%{char: "H", bounds: %{...}, font_size: 12.0, origin: %{x: 41.9, y: 115.2}}, ...]
# origin.y is the text baseline — the stable anchor for clustering glyphs into lines.
# Opt in to best-effort font style (preserve bold/italic emphasis through translation):
{:ok, styled} = ExPdfium.chars(doc, 0, style: true)
# => [%{char: "H", ..., style: %{font_name: "Helvetica", weight: 400, bold?: false,
# italic?: false, serif?: false, fixed_pitch?: false}}, ...]
# Search a page (case-insensitive by default):
{:ok, matches} = ExPdfium.search_text(doc, 0, "invoice", match_case: false)
# => [%{text: "Invoice", rects: [%{left: ..., bottom: ..., right: ..., top: ...}]}, ...]Every spatial result above is in PDF points (origin bottom-left, y-up). To
overlay a box on a page you rastered at some DPI, bounds_to_pixels/3 does the
scale and the y-flip (the step everyone forgets):
{:ok, %{height: h}} = ExPdfium.page_info(doc, 0)
ExPdfium.bounds_to_pixels(hd(segments).bounds, h, 150)
# => %{left: 87.3, top: 197.9, right: 185.4, bottom: 244.4} # raster pixels @150dpiMetadata, geometry & permissions
{:ok, meta} = ExPdfium.metadata(doc)
# => %{title: "…", author: "…", creation_date: "D:…", producer: "…",
# version: "1.7", page_count: 12, page_mode: :none, ...} # /Info + doc properties
# PDF date strings → a UTC DateTime:
{:ok, ~U[2021-08-11 23:47:58Z]} = ExPdfium.parse_pdf_date(meta.creation_date)
{:ok, info} = ExPdfium.page_info(doc, 0)
# => %{width: 612.0, height: 792.0, rotation: 0, label: nil,
# boxes: %{media: %{left: 0.0, bottom: 0.0, right: 612.0, top: 792.0},
# crop: nil, bleed: nil, trim: nil, art: nil}} # non-media boxes often nil
{:ok, perms} = ExPdfium.permissions(doc)
# => %{print_high_quality: true, extract_text_and_graphics: true, modify_content: true, ...}Structure & navigation
{:ok, tree} = ExPdfium.outline(doc) # bookmark tree
# => [%{title: "Chapter 1", page: 0, children: [%{title: "1.1", page: 0, children: []}]}, ...]
{:ok, links} = ExPdfium.links(doc, 0) # links on a page
# => [%{bounds: %{...}, uri: "https://example.com", page: nil},
# %{bounds: %{...}, uri: nil, page: 1}] # internal link to page 1
{:ok, files} = ExPdfium.attachments(doc) # => [%{index: 0, name: "note.txt", size: 25}]
{:ok, bytes} = ExPdfium.attachment_data(doc, 0)Forms & annotations (read)
{:ok, :acrobat} = ExPdfium.form_type(doc) # :none | :acrobat | :xfa_full | :xfa_foreground
{:ok, fields} = ExPdfium.form_fields(doc) # AcroForm fields, one per widget
# => [%{name: "full_name", type: :text, value: "Ada Lovelace", checked: nil,
# read_only: false, required: false, page: 0, bounds: %{...}},
# %{name: "subscribe", type: :checkbox, value: "Yes", checked: true, ...}]
{:ok, anns} = ExPdfium.annotations(doc, 0) # annotations on a page (markup + widgets)
# => [%{type: :highlight, contents: "Important", bounds: %{...}, name: nil,
# hidden: false, printed: false}, ...]XFA form data needs a V8-enabled pdfium build, which is not shipped;
:xfa_fulldocuments may expose an empty or partial AcroForm view.
Images & page objects
# What's on the page, typed, with bounds:
{:ok, objects} = ExPdfium.page_objects(doc, 0)
# => [%{index: 0, type: :text, bounds: %{...}},
# %{index: 2, type: :image, bounds: %{...}}, ...] # :text|:path|:image|:shading|:form
# The image objects, and how they're stored:
{:ok, images} = ExPdfium.images(doc, 0)
# => [%{index: 2, width: 800, height: 600, bits_per_pixel: 24, filters: ["DCTDecode"],
# bounds: %{...}, matrix: %{a: 800.0, b: 0.0, c: 0.0, d: 600.0, e: 40.0, f: 100.0}}]
# Decoded pixels — native pdfium channel order (:gray | :bgr | :bgrx | :bgra):
{:ok, bmp} = ExPdfium.image_data(doc, 0, 2)
# Hand it to Vix without minding the format — to_vix/1 sets the band count and
# swaps BGR→RGB for you (needs the optional :vix dep):
{:ok, img} = ExPdfium.Bitmap.to_vix(bmp)
:ok = Vix.Vips.Image.write_to_file(img, "image.png")
# …or the original encoded stream (a DCTDecode image IS a .jpg):
{:ok, jpg} = ExPdfium.image_raw_data(doc, 0, 2)The :matrix on each object/image is its [a b c d e f] transform, in the page's
unrotated content space. For an image it maps the unit square onto the page, so
you can recover the transform baked into the object (scale, plus any object-level
rotation/flip) without re-rendering. For the as-displayed orientation — e.g. a
scanned page bound for OCR — it must be composed with the page-level /Rotate
(which the content-space matrix does not carry):
# The composed content→display transform (PDF y-up space):
{:ok, m} = ExPdfium.object_display_matrix(doc, 0, 2)
# => %{a: ..., b: ..., c: ..., d: ..., e: ..., f: ...}
# …or, for a top-left-origin raster library, the clockwise rotation it wants:
{:ok, raw} = ExPdfium.image_raw_data(doc, 0, 2)
{:ok, deg} = ExPdfium.object_display_rotation(doc, 0, 2) # e.g. 270.0
{:ok, img} = Image.from_binary(raw)
{:ok, upright} = Image.rotate(img, deg) # Vips clockwise; upright as displayedExPdfium hands you the transform as data — it doesn't rotate pixels for you (that belongs in your image pipeline, e.g. Vix).
Handedness: the matrix is PDF space (origin bottom-left, y-up). Raster libraries (Vix/libvips, Pillow, ImageMagick) are y-down, which inverts the rotation sense — an angle read off the matrix and applied directly is 180° wrong on 90°/270° pages (and looks fine on unrotated ones). Use
object_display_rotation/3, which returns the clockwise degrees already in raster convention, or negate the matrix angle yourself.
Writing — page assembly
# Merge, rotate, and save. In-place ops return {:ok, doc} (the same handle), so
# they thread through `with`:
with {:ok, doc} <- ExPdfium.open("a.pdf"),
{:ok, other} <- ExPdfium.open("b.pdf"),
{:ok, doc} <- ExPdfium.append(doc, other), # merge b's pages onto a
{:ok, doc} <- ExPdfium.delete_pages(doc, 2..3), # drop a page or a range
{:ok, doc} <- ExPdfium.rotate_page(doc, 0, 90), # 0 | 90 | 180 | 270
:ok <- ExPdfium.save_to_file(doc, "merged.pdf") do
:ok
end
# Split / subset — build a NEW document from selected pages, in any order:
{:ok, subset} = ExPdfium.extract_pages(doc, [0, 2, 5])
{:ok, bytes} = ExPdfium.save_to_bytes(subset)save_to_bytes/1 / save_to_file/2 write a full snapshot and leave doc open
for further edits.
Creating documents
Build a PDF from scratch — pages, text (Standard-14 fonts), shapes, and images.
Coordinates are PDF points, origin bottom-left; colors are {r,g,b}/{r,g,b,a}
(0–255).
with {:ok, doc} <- ExPdfium.new(),
{:ok, doc} <- ExPdfium.add_page(doc, :letter), # :a4 | :legal | … | {w_pts, h_pts}
{:ok, doc} <- ExPdfium.draw_text(doc, 0, {72, 720}, "Invoice #42",
font: :helvetica_bold, size: 18),
{:ok, doc} <- ExPdfium.draw_line(doc, 0, {72, 710}, {540, 710}, stroke: {0, 0, 0}),
{:ok, doc} <- ExPdfium.draw_rectangle(doc, 0, %{left: 72, bottom: 600, right: 540, top: 690},
fill: {245, 245, 245}, stroke: {200, 200, 200}),
{:ok, doc} <- ExPdfium.draw_circle(doc, 0, {120, 645}, 24, fill: {220, 50, 50}),
:ok <- ExPdfium.save_to_file(doc, "invoice.pdf") do
:ok
endPlacing images
draw_image/4 takes decoded pixels — an ExPdfium.Bitmap, the same struct
render_page/3 and image_data/3 produce — not an encoded JPEG/PNG. So you can
re-place a rendered page or an extracted image directly:
{:ok, bmp} = ExPdfium.image_data(src_doc, 0, 2)
{:ok, doc} = ExPdfium.draw_image(doc, 0, bmp, at: %{left: 400, bottom: 700, right: 540, top: 760})Pixel byte order
pdfium stores images in BGRA order. draw_image/4 takes :bgra, :bgr,
:bgrx, and :gray bitmaps as-is, and for :rgba/:rgbx (what render_page/3
yields by default, and what most image libraries produce) it swaps R↔B for
you — so you never have to think about byte order.
To place an image file, decode it to pixels first with the optional Vix library (the same one used for rendering):
{:ok, vimg} = Vix.Vips.Image.new_from_file("logo.png") # a 4-band RGBA image
{w, h, 4} = Vix.Vips.Image.shape(vimg)
{:ok, pixels} = Vix.Vips.Image.write_to_binary(vimg)
bitmap = %ExPdfium.Bitmap{data: pixels, width: w, height: h, stride: w * 4, format: :rgba}
{:ok, doc} = ExPdfium.draw_image(doc, 0, bitmap, at: %{left: 400, bottom: 700, right: 540, top: 760})If the source isn't already 4-band RGBA, convert it first (e.g.
Vix.Vips.Operation.colourspace/2and add an alpha band).draw_image/4accepts:rgba,:bgra,:bgrx,:bgr, and:gray.
Writing — annotations
Author annotations on a page. Rectangles take a bounds map
(%{left:, bottom:, right:, top:}, PDF points) — the same shape annotations/2
reads back.
# A sticky note (icon + popup text), a visible text box, a boxed callout, a link:
{:ok, doc} = ExPdfium.add_text_annotation(doc, 0, {500, 720}, "Please review")
{:ok, doc} = ExPdfium.add_free_text_annotation(doc, 0, %{left: 72, bottom: 690, right: 320, top: 715}, "DRAFT")
{:ok, doc} = ExPdfium.add_square_annotation(doc, 0, %{left: 60, bottom: 540, right: 540, top: 660}, stroke: {220, 50, 50})
{:ok, doc} = ExPdfium.add_link_annotation(doc, 0, %{left: 72, bottom: 510, right: 260, top: 525}, "https://hex.pm")
{:ok, doc} = ExPdfium.delete_annotation(doc, 0, 0) # remove by 0-based page indexThe text-markup family (highlight/underline/strikeout/squiggly) is deferred: pdfium's own renderer won't display them without an appearance stream it does not auto-generate. FreeText renders its text in pdfium's default appearance color (black); for colored text, use
draw_text/5.
Form-filling and the text-markup annotation family are not currently included.
Development
The shipped NIF binds pdfium dynamically and loads a libpdfium bundled
inside the precompiled tarball, right beside the NIF (bblanchon publishes no
static libpdfium.a). For local work, download a libpdfium once and point the
tests at it:
just fetch-pdfium # downloads libpdfium for this host into priv/pdfium
just test # EXPDFIUM_BUILD=1 mix test (forces a from-source build)
just fmt # mix format + cargo fmt
EXPDFIUM_BUILD=1 forces a from-source NIF build instead of downloading a
precompiled one. CI runs the full gate: mix format --check-formatted,
cargo fmt --check, cargo clippy -- -D warnings,
mix compile --warnings-as-errors, and mix test.
Releasing
See UPDATE_PROCEDURE.md.
In short: just release bumps
the version, rolls the CHANGELOG, tags, and pushes; the tag triggers a build
matrix that attaches one NIF per target to a GitHub release; checksums are
regenerated from those artifacts; Hex publish is gated behind a manual approval.
License
MIT — see LICENSE. pdfium itself is BSD-3-Clause (Google/Chromium); the precompiled pdfium binaries come from bblanchon/pdfium-binaries.