PdfElixide

Elixir CIHex.pmHex.pmpdf_oxideLicense: MIT

Elixir bindings for pdf_oxide, a high-performance PDF library written in Rust. Built on top of Rustler.

⚠️ Status: This project is under active development and the public API is subject to change without notice until a 1.0 release. Expect breaking changes between minor versions.

Issues and Pull Requests are temporarily disabled. Since the API is not yet stable, they would only add noise. They will be re-enabled once the API approaches a 1.0 release.

Features

Requirements

The NIF ships as a precompiled binary via rustler_precompiled, so no Rust toolchain is needed. A stable Rust toolchain is only required to build from source.

Installation

Add pdf_elixide to your dependencies in mix.exs:

def deps do
[
{:pdf_elixide, "~> 0.8.0"}
]
end

Then fetch and compile:

mix deps.get
mix compile

The precompiled NIF is downloaded automatically on first build; no compilation step is needed.

Usage

Opening a document

Document inspection lives on PdfElixide.Document (open, version, page count, text extraction).

# Open from a file path
{:ok, doc} = PdfElixide.Document.open("path/to/file.pdf")
# Or from an in-memory binary
{:ok, bytes} = File.read("path/to/file.pdf")
{:ok, doc} = PdfElixide.Document.from_binary(bytes)

Inspecting a document

alias PdfElixide.Document
# Version is read directly from the struct — returned as a {major, minor} tuple.
{1, 4} = Document.version(doc)
# Page count is fetched from the underlying PDF and may fail.
{:ok, 3} = Document.page_count(doc)
# Extract text from a single page (zero-based index).
{:ok, text} = Document.text(doc, 0)
# Extract text from the whole document (pages separated by a form-feed).
{:ok, all} = Document.text(doc)
# Source path is the file the document was opened from, or `nil` when it was loaded from a binary.
"path/to/file.pdf" = Document.source_path(doc)

Each fallible function ships with a bang variant that returns the value directly and raises on error:

doc = PdfElixide.Document.open!("path/to/file.pdf")
pages = PdfElixide.Document.page_count!(doc)
text = PdfElixide.Document.text!(doc, 0)

Converting to Markdown

to_markdown turns a page — or the whole document — into structured Markdown, detecting headings and tables rather than returning the flat text stream that text/1 gives you.

alias PdfElixide.Document
# The whole document, pages joined by a `---` thematic break.
{:ok, markdown} = Document.to_markdown(doc)
# A single page (zero-based index).
{:ok, markdown} = Document.to_markdown(doc, 0)
# Either form takes options.
{:ok, markdown} = Document.to_markdown(doc, detect_headings: false)
{:ok, markdown} = Document.to_markdown(doc, 0, extract_tables: false)
# And from a page struct.
{:ok, markdown} = Document.Page.to_markdown(Document.page!(doc, 0))

The defaults mirror pdf_oxide's own, so to_markdown(doc) and to_markdown(doc, []) are equivalent. The full option list — heading detection, table extraction, image embedding, form-field inlining, running header/footer stripping, ligature expansion, reading order, and bold-marker behaviour — is documented under t:PdfElixide.Document.markdown_opts/0.

Converting to HTML

to_html mirrors to_markdown, emitting HTML instead: <h1><h6> for detected headings, <table> for detected tables, and <img> for images.

alias PdfElixide.Document
# The whole document, each page wrapped in <div class="page" data-page="N">.
{:ok, html} = Document.to_html(doc)
# A single page (zero-based index), with no page wrapper.
{:ok, html} = Document.to_html(doc, 0)
# Either form takes options.
{:ok, html} = Document.to_html(doc, detect_headings: false)
{:ok, html} = Document.to_html(doc, 0, extract_tables: false)
# One absolutely positioned div per text span instead of semantic markup.
{:ok, html} = Document.to_html(doc, preserve_layout: true)
# And from a page struct.
{:ok, html} = Document.Page.to_html(Document.page!(doc, 0))

