ExPdfium (ExPdfium v0.4.3)

Copy Markdown View Source

Elixir bindings for pdfium, Google's Chromium PDF engine, via the Rust pdfium-render crate. The native library ships precompiled (rustler_precompiled), so there is no Rust toolchain or separately-installed pdfium to set up.

A read & write toolkit

Read: open, render, extract/search text, metadata, page geometry, permissions, structure (bookmarks/links/attachments), forms/annotations, and images & page objects. Write: page assembly — merge (append/2), split/subset (extract_pages/2), delete_pages/2, rotate_page/3; document creation — new/0, add_page/3, and draw_text/draw_rectangle/draw_line/ draw_circle/draw_image; annotation authoring — add_text_annotation/5, add_free_text_annotation/5, add_square_annotation/4, add_link_annotation/5, delete_annotation/3; and save_to_bytes/1 / save_to_file/2. Form-filling and the text-markup annotation family (highlight/underline/…) are arriving in later 0.3.x releases.

Untrusted input

pdfium runs in-process as a native library, so a genuine crash in it would take down the BEAM VM (a Rust panic is contained; a native fault is not). ExPdfium validates and bounds caller arguments and capped page-render/image decode sizes, but extraction returns data proportional to the document's content — a large (or maliciously compressed) embedded file, image, or signature blob allocates memory proportional to its decoded size. When processing untrusted PDFs at scale, run that work behind OS memory limits and/or in an isolated, supervised OS process (e.g. a dedicated, restartable node) rather than relying on per-call caps.

Example

{:ok, doc} = ExPdfium.open("file.pdf")
{:ok, 3} = ExPdfium.page_count(doc)

{:ok, %ExPdfium.Bitmap{data: data, width: w, height: h}} =
  ExPdfium.render_page(doc, 0, dpi: 300)
{:ok, image} = Vix.Vips.Image.new_from_binary(data, w, h, 4, :VIPS_FORMAT_UCHAR)

:ok = ExPdfium.close(doc)

# Encrypted documents:
{:ok, doc} = ExPdfium.open("secret.pdf", password: "hunter2")

Summary

Documents

Explicitly close a document, releasing pdfium memory early. Optional and idempotent.

Open a PDF from a file path or an in-memory binary.

Open a PDF from an in-memory binary, with no source-kind guessing.

Open a PDF from a file path, with no source-kind guessing.

Number of pages in the document.

Rendering

Render a 0-indexed page to an ExPdfium.Bitmap (an uncompressed 4-channel pixel buffer).

Render every page to a small bitmap, returned in page order.

Structure & navigation

Extract the bytes of the embedded file at index (see attachments/1).

List the document's embedded files.

Return the links on a 0-indexed page.

Return the document outline (bookmarks) as a nested tree.

Forms & annotations

Return the annotations on a 0-indexed page, in page order.

Read the document's AcroForm fields, one entry per widget, across all pages.

Return which interactive-form technology the document uses.

Images & objects

Decode the image object at object_index (see page_objects/2 / images/2) to a pixel bitmap.

Return the original, still-encoded stream of the image object at object_index.

List the image objects on a 0-indexed page, with how each is stored.

The composed content→display transformation matrix for a 0-indexed object on a 0-indexed page.

The clockwise rotation, in degrees, to apply to an extracted image in a top-left-origin raster library (Vix/libvips, Pillow, ImageMagick) so it appears upright, as the page displays it.

List every object on a 0-indexed page, in page order.

Creating documents

Add a blank page to doc.

Draw a circle of radius centered at {cx, cy} on a 0-indexed page.

Place a decoded image (an ExPdfium.Bitmap) into the :at rectangle on a 0-indexed page, scaling it to fill those bounds.

Draw a straight line from {x1, y1} to {x2, y2} on a 0-indexed page.

Draw a rectangle covering the bounds/0 rectangle on a 0-indexed page.

Draw text with its baseline starting at {x, y} (PDF points, bottom-left origin) on a 0-indexed page.

Create a new, empty in-memory PDF document. Add pages with add_page/3 and content with the draw_* functions, then save_to_bytes/1 / save_to_file/2.

Annotating

Add a free-text annotation: a visible text box inside bounds.

Add a link annotation covering bounds that opens uri when clicked.

Add a square (rectangle) annotation filling bounds.

