PdfElixide
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.0release. 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
- Open PDF documents from a file path or an in-memory binary
- Query the PDF specification version
- Get the page count
- Extract text from a specific page
- Convert a page or the whole document to Markdown, with headings, tables, images, and reading order under your control
- Convert a page or the whole document to HTML, either as semantic markup or as absolutely positioned spans carrying each fragment's PDF coordinates
- Extract words with bounding boxes and font metadata
- Extract text lines (each with its bounding box and constituent words)
- Extract individual characters with glyph boxes, baseline origins, advances, and color
- Extract spans (runs of text sharing one text state, with the raw PDF text-state parameters)
- Detect tables, read their rows and cells, and render one as Markdown, HTML, or plain text
- Extract vector paths (lines, curves, rectangles) with their drawing operations and stroke/fill style
- Read the document outline (bookmarks / table of contents) as a nested tree
- Extract raster images (photos, logos, scans) as PNG bytes with their on-page geometry
- Extract the fonts a page uses (type, encoding, weight) and pull out embedded font programs
- Read annotations (links, notes, highlights, form widgets) with their geometry, contents, colors, and flags
- Extract AcroForm fields (name, kind, value)
- Fill AcroForm fields and save the result to a file or in-memory binary
- Read document metadata — both the
/Infodictionary and the XMP packet - Read encryption permission flags (print, copy, modify, …)
- Read logical page labels (roman numerals, prefixes, etc.)
- Tune every text extractor: restrict to a region, drop optional-content layers,
spot inks or
/Artifactheaders, and configure the table detector and the span merger
Requirements
- Elixir
~> 1.15 - Erlang/OTP compatible with the above
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:
:title— the bookmark label (String.t()):dest— where it points, as one of:{:page, page_index}— a zero-based page index{:named, name}— a named destination that could not be resolved to a page (the raw name is preserved)nil— no determinable destination
:children— the nested%PdfElixide.Document.OutlineItem{}structs
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:
:text— the word's text content (String.t()):page— the zero-based page index the word belongs to (non_neg_integer()), which keeps the flatwords/1list usable since bounding boxes are page-relative:bbox— a%PdfElixide.Geometry.Rect{}with:x,:y,:width,:height(points):font_size— the average font size (float()):font— the dominant font name (String.t()):bold?/:italic?— booleans
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:
:text— the full line text, its words joined by spaces (String.t()):page— the zero-based page index the line belongs to (non_neg_integer()):bbox— a%PdfElixide.Geometry.Rect{}spanning the whole line (points):words— the line's%PdfElixide.Document.Word{}structs (so the word count islength(line.words))
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:
:text— the character itself, as a one-grapheme string (String.t()):page— the zero-based page index the character belongs to (non_neg_integer()):bbox— a%PdfElixide.Geometry.Rect{}around the glyph (points):font_size— the font size in points (float()):font— the font name (String.t()):font_weight— the numeric PDF font weight,100–900(non_neg_integer()):bold?/:italic?/:monospace?— booleans (bold?isfont_weight >= 600):color— a%PdfElixide.Color.RGB{}with:r,:g,:bchannels in0.0..1.0:origin— the{x, y}baseline origin in points, the typographic reference point (unlike:bbox, which is the glyph's box):rotation— the rotation in degrees, clockwise from horizontal (float()):advance_width— the glyph's advance width from the font metrics (float()):rendered_advance— the actual advance to the next character's origin, including character and word spacing (float()); use this, not:advance_width, to detect word boundaries:ascent/:descent— the distance from the baseline to the top / bottom of the typographic glyph box in points (descentis negative):mcid— the marked-content ID for Tagged PDFs (non_neg_integer()ornil)
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:
:text— the span's text content (String.t()):page— the zero-based page index the span belongs to (non_neg_integer()):bbox— a%PdfElixide.Geometry.Rect{}around the whole run (points):font_size— the font size in points (float()):font— the font name (String.t()):font_weight— the numeric PDF font weight,100–900(non_neg_integer()):bold?/:italic?/:monospace?— booleans (bold?isfont_weight >= 600):color— a%PdfElixide.Color.RGB{}with:r,:g,:bchannels in0.0..1.0:rotation— the rotation in degrees, clockwise from horizontal (float()):char_spacing— theTccharacter spacing in points (float()):word_spacing— theTwword spacing in points (float()):horizontal_scaling— theTzhorizontal scaling as a percentage,100.0being normal (float()):text_rise— theTsbaseline shift as a ratio of the font size (float()); positive is superscript, negative subscript:heading_level— the heading level1–6when the span is part of a heading, otherwisenil:mcid— the marked-content ID for Tagged PDFs (non_neg_integer()ornil)
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:
- Detections are a best guess and include occasional false positives (form
layouts, label-colon-value lists). Each table carries
:real_grid?, which is true when the detection looks like a genuine data grid — at least two rows and columns, consistently populated. Filter on it when that matters. :bboxisnilwhen the detector could not determine an extent, on both tables and cells. Every other bounding box in this library is always present.
Each table carries:
:page— the zero-based page index (non_neg_integer()):bbox— a%PdfElixide.Geometry.Rect{}around the table, ornil:col_count— the number of columns, inferred from the first row (non_neg_integer()):has_header?— whether the table has an explicit header section (boolean()):real_grid?— whether the detection looks like a genuine data grid (boolean()):rows— the%PdfElixide.Document.Table.Row{}structs
Each row carries :header? and its :cells. Each cell carries:
:text— the cell's text content (String.t()):bbox— a%PdfElixide.Geometry.Rect{}around the cell, ornil:colspan/:rowspan— the number of columns and rows the cell spans (pos_integer()):header?— whether this is a header cell (boolean()):mcids— the marked-content IDs making up the cell, for Tagged PDFs ([non_neg_integer()]):spans— the cell's%PdfElixide.Document.Span{}structs, so per-run font, size, and color survive into the table
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:
Table.cell/3— the%Table.Cell{}at a zero-based row and columnTable.cell_text/3— just that cell's textTable.row/2andTable.row_count/1— the%Table.Row{}at an index, and how many there areTable.Row.cell/2andTable.Row.cell_text/2— the same, within a single row
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:
:page— the zero-based page index (non_neg_integer()):bbox— a%PdfElixide.Geometry.Rect{}around the path (points):operations— the drawing commands in stream order, as flat tagged tuples:{:move_to, x, y},{:line_to, x, y},{:curve_to, c1x, c1y, c2x, c2y, ex, ey}(a cubic Bézier — two control points then the endpoint),{:rectangle, x, y, width, height}, and:close_path:stroke_color— a%PdfElixide.Color.RGB{}when the path is stroked, otherwisenil:fill_color— a%PdfElixide.Color.RGB{}when the path is filled, otherwisenil:stroke_width— the stroke width in points (float()):line_cap— one of:butt | :round | :square:line_join— one of:miter | :round | :bevel:dash_pattern—{dash_lengths, phase}({[float()], float()}) when the stroke is dashed, otherwisenil:layer— the Optional Content Group ("layer") name (String.t()) when the path belongs to one, otherwisenil
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:
:page— the zero-based page index (non_neg_integer()):bbox— a%PdfElixide.Geometry.Rect{}giving where the image sits on the page (points), ornilwhen the placement is unknown:width/:height— the image's pixel dimensions (non_neg_integer()):format— how the image was stored in the PDF::jpeg(a JPEG blob) or:raw(decoded pixels):ref— an opaque handle to the underlying image, used by the encode/save functions below:color_space— how the image was stored, as an atom::device_rgb,:device_gray,:device_cmyk,:indexed,:cal_gray,:cal_rgb,:lab,:icc_based,:separation,:device_n, or:pattern:bits_per_component— the stored bit depth per component (non_neg_integer()):rotation_degrees— the image's rotation on the page (integer())
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:
:page— the zero-based page index (non_neg_integer()):resource_name— the page's font resource id, e.g."F1"(String.t()):base_font— the face name, with any six-letter subset prefix (ABCDEF+) stripped (String.t()):subtype— the PDF font type:"Type1","TrueType", or"Type0":encoding—{:standard, name}for a named base encoding (e.g."WinAnsiEncoding"), or:custom/:identity:embedded?— whether the font program is embedded in the document:subset?— whether the font is subsetted (carried a subset prefix):weight— the FontDescriptor weight (400 normal, 700 bold), ornil:bold?/:italic?— booleans derived from the font's descriptor and name:ref— an opaque handle to the font, used bydata/1below
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:
:name— the field's PDF name (String.t()):kind— one of:button | :text | :choice | :signature | :unknown:value— one of{:text, String.t()} | {:boolean, boolean()} | {:name, String.t()} | {:array, [String.t()]} | nil
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.