The result is a fragment: no doctype, no <html>/<body>, and no stylesheet, so you supply the surrounding page. Options are the subset of the Markdown ones that pdf_oxide actually applies while converting to HTML, plus the HTML-only :preserve_layout — see t:PdfElixide.Document.html_opts/0.

:preserve_layout output is positioned but not ready to render: the coordinates are raw PDF user-space values, measured from the bottom of the page, so you flip them yourself (top = height - y) and give each page wrapper position: relative and a size. The typedoc covers both corrections.

Releasing a document

A document keeps its PDF data in memory on the Rust side. That memory is freed when the BEAM garbage-collects the handle, so for most programs there is nothing to do. In a long-lived process that opens many documents, close/1 frees it at a point you choose instead:

doc = PdfElixide.Document.open!("path/to/file.pdf")
text = PdfElixide.Document.text!(doc, 0)
:ok = PdfElixide.Document.close(doc)
true = PdfElixide.Document.closed?(doc)
# Reading a closed document is an ordinary error, not a crash.
{:error, %PdfElixide.Error{reason: :closed}} = PdfElixide.Document.page_count(doc)

close/1 is idempotent, and PdfElixide.Editor, PdfElixide.Document.Image, and PdfElixide.Document.Font handles have the same pair of functions. Closing an editor discards unsaved edits, so save first. Images and fonts already extracted from a document own their data independently — closing the document leaves them usable, and vice versa.

Reading the outline

PdfElixide.Document.outline/1 reads the document's outline — its bookmarks or table of contents — as a tree of %PdfElixide.Document.OutlineItem{} structs. A document with no outline gives {:ok, []}:

{:ok, items} = PdfElixide.Document.outline(doc)
# Walk the tree — each item may carry nested `:children`.
defmodule TOC do
def print(items, depth \\ 0) do
for %{title: title, dest: dest, children: children} <- items do
IO.puts(String.duplicate(" ", depth) <> "#{title} (#{inspect(dest)})")
print(children, depth + 1)
end
end
end
TOC.print(items)
# Chapter 1 ({:page, 0})
# Section 1.1 ({:page, 1})
# Chapter 2 ({:page, 2})

Each outline item carries:

Reading metadata, permissions, and page labels

PdfElixide.Document.metadata/1 reads the classic /Info dictionary into a %PdfElixide.Document.Metadata{} struct. Every field is optional; a document without an /Info dictionary yields a struct with all fields nil:

{:ok, meta} = PdfElixide.Document.metadata(doc)
meta.title # => "Test Title"
meta.author # => "Jane Doe"
meta.producer # => "pdf_elixide"
# Dates are the raw PDF date strings, e.g. "D:20240115120000Z".

PdfElixide.Document.xmp_metadata/1 reads the XML-based XMP packet many modern PDFs carry instead of, or in addition to, the Info dictionary. It returns {:ok, nil} when the document has no XMP packet:

{:ok, xmp} = PdfElixide.Document.xmp_metadata(doc)
xmp.title # => "Test Title"
xmp.creators # => ["Jane Doe"] (XMP models authors/subjects as lists)
xmp.subjects # => ["alpha", "beta"]
xmp.raw_xml # => the original XMP packet, as an escape hatch

PdfElixide.Document.permissions/1 reads the /P permission flags of an encrypted document, returning {:ok, nil} for an unencrypted one. Per the PDF spec these flags are advisory:

{:ok, perms} = PdfElixide.Document.permissions(doc)
perms.copy # => false
perms.print_high_res # => true

PdfElixide.Document.page_labels/1 returns the logical page labels — one per page, in page order — falling back to decimal page numbers where none are declared. PdfElixide.Document.Page.label/1 reads a single page's label:

{:ok, ["i", "ii", "1", "2"]} = PdfElixide.Document.page_labels(doc)
{:ok, "i"} = PdfElixide.Document.Page.label(PdfElixide.Document.page!(doc, 0))