Add a text (sticky-note) annotation at a point, in PDF points (bottom-left origin).

Delete the annotation at 0-based index on a 0-indexed page.

Writing (page assembly)

Append a copy of every page of source onto the end of doc (merge).

Delete a page, or an inclusive range of pages, from doc.

Build a new document from the given 0-indexed pages of source.

Flatten every page (see flatten_page/2). Returns {:ok, doc}.

Flatten a 0-indexed page's annotations and form fields into its static content.

Set a page's absolute rotation, in degrees.

Serialize the document to PDF bytes.

Save the document to a file at path.

Diagnostics

Return a marker string confirming the native pdfium library loaded and initialized. Useful as a smoke test that the precompiled NIF is healthy.

Types

A bounding rectangle in PDF user-space points (1/72 inch). The origin is the page's bottom-left corner and y increases upward, so top >= bottom.

A page object's transformation matrix, the six values a, b, c, d, e, f of the PDF cm transform [a b c d e f]. A point (x, y) in the object's own space maps to (a·x + c·y + e, b·x + d·y + f) on the page.

Documents

close(document)

@spec close(ExPdfium.Document.t()) :: :ok

Explicitly close a document, releasing pdfium memory early. Optional and idempotent.

Documents are also closed when garbage-collected, but that close is processed asynchronously (on a background thread, so it can't stall a scheduler while a long render holds the pdfium lock). Call this for deterministic, immediate release.

open(path_or_binary, opts \\ [])

@spec open(
  Path.t() | binary(),
  keyword()
) :: {:ok, ExPdfium.Document.t()} | {:error, atom()}

Open a PDF from a file path or an in-memory binary.

A binary beginning with "%PDF" is treated as document bytes; any other binary is treated as a file path. The heuristic is ambiguous at the edges (a PDF with junk bytes before the header reads as a path; a path that somehow starts with "%PDF" reads as bytes) — use open_file/2 or open_blob/2 when the source kind is known.

Options

  • :password — password for an encrypted PDF (default nil)

Errors

Returns {:error, reason} where reason is one of:

  • :enoent — the path does not exist
  • :invalid_pdf — the bytes are not a parseable PDF
  • :password_error — the document is encrypted and the password was missing or incorrect
  • :unsupported_security — unsupported encryption/security handler
  • :file_error / :io_error / :open_failed — other read/open failures
  • :bad_source — internal: malformed source argument (e.g. a non-UTF-8 path)

open_blob(bytes, opts \\ [])

@spec open_blob(
  binary(),
  keyword()
) :: {:ok, ExPdfium.Document.t()} | {:error, atom()}

Open a PDF from an in-memory binary, with no source-kind guessing.

The explicit counterpart to open/2 for document bytes (same :password option and errors). Use this when the argument is always PDF data — including data with junk bytes before the %PDF header, which open/2 would misread as a path.

open_file(path, opts \\ [])

@spec open_file(
  Path.t(),
  keyword()
) :: {:ok, ExPdfium.Document.t()} | {:error, atom()}

Open a PDF from a file path, with no source-kind guessing.

The explicit counterpart to open/2 for a path (same :password option and errors). Use this when the argument is always a path — including a path that might begin with "%PDF", which open/2 would misread as document bytes.

page_count(document)

@spec page_count(ExPdfium.Document.t()) ::
  {:ok, non_neg_integer()} | {:error, :document_closed | :lock_poisoned}

Number of pages in the document.

Returns {:error, :document_closed} if the document has been closed with close/1.

65,535-page limit

pdfium-render represents page indices as a 16-bit integer, so documents with more than 65,535 pages are not supported and this count wraps (reports count mod 65536). Such documents are pathological; if you handle untrusted input that might contain one, treat the page count as unreliable above that bound.

Rendering

render_page(document, page_index, opts \\ [])

@spec render_page(ExPdfium.Document.t(), non_neg_integer(), keyword()) ::
  {:ok, ExPdfium.Bitmap.t()} | {:error, atom()}

Render a 0-indexed page to an ExPdfium.Bitmap (an uncompressed 4-channel pixel buffer).

Options

Sizing (highest precedence first; the default is dpi: 72):

  • :width and/or :height — output size in pixels (aspect-preserving if only one is given)
  • :scale — multiple of the natural size (1.0 == 72 DPI)
  • :dpi — dots per inch (e.g. 150, 300)

Other:

  • :format:rgba (default) or :bgra (pdfium's native order, no conversion)
  • :background:white (default) or :transparent
  • :grayscale — render in grayscale (default false). The bitmap is still 4-channel; the color channels just carry equal gray values.
  • :annotations — draw annotations (default true); set false to render the page without its markup/widget overlay
  • :form_fields — draw interactive form-field content (default true)

Bitmap layout

data is width * height * 4 bytes, row-major, stride (== width * 4) bytes per row, 8 bits per channel. Hand it straight to Vix/Image:

{:ok, %ExPdfium.Bitmap{data: data, width: w, height: h}} =
  ExPdfium.render_page(doc, 0, dpi: 300)
{:ok, image} = Vix.Vips.Image.new_from_binary(data, w, h, 4, :VIPS_FORMAT_UCHAR)

Errors

  • :page_out_of_bounds — no such page index
  • :document_closed — the document was closed
  • :unsupported_format / :unsupported_background — bad option value
  • :render_failed — pdfium failed to render the page

thumbnails(doc, opts \\ [])

@spec thumbnails(
  ExPdfium.Document.t(),
  keyword()
) :: {:ok, [ExPdfium.Bitmap.t()]} | {:error, atom()}

Render every page to a small bitmap, returned in page order.

Takes the same options as render_page/3 (sizing, :format, :grayscale, …). When no sizing option is given, sizing defaults to width: 200. A document with no pages returns {:ok, []}; if any page fails to render, the first error is returned.

{:ok, thumbs} = ExPdfium.thumbnails(doc, width: 160)
# => [%ExPdfium.Bitmap{...}, ...]   # one per page

Metadata & geometry

Structure & navigation

attachment_data(document, index)

@spec attachment_data(ExPdfium.Document.t(), non_neg_integer()) ::
  {:ok, binary()} | {:error, atom()}

Extract the bytes of the embedded file at index (see attachments/1).

Returns {:error, :attachment_not_found} for an invalid index, {:error, :attachment_failed} if pdfium cannot read the file data, or {:error, :attachment_too_large} if the decoded file exceeds a safety cap (embedded files are stored compressed, so a small PDF can decode to a much larger file — see the "Untrusted input" note on the module).

attachments(document)

@spec attachments(ExPdfium.Document.t()) ::
  {:ok,
   [%{index: non_neg_integer(), name: String.t(), size: non_neg_integer()}]}
  | {:error, atom()}

List the document's embedded files.

Each is %{index: non_neg_integer(), name: String.t(), size: non_neg_integer()}. Use attachment_data/2 with the index to extract the bytes.

links(document, page_index)

@spec links(ExPdfium.Document.t(), non_neg_integer()) ::
  {:ok,
   [
     %{
       bounds: bounds() | nil,
       uri: String.t() | nil,
       page: non_neg_integer() | nil
     }
   ]}
  | {:error, atom()}

Return the links on a 0-indexed page.

Each link is %{bounds: t:bounds/0 | nil, uri: String.t() | nil, page: non_neg_integer() | nil}uri for a web link, page for an internal /Dest destination. bounds is nil if the link has no rectangle; uri and page are both nil for an unsupported or action-based link.

outline(document)

@spec outline(ExPdfium.Document.t()) :: {:ok, [map()]} | {:error, atom()}

Return the document outline (bookmarks) as a nested tree.

Each node is %{title: String.t(), page: non_neg_integer() | nil, children: [node]}, where page is the 0-indexed destination page (or nil). A document with no outline returns {:ok, []}.

page is nil for a bookmark whose target is a GoTo action rather than a /Dest. The tree is capped (depth 64, 50_000 nodes) to bound pathological or cyclic outlines; beyond that it is silently truncated.

Forms & annotations

annotations(document, page_index)

@spec annotations(ExPdfium.Document.t(), non_neg_integer()) ::
  {:ok, [map()]} | {:error, atom()}

Return the annotations on a 0-indexed page, in page order.

Each annotation is:

%{
  type: atom(),                # the PDF /Subtype, e.g. :text, :highlight,
                               # :link, :widget, :ink, :stamp, :free_text…
  bounds: t:bounds/0 | nil,    # the annotation rectangle, in PDF points
  contents: String.t() | nil,  # the /Contents text
  name: String.t() | nil,      # the annotation's /NM name (not a field name)
  hidden: boolean(),
  printed: boolean()
}

Widget annotations (form-field controls) are listed alongside markup annotations; use form_fields/1 to read their field values. A page with no annotations returns {:ok, []}.

form_fields(document)

@spec form_fields(ExPdfium.Document.t()) :: {:ok, [map()]} | {:error, atom()}

Read the document's AcroForm fields, one entry per widget, across all pages.

Each field is:

%{
  name: String.t() | nil,   # the field's /T name
  type: :text | :checkbox | :radio_button | :combo_box | :list_box |
        :push_button | :signature | :unknown,
  value: String.t() | nil,  # text/combo/list value, or the selected on-state of a button group
  checked: boolean() | nil, # checkbox/radio only; nil for other types
  read_only: boolean(),
  required: boolean(),
  page: non_neg_integer(),  # 0-indexed page the widget sits on
  bounds: t:bounds/0 | nil
}

A checkbox or radio group shares one name across its option widgets, so it surfaces as one entry per option widget. For these, value is the group's currently-selected on-state (the same string on every widget in the group), and checked flags which widget is the selected one — so to find a radio group's answer, take the value of the entry whose checked is true. A document with no form returns {:ok, []}.

value and checked are read straight from pdfium without coercion: a checked checkbox is %{value: "Yes", checked: true}, never flattened to a string.

Limitations

  • This reads a group's selected value, not its available options — pdfium does not expose per-option export names for checkbox/radio groups. A naive Map.new(fields, &{&1.name, &1.value}) collapses a group to one entry; to find a group's answer, take the value of the entry whose checked is true.
  • A multi-select list box reports only pdfium's single value string, so additional selections beyond the first are not surfaced.

form_type(document)

@spec form_type(ExPdfium.Document.t()) ::
  {:ok, :none | :acrobat | :xfa_full | :xfa_foreground} | {:error, atom()}

Return which interactive-form technology the document uses.

One of :none, :acrobat (a classic AcroForm), :xfa_full, or :xfa_foreground (XFA forms). A document with no form returns {:ok, :none}.

XFA caveat

Reading XFA form data requires a pdfium build with the V8 JavaScript engine, which ExPdfium does not ship. form_fields/1 reads AcroForm fields; for an :xfa_full document the AcroForm view may be empty or partial.

Images & objects

image_data(document, page_index, object_index)

@spec image_data(ExPdfium.Document.t(), non_neg_integer(), non_neg_integer()) ::
  {:ok, ExPdfium.Bitmap.t()} | {:error, atom()}

Decode the image object at object_index (see page_objects/2 / images/2) to a pixel bitmap.

Returns {:ok, %ExPdfium.Bitmap{}}. Unlike render_page/3 (always 4-channel), an extracted image keeps its native channel order — format is :gray (1 channel), :bgr (3), or :bgrx / :bgra (4) — so check it before handing data to an image library.

These are the image's raw stored samples: image masks (soft/stencil) and object transforms are not applied, so a masked image comes back without its transparency, and the bitmap size may differ from images/2's reported dimensions. For the composited, as-displayed result, render the page with render_page/3 instead.

Errors: :object_not_found (no object at that index), :not_an_image (the object isn't an image), :image_too_large (the image's declared pixel count exceeds a safety cap, so it isn't decoded), :page_out_of_bounds, :image_failed.

image_raw_data(document, page_index, object_index)

@spec image_raw_data(ExPdfium.Document.t(), non_neg_integer(), non_neg_integer()) ::
  {:ok, binary()} | {:error, atom()}

Return the original, still-encoded stream of the image object at object_index.

This is the image exactly as stored, with its PDF filters not applied: for a "DCTDecode" image the bytes are a ready-to-write JPEG; for "FlateDecode" they are zlib-compressed samples, not a standalone image file. Check filters from images/2 to know which. For always-decodable pixels, use image_data/3.

Errors: :object_not_found, :not_an_image, :page_out_of_bounds, :image_failed.

images(document, page_index)

@spec images(ExPdfium.Document.t(), non_neg_integer()) ::
  {:ok, [map()]} | {:error, atom()}

List the image objects on a 0-indexed page, with how each is stored.

Each is:

%{
  index: non_neg_integer(),         # object index, for image_data/3
  width: non_neg_integer(),         # intrinsic image width, in pixels
  height: non_neg_integer(),        # intrinsic image height, in pixels
  bits_per_pixel: non_neg_integer(),
  filters: [String.t()],            # PDF stream filters, e.g. ["DCTDecode"]
  bounds: t:bounds/0 | nil,         # where it sits on the page, in points
  matrix: t:matrix/0 | nil          # placement transform (scale/rotation/flip)
}

width/height are pdfium's reported pixel dimensions (a 0 means pdfium couldn't read it), and index is valid only until the document is mutated.

Use image_data/3 to get decoded pixels, or image_raw_data/3 for the original encoded bytes — filters tells you the encoding (a "DCTDecode" raw stream is a ready JPEG; "FlateDecode" is zlib-compressed samples, not a standalone file).

matrix is the image's placement transform (see matrix/0). Because it maps the unit square onto the page, a caller can recover the transform baked into the image object — scale, plus any object-level rotation or flip — without re-rendering.

Orientation needs the page rotation too

The matrix is in content space and does not carry the page-level /Rotate, which is the usual rotation for scanned pages. For the as-displayed orientation, compose this matrix with page_info/2's :rotation. (Note also that page_info/2's :width/:height are already display-oriented, a different frame from this matrix — easy to conflate.) Using the object matrix alone will leave a /Rotate-rotated scan turned the wrong way.

object_display_matrix/3 does this composition for you, returning the content→display transform directly.

object_display_matrix(doc, page_index, object_index)

@spec object_display_matrix(
  ExPdfium.Document.t(),
  non_neg_integer(),
  non_neg_integer()
) ::
  {:ok, matrix()} | {:error, atom()}

The composed content→display transformation matrix for a 0-indexed object on a 0-indexed page.

page_objects/2 and images/2 give an object's :matrix in the page's unrotated content space. This composes that matrix with the page-level /Rotate (from page_info/2), returning the single matrix/0 that maps the object's own space straight to display coordinates (origin bottom-left of the page as shown, y up). object_index is the same index page_objects/2 / images/2 report.

That is exactly the transform needed to orient an extracted image as it appears on the page — e.g. to turn a native-resolution image_raw_data/3 JPEG the right way up for OCR — composing the object's own scale/rotation/flip with the page rotation, without re-rendering.

This library deliberately does not rotate pixels for you (that is image processing best left to your image pipeline); it hands you the transform as data. Apply it in Vix/Image, or read the rotation off it to pass an orientation hint to your OCR engine.

This matrix is PDF y-up — raster libraries are y-down

These values are in PDF space: origin bottom-left, y increasing up, so a positive atan2(b, a) is a counter-clockwise angle. Raster libraries (Vix/libvips, Pillow, ImageMagick) put the origin top-left with y going down, where the same numeric angle rotates the opposite way. Deriving an angle here and applying it directly turns 90°/270° pages 180° the wrong way (it cancels at 0°/180°, so it looks fine on unrotated docs). Negate the angle (or flip the image in y) first — or just call object_display_rotation/3, which returns the clockwise degrees already in raster convention.

Returns {:error, :object_not_found} if there is no such object, or {:error, :no_matrix} if pdfium could not report the object's matrix.

Discrete page rotation only

The page-rotation part covers PDF /Rotate (0/90/180/270). Any sub-90° rotation or skew lives in the object matrix itself and is preserved exactly in the composition.

object_display_rotation(doc, page_index, object_index)

@spec object_display_rotation(
  ExPdfium.Document.t(),
  non_neg_integer(),
  non_neg_integer()
) ::
  {:ok, float()} | {:error, atom()}

The clockwise rotation, in degrees, to apply to an extracted image in a top-left-origin raster library (Vix/libvips, Pillow, ImageMagick) so it appears upright, as the page displays it.

This is the raster-convention companion to object_display_matrix/3. That matrix is in PDF space (origin bottom-left, y up); raster libraries put the origin top-left with y increasing downward, which inverts the rotation sense. So an angle read straight off the PDF matrix (atan2(b, a)) and handed to a raster library comes out 180° wrong on 90°/270° pages — and, because it cancels at 0°/180°, passes casual testing on unrotated documents. This function returns the already-converted clockwise angle, normalized to [0, 360).

{:ok, raw} = ExPdfium.image_raw_data(doc, 0, 0)
{:ok, deg} = ExPdfium.object_display_rotation(doc, 0, 0)
{:ok, img} = Image.from_binary(raw)
{:ok, upright} = Image.rotate(img, deg)   # Vips is clockwise; upright as on the page

For a plain scanned page — a single full-page image, rotated only by the page /Rotate — this equals page_info/2's :rotation. It captures rotation only: any flip or skew carried in the object matrix is not reducible to an angle, so use object_display_matrix/3 for the full transform in that case.

Returns {:error, :object_not_found} or {:error, :no_matrix} exactly as object_display_matrix/3 does.

page_objects(document, page_index)

@spec page_objects(ExPdfium.Document.t(), non_neg_integer()) ::
  {:ok, [map()]} | {:error, atom()}

List every object on a 0-indexed page, in page order.

Each object is %{index: non_neg_integer(), type: atom(), bounds: t:bounds/0 | nil, matrix: t:matrix/0 | nil}, where type is one of :text, :path, :image, :shading, :form (an XObject form), or :unsupported. index is the object's position in the page's object list — pass it to image_data/3 / image_raw_data/3. It is valid only until the document is mutated (a write op can shift object indices).

matrix is the object's transformation matrix (see matrix/0); it is nil only if pdfium cannot report it.

Creating documents

add_page(doc, size, opts \\ [])

@spec add_page(ExPdfium.Document.t(), atom() | {number(), number()}, keyword()) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Add a blank page to doc.

size is a named paper size (:letter, :legal, :tabloid, :a3, :a4, :a5) or {width, height} in PDF points. By default the page is appended; pass at: index to insert it at a 0-based position (an index past the end appends). Returns {:ok, doc}, or {:error, :bad_page_size} for an unrecognized size.

draw_circle(doc, page, arg, radius, opts \\ [])

@spec draw_circle(
  ExPdfium.Document.t(),
  non_neg_integer(),
  {number(), number()},
  number(),
  keyword()
) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Draw a circle of radius centered at {cx, cy} on a 0-indexed page.

Options

  • :fill — fill color, or nil (default nil)
  • :stroke — outline color, or nil (default nil)
  • :stroke_width — outline width in points (default 1)

draw_image(doc, page, bitmap, opts)

@spec draw_image(
  ExPdfium.Document.t(),
  non_neg_integer(),
  ExPdfium.Bitmap.t(),
  keyword()
) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Place a decoded image (an ExPdfium.Bitmap) into the :at rectangle on a 0-indexed page, scaling it to fill those bounds.

The bitmap is the same struct render_page/3 and image_data/3 produce, so you can place a rendered page or an extracted image; to place a file, decode it to pixels first (e.g. with Vix — see the README). pdfium stores images in BGR order and ExPdfium handles the byte-order conversion, so :rgba, :bgra, :bgrx, :bgr, and :gray bitmaps all work.

Options

  • :at — the bounds/0 rectangle to fill (required; left/bottom should be the lower-left corner — inverted bounds mirror the image)

A bitmap whose data length doesn't match width * height * channels returns {:error, :bad_image_data}.

draw_line(doc, page, arg1, arg2, opts \\ [])

@spec draw_line(
  ExPdfium.Document.t(),
  non_neg_integer(),
  {number(), number()},
  {number(), number()},
  keyword()
) :: {:ok, ExPdfium.Document.t()} | {:error, atom()}

Draw a straight line from {x1, y1} to {x2, y2} on a 0-indexed page.

Options

  • :stroke — line color (default black)
  • :stroke_width — line width in points (default 1)

draw_rectangle(doc, page, bounds, opts \\ [])

@spec draw_rectangle(ExPdfium.Document.t(), non_neg_integer(), bounds(), keyword()) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Draw a rectangle covering the bounds/0 rectangle on a 0-indexed page.

Options

  • :fill — fill color {r,g,b}/{r,g,b,a}, or nil for no fill (default nil)
  • :stroke — outline color, or nil for no outline (default nil)
  • :stroke_width — outline width in points (default 1)

draw_text(doc, page, arg, text, opts \\ [])

@spec draw_text(
  ExPdfium.Document.t(),
  non_neg_integer(),
  {number(), number()},
  String.t(),
  keyword()
) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Draw text with its baseline starting at {x, y} (PDF points, bottom-left origin) on a 0-indexed page.

Options

  • :font — a Standard-14 font atom: :helvetica, :helvetica_bold, :helvetica_oblique, :helvetica_bold_oblique, :times_roman, :times_bold, :times_italic, :times_bold_italic, :courier, :courier_bold, :courier_oblique, :courier_bold_oblique, :symbol, :zapf_dingbats (default :helvetica). An unknown font → :unknown_font.
  • :size — font size in points (default 12)
  • :color{r, g, b} or {r, g, b, a}, 0–255 (default black)

new()

@spec new() :: {:ok, ExPdfium.Document.t()} | {:error, atom()}

Create a new, empty in-memory PDF document. Add pages with add_page/3 and content with the draw_* functions, then save_to_bytes/1 / save_to_file/2.

Annotating

add_free_text_annotation(doc, page, bounds, text, opts \\ [])

@spec add_free_text_annotation(
  ExPdfium.Document.t(),
  non_neg_integer(),
  bounds(),
  String.t(),
  keyword()
) :: {:ok, ExPdfium.Document.t()} | {:error, atom()}

Add a free-text annotation: a visible text box inside bounds.

bounds is a bounds/0 map (%{left:, bottom:, right:, top:}, PDF points). :fill sets the box's interior background and :stroke its border (each a color or nil, default nil). Returns {:ok, doc}.

Text color

The text itself renders in pdfium's default appearance (black). Setting the FreeText font color needs an FFI entry point (FPDFAnnot_SetFontColor) that the bundled pdfium build does not expose, so no text-color option is offered. For colored text, draw it with draw_text/5 instead.

add_square_annotation(doc, page, bounds, opts \\ [])

@spec add_square_annotation(
  ExPdfium.Document.t(),
  non_neg_integer(),
  bounds(),
  keyword()
) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Add a square (rectangle) annotation filling bounds.

bounds is a bounds/0 map (PDF points). :fill is the interior color (default nil, transparent) and :stroke the border color (default {0, 0, 0}). Returns {:ok, doc}.

add_text_annotation(doc, page, arg, text, opts \\ [])

@spec add_text_annotation(
  ExPdfium.Document.t(),
  non_neg_integer(),
  {number(), number()},
  String.t(),
  keyword()
) :: {:ok, ExPdfium.Document.t()} | {:error, atom()}

Add a text (sticky-note) annotation at a point, in PDF points (bottom-left origin).

The note shows as an icon at {x, y}; text is its popup contents, returned by annotations/2 as :contents. :color (default {255, 230, 0}) sets the icon color. Returns {:ok, doc}.

delete_annotation(doc, page, index)

@spec delete_annotation(ExPdfium.Document.t(), non_neg_integer(), non_neg_integer()) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Delete the annotation at 0-based index on a 0-indexed page.

The index matches the order returned by annotations/2. Returns {:ok, doc}, or {:error, :annotation_not_found} if the index is out of range.

Writing (page assembly)

append(doc, document)

@spec append(ExPdfium.Document.t(), ExPdfium.Document.t()) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Append a copy of every page of source onto the end of doc (merge).

Mutates doc in place and returns {:ok, doc} (the same handle). source is not modified. Appending a document to itself returns {:error, :same_document}. Returns {:error, :document_closed} if either document is closed.

delete_pages(doc, index)

@spec delete_pages(ExPdfium.Document.t(), non_neg_integer() | Range.t()) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Delete a page, or an inclusive range of pages, from doc.

Pass a single 0-indexed page (delete_pages(doc, 3)) or an inclusive, ascending, unit-step range (delete_pages(doc, 2..4) deletes pages 2, 3, and 4). Mutates doc in place and returns {:ok, doc}.

Errors:

  • :page_out_of_bounds — the index/range falls outside the document
  • :cannot_delete_all_pages — the range would remove every page (a zero-page document is degenerate); extract what you want with extract_pages/2 instead
  • :bad_range — a descending or non-unit-step range (e.g. 4..2 or 0..6//2); these are rejected rather than silently reinterpreted

extract_pages(document, indices)

@spec extract_pages(ExPdfium.Document.t(), [non_neg_integer()]) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Build a new document from the given 0-indexed pages of source.

indices is a list of page indices in the desired output order; duplicates are allowed (e.g. [2, 0, 0, 1]). This is the split/subset primitive — splitting a document is a few extract_pages/2 calls. source is left untouched, and the returned document is independent (close/GC it separately).

Returns {:error, :empty_selection} for an empty list, or {:error, :page_out_of_bounds} if any index is out of range (validated before any page is copied, so no partial document is produced).

flatten(doc)

@spec flatten(ExPdfium.Document.t()) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Flatten every page (see flatten_page/2). Returns {:ok, doc}.

flatten_page(doc, page_index)

@spec flatten_page(ExPdfium.Document.t(), non_neg_integer()) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Flatten a 0-indexed page's annotations and form fields into its static content.

After flattening, the annotation/form overlay is baked into the page and renders identically everywhere (and can no longer be edited as annotations). pdfium uses the print appearance. A page with nothing to flatten is a no-op. Mutates doc in place; returns {:ok, doc}.

rotate_page(doc, page_index, degrees)

@spec rotate_page(ExPdfium.Document.t(), non_neg_integer(), 0 | 90 | 180 | 270) ::
  {:ok, ExPdfium.Document.t()} | {:error, atom()}

Set a page's absolute rotation, in degrees.

degrees must be 0, 90, 180, or 270; anything else returns {:error, :bad_rotation}. Mutates doc in place and returns {:ok, doc}. The rotation persists through save_to_bytes/1 / save_to_file/2.

save_to_bytes(document)

@spec save_to_bytes(ExPdfium.Document.t()) :: {:ok, binary()} | {:error, atom()}

Serialize the document to PDF bytes.

A full save (pdfium's FPDF_SaveAsCopy) reflecting any edits made via the writing functions. It does not close or alter doc, so you can keep editing and save again. Returns {:error, :document_closed} if the document has been closed, or {:error, :save_failed} if pdfium cannot serialize it.

The whole document is buffered in memory (there is no streaming save), so peak usage is roughly the document size; this is fine for typical PDFs.

save_to_file(doc, path)

@spec save_to_file(ExPdfium.Document.t(), Path.t()) :: :ok | {:error, atom()}

Save the document to a file at path.

Equivalent to save_to_bytes/1 followed by File.write/2. Returns :ok, or {:error, reason} — either a document error (e.g. :document_closed) or a File.write/2 posix reason (e.g. :enoent, :eacces).

Diagnostics

pdfium_version()

@spec pdfium_version() :: String.t()

Return a marker string confirming the native pdfium library loaded and initialized. Useful as a smoke test that the precompiled NIF is healthy.

pdfium exposes no build-version string through its public C API, so this is a fixed confirmation marker rather than a version number.

Types

bounds()

@type bounds() :: %{left: float(), bottom: float(), right: float(), top: float()}

A bounding rectangle in PDF user-space points (1/72 inch). The origin is the page's bottom-left corner and y increases upward, so top >= bottom.

matrix()

@type matrix() :: %{
  a: float(),
  b: float(),
  c: float(),
  d: float(),
  e: float(),
  f: float()
}

A page object's transformation matrix, the six values a, b, c, d, e, f of the PDF cm transform [a b c d e f]. A point (x, y) in the object's own space maps to (a·x + c·y + e, b·x + d·y + f) on the page.

These are PDF coordinates — origin bottom-left, y increasing up — so a positive atan2(b, a) is a counter-clockwise rotation. Top-left-origin raster libraries (Vix/libvips, Pillow, ImageMagick) are y-down, where the same angle rotates the other way; negate it (or y-flip the image) before applying. See object_display_rotation/3 for the rotation already in raster convention.

For an image object the matrix maps the unit square [0,1]×[0,1] onto the placement, so a/d carry scale, b/c shear/rotation, and e/f the translation.

Content space, not display space

This matrix lives in the page's unrotated content coordinate space. It does not include the page-level /Rotate (the dominant rotation for scanned documents). page_info/2 reports that separately as :rotation (0/90/180/270), and its :width/:height are already display-oriented — a different frame from this matrix. To get an object's as-displayed orientation, compose this matrix with the page rotation; the matrix alone recovers only the transform baked into the object itself.