Tuning extraction

The six text-family extractors — text, chars, words, text_lines, spans and tables — all take an optional keyword list, in the same shape as to_markdown/2,3: a list means "the whole document", a zero-based integer means "this page", and both can be followed by options.

# Restrict extraction to a region — any extracted bbox works as one.
{:ok, [heading | _]} = PdfElixide.Document.text_lines(doc, 0)
{:ok, words} = PdfElixide.Document.words(doc, 0, region: heading.bbox)
# ...with a stricter containment rule than the default :intersects.
{:ok, words} =
PdfElixide.Document.words(doc, 0, region: heading.bbox, region_mode: :fully_contained)
# Drop /Artifact-tagged running headers, footers and watermarks (ISO 32000-1
# §14.8.2.2.1), an optional-content layer, or a spot ink.
{:ok, words} = PdfElixide.Document.words(doc, 0, include_artifacts: false)
{:ok, text} = PdfElixide.Document.text(doc, 0, exclude_layers: ["Watermark"])
{:ok, text} = PdfElixide.Document.text(doc, 0, exclude_inks: ["PANTONE 185 C"])
# Tune the spatial table detector when a page yields no table, or too many.
{:ok, tables} =
PdfElixide.Document.tables(doc, 0, preset: :strict, min_table_cells: 6)
# Every option is available from a page handle too.
{:ok, tables} =
doc |> PdfElixide.Document.page!(0) |> PdfElixide.Document.Page.tables(preset: :relaxed)

Each function documents its own list — t:PdfElixide.Document.text_opts/0, words_opts/0, text_lines_opts/0, chars_opts/0, spans_opts/0 and tables_opts/0 — including a few combinations pdf_oxide cannot serve at once, where the typedoc names exactly which options get dropped. An unknown key is ignored; a known key with a wrong-typed value comes back as {:error, %PdfElixide.Error{reason: :other}} naming the field.

Extracting words

Word extraction keeps the positional and font information that plain text discards. PdfElixide.Document.words/2 returns the words of a single page (and words/1 returns every page's words as one flat list) as %PdfElixide.Document.Word{} structs:

{:ok, words} = PdfElixide.Document.words(doc, 0)
Enum.each(words, fn %PdfElixide.Document.Word{text: text, bbox: bbox} ->
IO.inspect({text, bbox.x, bbox.y, bbox.width, bbox.height})
end)
# Words are also reachable from a page handle.
{:ok, words} = doc |> PdfElixide.Document.page!(0) |> PdfElixide.Document.Page.words()

Each word carries:

Extracting lines

PdfElixide.Document.text_lines/2 returns the lines of a single page (and text_lines/1 returns every page's lines as one flat list) as %PdfElixide.Document.TextLine{} structs. Each line nests its constituent %PdfElixide.Document.Word{} structs:

{:ok, lines} = PdfElixide.Document.text_lines(doc, 0)
Enum.each(lines, fn %PdfElixide.Document.TextLine{text: text, words: words} ->
IO.inspect({text, length(words)})
end)
# Lines are also reachable from a page handle.
{:ok, lines} = doc |> PdfElixide.Document.page!(0) |> PdfElixide.Document.Page.text_lines()

Each line carries:

Extracting chars

PdfElixide.Document.chars/2 returns the characters of a single page (and chars/1 returns every page's characters as one flat list) as %PdfElixide.Document.Char{} structs. This is the most detailed extraction level — it keeps the per-glyph typographic data that words and lines flatten away:

{:ok, chars} = PdfElixide.Document.chars(doc, 0)
Enum.map_join(chars, & &1.text)
#=> "Page One"
# Chars are also reachable from a page handle.
{:ok, chars} = doc |> PdfElixide.Document.page!(0) |> PdfElixide.Document.Page.chars()

Each character carries:

Extracting spans

A span is a run of glyphs sharing one text state — the same font, size, color, and text-state parameters — which is what the PDF content stream actually emits. It sits between chars and words, and it is the only extraction level that keeps the raw text-state parameters. PdfElixide.Document.spans/2 returns the spans of a single page (and spans/1 returns every page's spans as one flat list) as %PdfElixide.Document.Span{} structs:

{:ok, spans} = PdfElixide.Document.spans(doc, 0)
Enum.each(spans, fn %PdfElixide.Document.Span{text: text, font: font} ->
IO.inspect({text, font})
end)
# Spans are also reachable from a page handle.
{:ok, spans} = doc |> PdfElixide.Document.page!(0) |> PdfElixide.Document.Page.spans()

Each span carries:

Detecting tables

PdfElixide.Document.tables/2 returns the tables of a single page (and tables/1 returns every page's tables as one flat list) as %PdfElixide.Document.Table{} structs. A page with no table gives {:ok, []}:

alias PdfElixide.Document.Table
{:ok, [table | _]} = PdfElixide.Document.tables(doc, 0)
Table.cell_text(table, 0, 0)
#=> "Age"
Enum.map(table, fn row -> Enum.map(row, & &1.text) end)
#=> [["Age", "0.042", "0.011", "0.001"], ["Sex", "0.318", "0.142", "0.025"], ...]
# Tables are also reachable from a page handle.
{:ok, tables} = doc |> PdfElixide.Document.page!(0) |> PdfElixide.Document.Page.tables()

Unlike the text levels above, tables are detected — a spatial algorithm combines text alignment with the page's vector lines, since most PDFs carry no explicit table markup. Two consequences:

Each table carries:

Each row carries :header? and its :cells. Each cell carries:

Rendering a single table

A detected table renders on its own, in any of the three formats the whole-document converters use:

Table.to_markdown(table)
#=> {:ok, "| Age | 0.042 | 0.011 | 0.001 |\n|---|---|---|---|\n| Sex | ..."}
Table.to_html(table)
#=> {:ok, "<table>\n<tbody>\n<tr><td>Age</td><td>0.042</td>..."}
Table.to_text(table)
#=> {:ok, "Age 0.042 0.011 0.001\nSex 0.318 0.142 0.025\n..."}
# Markdown takes one option: :conservative (the default) applies ** only to
# content-bearing text, :aggressive also wraps whitespace-only bold spans.
Table.to_markdown(table, bold_markers: :aggressive)

Each has a bang variant, and the output is the same block PdfElixide.Document.to_markdown/2 and to_html/2 emit for that table within its page — the same renderer produces both.

Rendering goes through the table's :ref, a handle to the detected table held on the Rust side, so it works only on a table that came from extraction, and Table.close/1 releases it early when you are walking many tables and keeping only their text. The struct's own rows and cells stay readable afterwards.

Two upstream quirks are worth knowing. Markdown requires a header row, so to_markdown/2 renders the first row as one even when :has_header? is false. And while :colspan widens a cell into extra columns there, :rowspan is ignored — to_html/1 is the one that carries both as attributes.

Rather than walking those lists by hand, reach into a table with the accessors:

Indices are positions — the row within :rows, the column within that row's :cells — so they reach exactly what Enum.at/2 would. The detector drops the cells a merge covers without leaving a placeholder, so a row containing a cell whose :colspan or :rowspan is greater than one stores fewer cells than :col_count, and positions after the merge no longer line up with the visual column. Every accessor except row_count/1 returns nil when the index falls outside the table (a negative or non-integer index raises FunctionClauseError), so none of them has a bang variant.

Tables and rows are also Enumerable — a table over its rows, a row over its cells — which is where the nested Enum.map/2 above comes from.

Extracting paths

PdfElixide.Document.paths/2 returns the vector graphics of a single page — the lines, curves, rectangles, and filled shapes drawn on it (table rulings, underlines, boxes, diagrams) — as %PdfElixide.Document.Path{} structs (and paths/1 returns every page's paths as one flat list). A page with no vector graphics gives {:ok, []}:

{:ok, paths} = PdfElixide.Document.paths(doc, 0)
for path <- paths do
IO.inspect(path.operations)
end
#=> [{:move_to, 100.0, 710.0}, {:line_to, 500.0, 710.0}]
# Paths are also reachable from a page handle.
{:ok, paths} = doc |> PdfElixide.Document.page!(0) |> PdfElixide.Document.Page.paths()

Each path carries:

Colors are always resolved to DeviceRGB during extraction, which is why these are typed %PdfElixide.Color.RGB{} specifically rather than the wider PdfElixide.Color union. A path can be both stroked and filled, so :stroke_color and :fill_color are independent.

Extracting images

PdfElixide.Document.images/2 returns the raster images of a single page — the photos, logos, and scanned pictures drawn on it — as %PdfElixide.Document.Image{} structs (and images/1 returns every page's images as one flat list). A page with no images gives {:ok, []}:

{:ok, images} = PdfElixide.Document.images(doc, 0)
for image <- images do
PdfElixide.Document.Image.save(image, "page0-#{image.width}x#{image.height}.png")
end
# Images are also reachable from a page handle.
{:ok, images} = doc |> PdfElixide.Document.page!(0) |> PdfElixide.Document.Page.images()

Each image carries:

The pixel data is encoded on demand — mirroring PdfElixide.Editor — with PdfElixide.Document.Image.to_binary/2 (in-memory bytes) and PdfElixide.Document.Image.save/3 (to a file), each taking a format: :png | :jpeg option (:png is the default for to_binary/2):

image = hd(images)
{:ok, png} = PdfElixide.Document.Image.to_binary(image) # PNG bytes
{:ok, jpg} = PdfElixide.Document.Image.to_binary(image, format: :jpeg)
# save/3 infers the format from the extension; :format overrides it.
:ok = PdfElixide.Document.Image.save(image, "out.png")
:ok = PdfElixide.Document.Image.save(image, "thumb.bin", format: :jpeg)

Encoding always produces a valid file, whatever the stored codec (JPEG, CCITT bilevel, CMYK, indexed palette, ...). JPEG output is lossless pass-through for a :jpeg-stored image (the original bytes are returned untouched) except CMYK JPEGs, which are re-encoded to RGB. Bang variants (to_binary!/2, save!/3) raise instead of returning {:error, _}.

For the raw stored bytes (rather than a re-encoded image), use PdfElixide.Document.Image.data/1:

case PdfElixide.Document.Image.data(image) do
{:ok, {:jpeg, bytes}} -> bytes # the original JPEG blob (zero loss)
{:ok, {:raw, pixels, format}} -> pixels # bare pixels; format is :rgb | :grayscale | :cmyk
end

{:raw, ...} bytes are uncompressed pixels, not a standalone file — interpret them with :width, :height, and :color_space, or use to_binary/2 when you want an encoded PNG/JPEG.

Extracting fonts

PdfElixide.Document.fonts/2 returns the fonts referenced by a single page as %PdfElixide.Document.Font{} structs (and fonts/1 returns every page's fonts as one flat list, so a font used on several pages appears once per page). A page that references no fonts gives {:ok, []}:

{:ok, fonts} = PdfElixide.Document.fonts(doc, 0)
# Fonts are also reachable from a page handle.
{:ok, fonts} = doc |> PdfElixide.Document.page!(0) |> PdfElixide.Document.Page.fonts()

Each font carries:

For an embedded font, PdfElixide.Document.Font.data/1 pulls out the raw font program — the TrueType / OpenType bytes, suitable for re-embedding elsewhere. A non-embedded font (e.g. one of the standard 14) gives {:ok, nil}:

font = Enum.find(fonts, & &1.embedded?)
case PdfElixide.Document.Font.data(font) do
{:ok, nil} -> :not_embedded
{:ok, bytes} -> File.write!("#{font.base_font}.otf", bytes)
end

data!/1 returns the bytes (or nil) directly, raising on error.

Reading annotations

PdfElixide.Document.annotations/2 returns the annotations on a single page as %PdfElixide.Document.Annotation{} structs (and annotations/1 returns every page's annotations as one flat list). A page with no annotations gives {:ok, []}:

{:ok, annotations} = PdfElixide.Document.annotations(doc, 0)
# Annotations are also reachable from a page handle.
{:ok, annotations} = doc |> PdfElixide.Document.page!(0) |> PdfElixide.Document.Page.annotations()

Each annotation carries its :page index, a parsed :subtype atom (:link, :text, :highlight, :widget, …), its :rect (a %PdfElixide.Geometry.Rect{}), :contents, :author, dates, :opacity, and more. Unlike text and path colors, annotation colors carry the raw /C (or /IC) components, so any of the PdfElixide.Color structs can appear — decoded by component count, since the array itself names no colorspace:

%PdfElixide.Color.RGB{r: 1.0, g: 0.0, b: 0.0} # 3 components
%PdfElixide.Color.Gray{gray: 0.5} # 1 component
%PdfElixide.Color.CMYK{c: 0.0, m: 0.0, y: 0.0, k: 1.0} # 4 components
%PdfElixide.Color.Unknown{components: [0.25, 0.75]} # any other count

A count-based guess can be wrong — a one-component /C in a Separation space reads as %PdfElixide.Color.Gray{} even though the value is a tint. :color and :interior_color are nil when no color is decoded.

Link annotations expose where they point via :destination and :action:

%PdfElixide.Document.Annotation{action: {:uri, "https://example.com"}}

The /F flags decode into a %PdfElixide.Document.Annotation.Flags{} sub-struct (one boolean per bit, plus :raw for the undecoded integer):

annotation.flags.print # => true
annotation.flags.hidden # => false
annotation.flags.raw # => 4

Widget (form field) annotations additionally populate :field_type, :field_name, :field_value, and related keys; for richer form-field access see PdfElixide.Form. annotations!/1 and annotations!/2 return the list directly, raising on error.

Extracting form fields

PdfElixide.Form.fields/1 returns the AcroForm fields of the document as a list of %PdfElixide.Form.Field{} structs:

{:ok, fields} = PdfElixide.Form.fields(doc)
Enum.each(fields, fn %PdfElixide.Form.Field{name: name, kind: kind, value: value} ->
IO.inspect({name, kind, value})
end)

Each field carries:

A bang variant, PdfElixide.Form.fields!/1, returns the list directly and raises on error.

Filling form fields

To modify a PDF, open it as a PdfElixide.Editor instead of a PdfElixide.Document, set values with PdfElixide.Form.set_value/3, then persist the result with PdfElixide.Editor.save/3 (file) or PdfElixide.Editor.to_binary/2 (in-memory).

alias PdfElixide.Editor
alias PdfElixide.Form
{:ok, editor} = Editor.open("path/to/form.pdf")
# Values use the same tagged-tuple shape returned by Form.fields/1.
:ok = Form.set_value(editor, "full_name", {:text, "Jane Doe"})
:ok = Form.set_value(editor, "subscribe", {:boolean, true})
# Write the filled PDF to disk.
:ok = Editor.save(editor, "path/to/filled.pdf")
# Or get the bytes back for streaming / storage.
{:ok, bytes} = Editor.to_binary(editor)

Both save/3 and to_binary/2 accept a keyword list of options (:incremental, :compress, :linearize, :garbage_collect). For form filling against an existing PDF, an incremental save preserves the original AcroForm structure and only appends the field-value updates:

:ok = Editor.save(editor, "path/to/filled.pdf", incremental: true)

Bang variants Editor.open!/1, Editor.save!/3, Editor.to_binary!/2, and Form.set_value!/3 raise on error.

Documentation

Full API documentation is published on HexDocs.

License

Released under the MIT License.