Skip to main content

lib/pdf_oxide.ex

defmodule PdfOxide do
  @moduledoc """
  Idiomatic Elixir bindings for pdf_oxide — fast PDF text, Markdown and HTML
  extraction, plus building PDFs from Markdown/HTML/text.

  Backed by a NIF over the pdf_oxide C ABI; CPU-bound extraction runs on dirty
  CPU schedulers so it never blocks the BEAM. Handles are NIF resources freed by
  the GC. Functions return `{:ok, value}` / `{:error, code}`; the `!` variants
  raise `PdfOxide.Error`. Page indices are 0-based.
  """

  alias PdfOxide.Native

  defmodule Document do
    @moduledoc "An opened PDF document handle (NIF resource)."
    defstruct [:ref]
  end

  defmodule Pdf do
    @moduledoc "A built PDF handle (NIF resource)."
    defstruct [:ref]
  end

  defmodule DocumentEditor do
    @moduledoc """
    A mutable PDF editing handle (NIF resource). Open one with
    `PdfOxide.open_editor/1` or `PdfOxide.open_editor_from_bytes/1`, mutate it
    in place (rotate/crop/redact/flatten/merge/…) and serialise with
    `PdfOxide.editor_save/2` or `PdfOxide.editor_save_to_bytes/1`. The native
    handle is freed by the GC or eagerly via `PdfOxide.editor_close/1`. Page
    indices are 0-based.
    """
    defstruct [:ref]
  end

  defmodule Page do
    @moduledoc """
    A lightweight view of a single (0-based) page. Holds its `Document` so the
    underlying native handle stays alive as long as the page is referenced.
    """
    defstruct [:doc, :index]
  end

  defmodule Error do
    defexception [:code, :op]
    @impl true
    def message(%{code: code, op: op}),
      do: "pdf_oxide: #{op} failed (error code #{code})"
  end

  defmodule Bbox do
    @moduledoc "An axis-aligned bounding box (PDF user-space units)."
    defstruct [:x, :y, :width, :height]
  end

  defmodule Char do
    @moduledoc "A single extracted character. `character` is a Unicode codepoint (integer)."
    defstruct [:character, :bbox, :font_name, :font_size]
  end

  defmodule Word do
    @moduledoc "An extracted word with its layout/style metadata."
    defstruct [:text, :bbox, :font_name, :font_size, :bold]
  end

  defmodule TextLine do
    @moduledoc "An extracted line of text."
    defstruct [:text, :bbox, :word_count]
  end

  defmodule Table do
    @moduledoc """
    An extracted table. Read a cell's text with `cell/3` (0-based `row`/`col`).
    `cells` holds the cell text as a row-major list of lists.
    """
    defstruct [:row_count, :col_count, :has_header, :cells]
  end

  defmodule Font do
    @moduledoc "An embedded/referenced font on a page."
    defstruct [:name, :type, :encoding, :embedded, :subset]
  end

  defmodule Image do
    @moduledoc "An embedded image. `data` holds its raw bytes."
    defstruct [:width, :height, :bits_per_component, :format, :colorspace, :data]
  end

  defmodule Annotation do
    @moduledoc "A page annotation with its placement and style metadata."
    defstruct [:type, :subtype, :content, :author, :rect, :border_width]
  end

  defmodule Path do
    @moduledoc "An extracted vector path (its bbox and stroke/fill style)."
    defstruct [:bbox, :stroke_width, :has_stroke, :has_fill, :operation_count]
  end

  defmodule FormField do
    @moduledoc "An AcroForm field: its `name`, current `value`, `type`, and flags."
    defstruct [:name, :value, :type, :read_only, :required]
  end

  defmodule SearchResult do
    @moduledoc "A single search hit: its `text`, 0-based `page` and `bbox`."
    defstruct [:text, :page, :bbox]
  end

  defmodule RenderedImage do
    @moduledoc """
    A rendered page raster. `width`/`height` are in pixels and `data` holds the
    encoded image bytes (PNG by default). `ref` is the live native handle kept so
    `PdfOxide.save/2` can write the image with the renderer's own encoder; it is
    freed by the GC.
    """
    defstruct [:ref, :width, :height, :data]
  end

  defmodule DocumentBuilder do
    @moduledoc """
    A PDF *creation* builder handle (NIF resource). Create one with
    `PdfOxide.builder/0`, set metadata, start pages with `builder_page/3`,
    `builder_letter_page/1` or `builder_a4_page/1`, then `builder_build/1` /
    `builder_save/2`. The native handle is freed by the GC or eagerly via
    `PdfOxide.builder_close/1`.
    """
    defstruct [:ref]
  end

  defmodule PageBuilder do
    @moduledoc """
    A page-creation builder handle (NIF resource) started off a
    `DocumentBuilder`. Emit content with the fluent `page_*` ops, then commit it
    to its parent with `PdfOxide.page_done/1` (which consumes the handle) — or
    drop it with `PdfOxide.page_close/1`.
    """
    defstruct [:ref]
  end

  defmodule EmbeddedFont do
    @moduledoc """
    A loaded TTF/OTF font handle (NIF resource) for embedding via
    `PdfOxide.builder_register_embedded_font/3`. A successful register *consumes*
    the font; the wrapper is left holding a freed handle and must not be used
    again. Otherwise free it with `PdfOxide.font_close/1` (also GC-freed).
    """
    defstruct [:ref]
  end

  defmodule Certificate do
    @moduledoc """
    Signing credentials (X.509 certificate + private key) as a native handle.
    Load with `PdfOxide.certificate_from_bytes/2` (PKCS#12) or
    `PdfOxide.certificate_from_pem/2`, read its accessors, and free with
    `PdfOxide.certificate_close/1` (also GC-freed).
    """
    defstruct [:ref]
  end

  defmodule SignatureInfo do
    @moduledoc """
    A parsed PDF signature (native handle). Read its signer/time/reason metadata
    and verify it with `PdfOxide.signature_verify/1` /
    `PdfOxide.signature_verify_detached/2`. Free with
    `PdfOxide.signature_close/1` (also GC-freed).
    """
    defstruct [:ref]
  end

  defmodule Timestamp do
    @moduledoc """
    A parsed RFC 3161 timestamp token (native handle). Free with
    `PdfOxide.timestamp_close/1` (also GC-freed).
    """
    defstruct [:ref]
  end

  defmodule TsaClient do
    @moduledoc """
    An RFC 3161 Time-Stamping Authority client (native handle). Request tokens
    with `PdfOxide.tsa_request_timestamp/2` /
    `PdfOxide.tsa_request_timestamp_hash/3`. Free with
    `PdfOxide.tsa_close/1` (also GC-freed).
    """
    defstruct [:ref]
  end

  defmodule Dss do
    @moduledoc """
    A Document Security Store (native handle): the document-level certs/CRLs/OCSPs
    that back long-term-validation signatures. Free with `PdfOxide.dss_close/1`
    (also GC-freed).
    """
    defstruct [:ref]
  end

  defmodule PdfAResult do
    @moduledoc "Result of a PDF/A validation pass (native handle)."
    defstruct [:ref]
  end

  defmodule PdfUaResult do
    @moduledoc "Result of a PDF/UA accessibility validation pass (native handle)."
    defstruct [:ref]
  end

  defmodule PdfXResult do
    @moduledoc "Result of a PDF/X validation pass (native handle)."
    defstruct [:ref]
  end

  defmodule UaStats do
    @moduledoc "Accessibility element counts from a PDF/UA validation pass."
    defstruct [:struct, :images, :tables, :forms, :annotations, :pages]
  end

  defmodule Barcode do
    @moduledoc """
    A generated/decoded barcode or QR code (native handle). Read its payload,
    format and confidence, render it to PNG/SVG, or stamp it onto an editor page
    with `PdfOxide.add_barcode_to_page/7`. Free with `PdfOxide.barcode_close/1`
    (also GC-freed).
    """
    defstruct [:ref]
  end

  defmodule OcrEngine do
    @moduledoc """
    An OCR engine (native handle) built from detection/recognition model and
    dictionary file paths via `PdfOxide.ocr_engine/3`. Free with
    `PdfOxide.ocr_engine_close/1` (also GC-freed).
    """
    defstruct [:ref]
  end

  defmodule Renderer do
    @moduledoc """
    A reusable page renderer (native handle) with fixed dpi/format/quality/
    anti-aliasing, created with `PdfOxide.renderer/4`. Free with
    `PdfOxide.renderer_close/1` (also GC-freed).
    """
    defstruct [:ref]
  end

  defmodule ElementList do
    @moduledoc """
    An opaque list of page elements (native handle) from
    `PdfOxide.page_elements/2`. Read its length with `PdfOxide.element_count/1`
    (the per-element accessors land in a later phase). Free with
    `PdfOxide.element_list_close/1` (also GC-freed).
    """
    defstruct [:ref]
  end

  # ── Pdf builder ────────────────────────────────────────────────────────────
  @doc "Build a PDF from Markdown."
  def from_markdown(md), do: wrap_pdf(Native.from_markdown(md))
  @doc "Build a PDF from HTML."
  def from_html(html), do: wrap_pdf(Native.from_html(html))
  @doc "Build a PDF from plain text."
  def from_text(text), do: wrap_pdf(Native.from_text(text))

  @doc """
  Write to `path` — a built `Pdf`, or a `RenderedImage` page raster.
  """
  def save(%Pdf{ref: ref}, path), do: Native.pdf_save(ref, path)
  def save(%RenderedImage{ref: ref}, path), do: Native.img_save(ref, path)
  @doc "Serialize a built PDF to a binary."
  def to_bytes(%Pdf{ref: ref}), do: Native.pdf_save_to_bytes(ref)

  @doc "Free a document, built PDF or editor's native handle now (idempotent)."
  def close(%Document{ref: ref}), do: Native.doc_close(ref)
  def close(%Pdf{ref: ref}), do: Native.pdf_close(ref)
  def close(%DocumentEditor{ref: ref}), do: Native.editor_close(ref)

  # ── Document ─────────────────────────────────────────────────────────────────
  @doc "Open a PDF from a path."
  def open(path), do: wrap_doc(Native.doc_open(path))

  @doc "Open a password-protected PDF."
  def open_with_password(path, password), do: wrap_doc(Native.doc_open_pw(path, password))

  @doc "Open a PDF from a binary."
  def open_from_bytes(bytes), do: wrap_doc(Native.doc_open_bytes(bytes))

  @doc "Number of pages."
  def page_count(%Document{ref: ref}), do: Native.doc_page_count(ref)
  @doc "PDF version as `%{major: _, minor: _}`."
  def version(%Document{ref: ref}) do
    {major, minor} = Native.doc_version(ref)
    %{major: major, minor: minor}
  end

  @doc "Whether the document is encrypted."
  def encrypted?(%Document{ref: ref}), do: Native.doc_is_encrypted(ref)
  @doc "Whether the document has a logical structure tree."
  def structure_tree?(%Document{ref: ref}), do: Native.doc_has_structure_tree(ref)

  @doc "Reading-order text for a (0-based) page."
  def extract_text(%Document{ref: ref}, page), do: Native.doc_extract_text(ref, page)
  @doc "Plain text for a page."
  def to_plain_text(%Document{ref: ref}, page), do: Native.doc_to_plain_text(ref, page)
  @doc "Markdown for a page."
  def to_markdown(%Document{ref: ref}, page), do: Native.doc_to_markdown(ref, page)
  @doc "HTML for a page."
  def to_html(%Document{ref: ref}, page), do: Native.doc_to_html(ref, page)
  @doc "Markdown for the whole document."
  def to_markdown_all(%Document{ref: ref}), do: Native.doc_to_markdown_all(ref)
  @doc "HTML for the whole document."
  def to_html_all(%Document{ref: ref}), do: Native.doc_to_html_all(ref)
  @doc "Plain text for the whole document."
  def to_plain_text_all(%Document{ref: ref}), do: Native.doc_to_plain_text_all(ref)

  @doc """
  Authenticate an encrypted document with `password`. Returns `{:ok, true}` on
  success and `{:ok, false}` for a wrong password (not an error).
  """
  def authenticate(%Document{ref: ref}, password), do: Native.doc_authenticate(ref, password)

  @doc "Structured content for a page as a JSON string."
  def extract_structured_json(%Document{ref: ref}, page),
    do: Native.doc_extract_structured_json(ref, page)

  @doc """
  Extract the individual characters of a (0-based) page as a list of `Char`.
  """
  def extract_chars(%Document{ref: ref}, page) do
    with {:ok, list} <- Native.doc_extract_chars(ref, page) do
      {:ok,
       Enum.map(list, fn {cp, x, y, w, h, font, size} ->
         %Char{
           character: cp,
           bbox: %Bbox{x: x, y: y, width: w, height: h},
           font_name: font,
           font_size: size
         }
       end)}
    end
  end

  @doc """
  Extract the words of a (0-based) page as a list of `Word`.
  """
  def extract_words(%Document{ref: ref}, page) do
    with {:ok, list} <- Native.doc_extract_words(ref, page) do
      {:ok,
       Enum.map(list, fn {text, x, y, w, h, font, size, bold} ->
         %Word{
           text: text,
           bbox: %Bbox{x: x, y: y, width: w, height: h},
           font_name: font,
           font_size: size,
           bold: bold
         }
       end)}
    end
  end

  @doc """
  Extract the text lines of a (0-based) page as a list of `TextLine`.
  """
  def extract_text_lines(%Document{ref: ref}, page) do
    with {:ok, list} <- Native.doc_extract_text_lines(ref, page) do
      {:ok,
       Enum.map(list, fn {text, x, y, w, h, word_count} ->
         %TextLine{
           text: text,
           bbox: %Bbox{x: x, y: y, width: w, height: h},
           word_count: word_count
         }
       end)}
    end
  end

  @doc """
  Extract the tables of a (0-based) page as a list of `Table`. Use `cell/3` to
  read a table's (0-based) cell text.
  """
  def extract_tables(%Document{ref: ref}, page) do
    with {:ok, list} <- Native.doc_extract_tables(ref, page) do
      {:ok,
       Enum.map(list, fn {row_count, col_count, has_header, cells} ->
         %Table{
           row_count: row_count,
           col_count: col_count,
           has_header: has_header,
           cells: cells
         }
       end)}
    end
  end

  @doc "Text of a table's (0-based) `row`/`col` cell."
  def cell(%Table{cells: cells}, row, col),
    do: cells |> Enum.at(row, []) |> Enum.at(col)

  @doc """
  Extract the embedded/referenced fonts of a (0-based) page as a list of `Font`.
  """
  def embedded_fonts(%Document{ref: ref}, page) do
    with {:ok, list} <- Native.doc_embedded_fonts(ref, page) do
      {:ok,
       Enum.map(list, fn {name, type, encoding, embedded, subset} ->
         %Font{
           name: name,
           type: type,
           encoding: encoding,
           embedded: embedded,
           subset: subset
         }
       end)}
    end
  end

  @doc """
  Extract the embedded images of a (0-based) page as a list of `Image`.
  """
  def embedded_images(%Document{ref: ref}, page) do
    with {:ok, list} <- Native.doc_embedded_images(ref, page) do
      {:ok,
       Enum.map(list, fn {width, height, bpc, format, colorspace, data} ->
         %Image{
           width: width,
           height: height,
           bits_per_component: bpc,
           format: format,
           colorspace: colorspace,
           data: data
         }
       end)}
    end
  end

  @doc """
  Extract the annotations of a (0-based) page as a list of `Annotation`.
  """
  def page_annotations(%Document{ref: ref}, page) do
    with {:ok, list} <- Native.doc_page_annotations(ref, page) do
      {:ok,
       Enum.map(list, fn {type, subtype, content, author, x, y, w, h, border_width} ->
         %Annotation{
           type: type,
           subtype: subtype,
           content: content,
           author: author,
           rect: %Bbox{x: x, y: y, width: w, height: h},
           border_width: border_width
         }
       end)}
    end
  end

  @doc """
  Extract the vector paths of a (0-based) page as a list of `Path`.
  """
  def extract_paths(%Document{ref: ref}, page) do
    with {:ok, list} <- Native.doc_extract_paths(ref, page) do
      {:ok,
       Enum.map(list, fn {x, y, w, h, stroke_width, has_stroke, has_fill, operation_count} ->
         %Path{
           bbox: %Bbox{x: x, y: y, width: w, height: h},
           stroke_width: stroke_width,
           has_stroke: has_stroke,
           has_fill: has_fill,
           operation_count: operation_count
         }
       end)}
    end
  end

  @doc """
  Search a (0-based) page for `term`, returning a list of `SearchResult`.
  """
  def search(%Document{ref: ref}, page, term, case_sensitive) do
    with {:ok, list} <- Native.doc_search_page(ref, page, term, case_sensitive) do
      {:ok, Enum.map(list, &to_search_result/1)}
    end
  end

  @doc """
  Search the whole document for `term`, returning a list of `SearchResult`.
  """
  def search_all(%Document{ref: ref}, term, case_sensitive) do
    with {:ok, list} <- Native.doc_search_all(ref, term, case_sensitive) do
      {:ok, Enum.map(list, &to_search_result/1)}
    end
  end

  defp to_search_result({text, page, x, y, w, h}),
    do: %SearchResult{text: text, page: page, bbox: %Bbox{x: x, y: y, width: w, height: h}}

  # ── page rendering (phase 3) ─────────────────────────────────────────────────
  @doc """
  Render a (0-based) `page_index` to a `RenderedImage`. `format` is an image
  format code (0 = PNG, the default).
  """
  def render_page(%Document{ref: ref}, page_index, format \\ 0),
    do: wrap_image(Native.doc_render_page(ref, page_index, format))

  @doc """
  Render a (0-based) `page_index` at `zoom` (1.0 = 100%) to a `RenderedImage`.
  `format` is an image format code (0 = PNG, the default).
  """
  def render_page_zoom(%Document{ref: ref}, page_index, zoom, format \\ 0),
    do: wrap_image(Native.doc_render_page_zoom(ref, page_index, zoom * 1.0, format))

  @doc """
  Render a (0-based) `page_index` as a thumbnail fitting `size` pixels on the
  longest side, to a `RenderedImage`. `format` is an image format code
  (0 = PNG, the default).
  """
  def render_page_thumbnail(%Document{ref: ref}, page_index, size, format \\ 0),
    do: wrap_image(Native.doc_render_page_thumbnail(ref, page_index, size, format))

  # ── Page ─────────────────────────────────────────────────────────────────────
  @doc """
  A `Page` view for the (0-based) `index`. The page keeps its document alive, so
  it must not outlive a `close/1` on the document.
  """
  def page(%Document{} = doc, index) when is_integer(index),
    do: %Page{doc: doc, index: index}

  @doc "Reading-order text for the page."
  def text(%Page{doc: doc, index: index}), do: extract_text(doc, index)
  @doc "Markdown for the page."
  def markdown(%Page{doc: doc, index: index}), do: to_markdown(doc, index)
  @doc "HTML for the page."
  def html(%Page{doc: doc, index: index}), do: to_html(doc, index)
  @doc "Plain text for the page."
  def plain_text(%Page{doc: doc, index: index}), do: to_plain_text(doc, index)

  # ── DocumentEditor ───────────────────────────────────────────────────────────
  @doc "Open a PDF for editing from a path."
  def open_editor(path), do: wrap_editor(Native.editor_open(path))
  @doc "Open a PDF for editing from a binary."
  def open_editor_from_bytes(bytes), do: wrap_editor(Native.editor_open_bytes(bytes))

  @doc "Number of pages in the editor."
  def editor_page_count(%DocumentEditor{ref: ref}), do: Native.editor_page_count(ref)
  @doc "PDF version as `%{major: _, minor: _}`."
  def editor_version(%DocumentEditor{ref: ref}) do
    {major, minor} = Native.editor_version(ref)
    %{major: major, minor: minor}
  end

  @doc "Whether the editor has unsaved modifications."
  def editor_modified?(%DocumentEditor{ref: ref}), do: Native.editor_is_modified(ref)
  @doc "The editor's source path (empty for a bytes-opened editor)."
  def editor_source_path(%DocumentEditor{ref: ref}), do: Native.editor_source_path(ref)

  @doc "Read `/Info.Producer`."
  def get_producer(%DocumentEditor{ref: ref}), do: Native.editor_get_producer(ref)
  @doc "Set `/Info.Producer`."
  def set_producer(%DocumentEditor{ref: ref}, value), do: Native.editor_set_producer(ref, value)
  @doc "Read `/Info.CreationDate` as a raw PDF date string."
  def get_creation_date(%DocumentEditor{ref: ref}), do: Native.editor_get_creation_date(ref)
  @doc "Set `/Info.CreationDate` (raw PDF date string)."
  def set_creation_date(%DocumentEditor{ref: ref}, date),
    do: Native.editor_set_creation_date(ref, date)

  @doc "Delete a (0-based) page."
  def delete_page(%DocumentEditor{ref: ref}, page_index),
    do: Native.editor_delete_page(ref, page_index)

  @doc "Move a (0-based) page `from` → `to`."
  def move_page(%DocumentEditor{ref: ref}, from, to), do: Native.editor_move_page(ref, from, to)

  @doc "Rotate a single (0-based) page by `degrees` (additive)."
  def rotate_page_by(%DocumentEditor{ref: ref}, page, degrees),
    do: Native.editor_rotate_page_by(ref, page, degrees)

  @doc "Rotate all pages by `degrees` (relative)."
  def rotate_all_pages(%DocumentEditor{ref: ref}, degrees),
    do: Native.editor_rotate_all_pages(ref, degrees)

  @doc "Set the absolute rotation of a (0-based) page."
  def set_page_rotation(%DocumentEditor{ref: ref}, page, degrees),
    do: Native.editor_set_page_rotation(ref, page, degrees)

  @doc "Rotation (degrees) of a (0-based) page."
  def get_page_rotation(%DocumentEditor{ref: ref}, page),
    do: Native.editor_get_page_rotation(ref, page)

  @doc "Crop `left`/`right`/`top`/`bottom` margins off every page."
  def crop_margins(%DocumentEditor{ref: ref}, left, right, top, bottom),
    do: Native.editor_crop_margins(ref, left * 1.0, right * 1.0, top * 1.0, bottom * 1.0)

  @doc "CropBox of a (0-based) page as a `Bbox`."
  def get_page_crop_box(%DocumentEditor{ref: ref}, page),
    do: wrap_box(Native.editor_get_crop_box(ref, page))

  @doc "Set the CropBox of a (0-based) page."
  def set_page_crop_box(%DocumentEditor{ref: ref}, page, x, y, w, h),
    do: Native.editor_set_crop_box(ref, page, x * 1.0, y * 1.0, w * 1.0, h * 1.0)

  @doc "MediaBox of a (0-based) page as a `Bbox`."
  def get_page_media_box(%DocumentEditor{ref: ref}, page),
    do: wrap_box(Native.editor_get_media_box(ref, page))

  @doc "Set the MediaBox of a (0-based) page."
  def set_page_media_box(%DocumentEditor{ref: ref}, page, x, y, w, h),
    do: Native.editor_set_media_box(ref, page, x * 1.0, y * 1.0, w * 1.0, h * 1.0)

  @doc "Apply (burn in) redactions on a single (0-based) page."
  def apply_page_redactions(%DocumentEditor{ref: ref}, page),
    do: Native.editor_apply_page_redactions(ref, page)

  @doc "Apply all pending redactions across the document."
  def apply_all_redactions(%DocumentEditor{ref: ref}), do: Native.editor_apply_all_redactions(ref)

  @doc "Whether a (0-based) page is marked for redaction."
  def page_marked_for_redaction?(%DocumentEditor{ref: ref}, page),
    do: Native.editor_is_marked_for_redaction(ref, page)

  @doc "Remove the redaction mark from a (0-based) page."
  def unmark_page_for_redaction(%DocumentEditor{ref: ref}, page),
    do: Native.editor_unmark_for_redaction(ref, page)

  @doc "Erase a single rectangular region on a (0-based) page."
  def erase_region(%DocumentEditor{ref: ref}, page, x, y, w, h),
    do: Native.editor_erase_region(ref, page, x * 1.0, y * 1.0, w * 1.0, h * 1.0)

  @doc """
  Erase multiple rectangular regions on a (0-based) page. `rects` is a list of
  `{x, y, w, h}` tuples.
  """
  def erase_regions(%DocumentEditor{ref: ref}, page, rects) when is_list(rects) do
    quads = Enum.map(rects, fn {x, y, w, h} -> {x * 1.0, y * 1.0, w * 1.0, h * 1.0} end)
    Native.editor_erase_regions(ref, page, quads)
  end

  @doc "Clear all pending erase-region entries for a (0-based) page."
  def clear_erase_regions(%DocumentEditor{ref: ref}, page),
    do: Native.editor_clear_erase_regions(ref, page)

  @doc "Flatten annotations on a (0-based) page."
  def flatten_annotations(%DocumentEditor{ref: ref}, page),
    do: Native.editor_flatten_annotations(ref, page)

  @doc "Flatten annotations across the whole document."
  def flatten_all_annotations(%DocumentEditor{ref: ref}),
    do: Native.editor_flatten_all_annotations(ref)

  @doc "Whether a (0-based) page is marked for annotation-flatten."
  def page_marked_for_flatten?(%DocumentEditor{ref: ref}, page),
    do: Native.editor_is_marked_for_flatten(ref, page)

  @doc "Remove the flatten mark from a (0-based) page."
  def unmark_page_for_flatten(%DocumentEditor{ref: ref}, page),
    do: Native.editor_unmark_for_flatten(ref, page)

  @doc "Set a form field value (UTF-8)."
  def set_form_field_value(%DocumentEditor{ref: ref}, name, value),
    do: Native.editor_set_form_field_value(ref, name, value)

  @doc "Flatten all forms (bake field values into page content)."
  def flatten_forms(%DocumentEditor{ref: ref}), do: Native.editor_flatten_forms(ref)

  @doc "Flatten forms on a specific (0-based) page."
  def flatten_forms_on_page(%DocumentEditor{ref: ref}, page_index),
    do: Native.editor_flatten_forms_on_page(ref, page_index)

  @doc "Number of warnings from the last form-flatten."
  def flatten_warnings_count(%DocumentEditor{ref: ref}),
    do: Native.editor_flatten_warnings_count(ref)

  @doc "The `index`-th flatten warning string."
  def flatten_warning(%DocumentEditor{ref: ref}, index),
    do: Native.editor_flatten_warning(ref, index)

  @doc "Merge pages from a source PDF on disk into this document."
  def merge_from(%DocumentEditor{ref: ref}, source_path),
    do: Native.editor_merge_from(ref, source_path)

  @doc "Merge pages from an in-memory PDF binary into this document."
  def merge_from_bytes(%DocumentEditor{ref: ref}, bytes),
    do: Native.editor_merge_from_bytes(ref, bytes)

  @doc """
  Convert to PDF/A in place (`level` 0..7). Works on a `DocumentEditor` or an
  opened `Document`.
  """
  def convert_to_pdf_a(%DocumentEditor{ref: ref}, level),
    do: Native.editor_convert_to_pdf_a(ref, level)

  def convert_to_pdf_a(%Document{ref: ref}, level), do: Native.doc_convert_to_pdf_a(ref, level)

  @doc "Embed a file attachment `name` with `bytes` into the document."
  def embed_file(%DocumentEditor{ref: ref}, name, bytes),
    do: Native.editor_embed_file(ref, name, bytes)

  @doc "Extract a subset of (0-based) `pages` to a new in-memory PDF binary."
  def extract_pages_to_bytes(%DocumentEditor{ref: ref}, pages) when is_list(pages),
    do: Native.editor_extract_pages_to_bytes(ref, pages)

  @doc "Save the edited document to `path`."
  def editor_save(%DocumentEditor{ref: ref}, path), do: Native.editor_save(ref, path)
  @doc "Serialize the edited document to a binary."
  def editor_save_to_bytes(%DocumentEditor{ref: ref}), do: Native.editor_save_to_bytes(ref)

  @doc "Serialize the edited document to bytes with compress/GC/linearize options."
  def editor_save_to_bytes_with_options(
        %DocumentEditor{ref: ref},
        compress,
        garbage_collect,
        linearize
      ),
      do: Native.editor_save_to_bytes_with_options(ref, compress, garbage_collect, linearize)

  @doc "Save the edited document AES-256 encrypted to `path`."
  def editor_save_encrypted(%DocumentEditor{ref: ref}, path, user_password, owner_password),
    do: Native.editor_save_encrypted(ref, path, user_password, owner_password)

  @doc "Serialize the edited document AES-256 encrypted to a binary."
  def editor_save_encrypted_to_bytes(%DocumentEditor{ref: ref}, user_password, owner_password),
    do: Native.editor_save_encrypted_to_bytes(ref, user_password, owner_password)

  @doc "Free the editor's native handle now (idempotent)."
  def editor_close(%DocumentEditor{ref: ref}), do: Native.editor_close(ref)

  # ── EmbeddedFont ─────────────────────────────────────────────────────────────
  @doc "Load a TTF/OTF font from a file path into an `EmbeddedFont`."
  def font_from_file(path), do: wrap_font(Native.font_from_file(path))

  @doc """
  Load a TTF/OTF font from a binary into an `EmbeddedFont`. `name` may be an
  empty string to use the font's own PostScript name.
  """
  def font_from_bytes(bytes, name \\ ""), do: wrap_font(Native.font_from_bytes(bytes, name))

  @doc """
  Free an `EmbeddedFont`'s native handle now (idempotent). A no-op after a
  successful `builder_register_embedded_font/3` consumed the font.
  """
  def font_close(%EmbeddedFont{ref: ref}), do: Native.font_close(ref)

  # ── DocumentBuilder ──────────────────────────────────────────────────────────
  @doc "Create a new PDF-creation `DocumentBuilder`."
  def builder, do: wrap_doc_builder(Native.dbld_create())

  @doc "Set the document title."
  def builder_set_title(%DocumentBuilder{ref: ref}, title), do: Native.dbld_set_title(ref, title)
  @doc "Set the document author."
  def builder_set_author(%DocumentBuilder{ref: ref}, author),
    do: Native.dbld_set_author(ref, author)

  @doc "Set the document subject."
  def builder_set_subject(%DocumentBuilder{ref: ref}, subject),
    do: Native.dbld_set_subject(ref, subject)

  @doc "Set the document keywords (comma-separated)."
  def builder_set_keywords(%DocumentBuilder{ref: ref}, keywords),
    do: Native.dbld_set_keywords(ref, keywords)

  @doc "Set the creator application name."
  def builder_set_creator(%DocumentBuilder{ref: ref}, creator),
    do: Native.dbld_set_creator(ref, creator)

  @doc "Run JavaScript when the document is opened (`/OpenAction`)."
  def builder_on_open(%DocumentBuilder{ref: ref}, script), do: Native.dbld_on_open(ref, script)
  @doc "Set the document's natural language tag (e.g. \"en-US\")."
  def builder_language(%DocumentBuilder{ref: ref}, lang), do: Native.dbld_language(ref, lang)
  @doc "Enable PDF/UA-1 tagged-PDF mode."
  def builder_tagged_pdf_ua1(%DocumentBuilder{ref: ref}), do: Native.dbld_tagged_pdf_ua1(ref)

  @doc "Add a role-map entry: custom structure type → standard PDF structure type."
  def builder_role_map(%DocumentBuilder{ref: ref}, custom, standard),
    do: Native.dbld_role_map(ref, custom, standard)

  @doc """
  Register a TTF/OTF `EmbeddedFont` under `name`. On success the builder
  *consumes* the font handle — do not use or `font_close/1` it afterwards.
  """
  def builder_register_embedded_font(%DocumentBuilder{ref: ref}, name, %EmbeddedFont{ref: fref}),
    do: Native.dbld_register_embedded_font(ref, name, fref)

  @doc "Start a US Letter page, returning a `PageBuilder`."
  def builder_letter_page(%DocumentBuilder{ref: ref}),
    do: wrap_page_builder(Native.dbld_letter_page(ref))

  @doc "Start an A4 page, returning a `PageBuilder`."
  def builder_a4_page(%DocumentBuilder{ref: ref}), do: wrap_page_builder(Native.dbld_a4_page(ref))

  @doc "Start a custom `width`×`height` (PDF points) page, returning a `PageBuilder`."
  def builder_page(%DocumentBuilder{ref: ref}, width, height),
    do: wrap_page_builder(Native.dbld_page(ref, width * 1.0, height * 1.0))

  @doc "Build the PDF and return its bytes."
  def builder_build(%DocumentBuilder{ref: ref}), do: Native.dbld_build(ref)
  @doc "Build and save the PDF to `path`."
  def builder_save(%DocumentBuilder{ref: ref}, path), do: Native.dbld_save(ref, path)

  @doc "Build and save the PDF AES-256 encrypted to `path`."
  def builder_save_encrypted(%DocumentBuilder{ref: ref}, path, user_password, owner_password),
    do: Native.dbld_save_encrypted(ref, path, user_password, owner_password)

  @doc "Build the PDF AES-256 encrypted and return its bytes."
  def builder_to_bytes_encrypted(%DocumentBuilder{ref: ref}, user_password, owner_password),
    do: Native.dbld_to_bytes_encrypted(ref, user_password, owner_password)

  @doc "Free the builder's native handle now (idempotent)."
  def builder_close(%DocumentBuilder{ref: ref}), do: Native.dbld_close(ref)

  # ── PageBuilder ──────────────────────────────────────────────────────────────
  @doc "Set the font + size for subsequent text on this page."
  def page_font(%PageBuilder{ref: ref}, name, size), do: Native.pbld_font(ref, name, size * 1.0)
  @doc "Move the cursor to absolute `(x, y)` (PDF points, from lower-left)."
  def page_at(%PageBuilder{ref: ref}, x, y), do: Native.pbld_at(ref, x * 1.0, y * 1.0)
  @doc "Emit a line of text at the cursor, then advance one line-height."
  def page_text(%PageBuilder{ref: ref}, text), do: Native.pbld_text(ref, text)
  @doc "Emit a heading with `level` (1–6) and `text`."
  def page_heading(%PageBuilder{ref: ref}, level, text), do: Native.pbld_heading(ref, level, text)
  @doc "Emit a paragraph with automatic line wrapping."
  def page_paragraph(%PageBuilder{ref: ref}, text), do: Native.pbld_paragraph(ref, text)
  @doc "Advance the cursor down by `points`."
  def page_space(%PageBuilder{ref: ref}, points), do: Native.pbld_space(ref, points * 1.0)
  @doc "Draw a horizontal rule across the page."
  def page_horizontal_rule(%PageBuilder{ref: ref}), do: Native.pbld_horizontal_rule(ref)
  @doc "Attach a URL link to the previously-emitted text element."
  def page_link_url(%PageBuilder{ref: ref}, url), do: Native.pbld_link_url(ref, url)
  @doc "Link the previous text to an internal (0-based) `page_index`."
  def page_link_page(%PageBuilder{ref: ref}, page_index),
    do: Native.pbld_link_page(ref, page_index)

  @doc "Link the previous text to a named destination."
  def page_link_named(%PageBuilder{ref: ref}, destination),
    do: Native.pbld_link_named(ref, destination)

  @doc "Link the previous text to a JavaScript action."
  def page_link_javascript(%PageBuilder{ref: ref}, script),
    do: Native.pbld_link_javascript(ref, script)

  @doc "Run JavaScript when this page is opened (`/AA /O`)."
  def page_on_open(%PageBuilder{ref: ref}, script), do: Native.pbld_on_open(ref, script)
  @doc "Run JavaScript when this page is closed (`/AA /C`)."
  def page_on_close(%PageBuilder{ref: ref}, script), do: Native.pbld_on_close(ref, script)
  @doc "Set a keystroke JS action on the most-recently-added form field."
  def page_field_keystroke(%PageBuilder{ref: ref}, script),
    do: Native.pbld_field_keystroke(ref, script)

  @doc "Set a format JS action on the most-recently-added form field."
  def page_field_format(%PageBuilder{ref: ref}, script), do: Native.pbld_field_format(ref, script)
  @doc "Set a validate JS action on the most-recently-added form field."
  def page_field_validate(%PageBuilder{ref: ref}, script),
    do: Native.pbld_field_validate(ref, script)

  @doc "Set a calculate JS action on the most-recently-added form field."
  def page_field_calculate(%PageBuilder{ref: ref}, script),
    do: Native.pbld_field_calculate(ref, script)

  @doc "Highlight the previous text with an RGB colour (channels 0.0–1.0)."
  def page_highlight(%PageBuilder{ref: ref}, r, g, b),
    do: Native.pbld_highlight(ref, r * 1.0, g * 1.0, b * 1.0)

  @doc "Underline the previous text (RGB 0.0–1.0)."
  def page_underline(%PageBuilder{ref: ref}, r, g, b),
    do: Native.pbld_underline(ref, r * 1.0, g * 1.0, b * 1.0)

  @doc "Strikeout the previous text (RGB 0.0–1.0)."
  def page_strikeout(%PageBuilder{ref: ref}, r, g, b),
    do: Native.pbld_strikeout(ref, r * 1.0, g * 1.0, b * 1.0)

  @doc "Squiggly-underline the previous text (RGB 0.0–1.0)."
  def page_squiggly(%PageBuilder{ref: ref}, r, g, b),
    do: Native.pbld_squiggly(ref, r * 1.0, g * 1.0, b * 1.0)

  @doc "Attach a sticky-note annotation to the previous text."
  def page_sticky_note(%PageBuilder{ref: ref}, text), do: Native.pbld_sticky_note(ref, text)
  @doc "Place a free-standing sticky note at absolute `(x, y)`."
  def page_sticky_note_at(%PageBuilder{ref: ref}, x, y, text),
    do: Native.pbld_sticky_note_at(ref, x * 1.0, y * 1.0, text)

  @doc "Apply a text watermark to the entire page."
  def page_watermark(%PageBuilder{ref: ref}, text), do: Native.pbld_watermark(ref, text)
  @doc "Apply the standard \"CONFIDENTIAL\" diagonal watermark."
  def page_watermark_confidential(%PageBuilder{ref: ref}),
    do: Native.pbld_watermark_confidential(ref)

  @doc "Apply the standard \"DRAFT\" diagonal watermark."
  def page_watermark_draft(%PageBuilder{ref: ref}), do: Native.pbld_watermark_draft(ref)
  @doc "Attach a standard stamp annotation by `type_name`."
  def page_stamp(%PageBuilder{ref: ref}, type_name), do: Native.pbld_stamp(ref, type_name)

  @doc "Place a free-flowing text annotation inside the given rectangle."
  def page_freetext(%PageBuilder{ref: ref}, x, y, w, h, text),
    do: Native.pbld_freetext(ref, x * 1.0, y * 1.0, w * 1.0, h * 1.0, text)

  @doc """
  Add a single-line text form field. `default_value` may be an empty string for
  a blank field.
  """
  def page_text_field(%PageBuilder{ref: ref}, name, x, y, w, h, default_value \\ ""),
    do: Native.pbld_text_field(ref, name, x * 1.0, y * 1.0, w * 1.0, h * 1.0, default_value)

  @doc "Add a checkbox form field. `checked` is non-zero for initially-ticked."
  def page_checkbox(%PageBuilder{ref: ref}, name, x, y, w, h, checked),
    do: Native.pbld_checkbox(ref, name, x * 1.0, y * 1.0, w * 1.0, h * 1.0, checked)

  @doc """
  Add a dropdown combo-box. `options` is a list of strings; `selected` may be an
  empty string for no initial selection.
  """
  def page_combo_box(%PageBuilder{ref: ref}, name, x, y, w, h, options, selected \\ "")
      when is_list(options),
      do: Native.pbld_combo_box(ref, name, x * 1.0, y * 1.0, w * 1.0, h * 1.0, options, selected)

  @doc """
  Add a radio-button group. `values`/`xs`/`ys`/`ws`/`hs` are parallel lists
  describing each button; `selected` may be an empty string.
  """
  def page_radio_group(%PageBuilder{ref: ref}, name, values, xs, ys, ws, hs, selected \\ "")
      when is_list(values) do
    f = fn list -> Enum.map(list, &(&1 * 1.0)) end
    Native.pbld_radio_group(ref, name, values, f.(xs), f.(ys), f.(ws), f.(hs), selected)
  end

  @doc "Add a clickable push button with a visible caption."
  def page_push_button(%PageBuilder{ref: ref}, name, x, y, w, h, caption),
    do: Native.pbld_push_button(ref, name, x * 1.0, y * 1.0, w * 1.0, h * 1.0, caption)

  @doc "Add an unsigned signature placeholder field."
  def page_signature_field(%PageBuilder{ref: ref}, name, x, y, w, h),
    do: Native.pbld_signature_field(ref, name, x * 1.0, y * 1.0, w * 1.0, h * 1.0)

  @doc "Add a footnote: inline `ref_mark` + page-end `note_text`."
  def page_footnote(%PageBuilder{ref: ref}, ref_mark, note_text),
    do: Native.pbld_footnote(ref, ref_mark, note_text)

  @doc "Lay out `text` across `column_count` balanced columns with `gap_pt` between."
  def page_columns(%PageBuilder{ref: ref}, column_count, gap_pt, text),
    do: Native.pbld_columns(ref, column_count, gap_pt * 1.0, text)

  @doc "Emit `text` inline at the cursor (advances x, not y)."
  def page_inline(%PageBuilder{ref: ref}, text), do: Native.pbld_inline(ref, text)
  @doc "Emit an inline bold run."
  def page_inline_bold(%PageBuilder{ref: ref}, text), do: Native.pbld_inline_bold(ref, text)
  @doc "Emit an inline italic run."
  def page_inline_italic(%PageBuilder{ref: ref}, text), do: Native.pbld_inline_italic(ref, text)
  @doc "Emit an inline coloured run (RGB 0.0–1.0)."
  def page_inline_color(%PageBuilder{ref: ref}, r, g, b, text),
    do: Native.pbld_inline_color(ref, r * 1.0, g * 1.0, b * 1.0, text)

  @doc "Advance the cursor one line-height and reset x."
  def page_newline(%PageBuilder{ref: ref}), do: Native.pbld_newline(ref)

  @doc """
  Place a 1-D barcode. `barcode_type`: 0=Code128 1=Code39 2=EAN13 3=EAN8
  4=UPCA 5=ITF 6=Code93 7=Codabar.
  """
  def page_barcode_1d(%PageBuilder{ref: ref}, barcode_type, data, x, y, w, h),
    do: Native.pbld_barcode_1d(ref, barcode_type, data, x * 1.0, y * 1.0, w * 1.0, h * 1.0)

  @doc "Place a QR-code image (square `size`×`size` points)."
  def page_barcode_qr(%PageBuilder{ref: ref}, data, x, y, size),
    do: Native.pbld_barcode_qr(ref, data, x * 1.0, y * 1.0, size * 1.0)

  @doc "Embed an image (raw JPEG/PNG bytes) at `(x, y, w, h)`."
  def page_image(%PageBuilder{ref: ref}, bytes, x, y, w, h),
    do: Native.pbld_image(ref, bytes, x * 1.0, y * 1.0, w * 1.0, h * 1.0)

  @doc "Embed an image with accessibility `alt_text` at `(x, y, w, h)`."
  def page_image_with_alt(%PageBuilder{ref: ref}, bytes, x, y, w, h, alt_text),
    do: Native.pbld_image_with_alt(ref, bytes, x * 1.0, y * 1.0, w * 1.0, h * 1.0, alt_text)

  @doc "Embed a decorative image as an `/Artifact` (no alt text)."
  def page_image_artifact(%PageBuilder{ref: ref}, bytes, x, y, w, h),
    do: Native.pbld_image_artifact(ref, bytes, x * 1.0, y * 1.0, w * 1.0, h * 1.0)

  @doc "Draw a stroked rectangle outline (1pt black)."
  def page_rect(%PageBuilder{ref: ref}, x, y, w, h),
    do: Native.pbld_rect(ref, x * 1.0, y * 1.0, w * 1.0, h * 1.0)

  @doc "Draw a filled rectangle in RGB colour (channels 0–1)."
  def page_filled_rect(%PageBuilder{ref: ref}, x, y, w, h, r, g, b),
    do:
      Native.pbld_filled_rect(ref, x * 1.0, y * 1.0, w * 1.0, h * 1.0, r * 1.0, g * 1.0, b * 1.0)

  @doc "Draw a line from `(x1, y1)` to `(x2, y2)` (1pt black)."
  def page_line(%PageBuilder{ref: ref}, x1, y1, x2, y2),
    do: Native.pbld_line(ref, x1 * 1.0, y1 * 1.0, x2 * 1.0, y2 * 1.0)

  @doc "Buffer a stroked rectangle with `width` + RGB colour."
  def page_stroke_rect(%PageBuilder{ref: ref}, x, y, w, h, width, r, g, b),
    do:
      Native.pbld_stroke_rect(
        ref,
        x * 1.0,
        y * 1.0,
        w * 1.0,
        h * 1.0,
        width * 1.0,
        r * 1.0,
        g * 1.0,
        b * 1.0
      )

  @doc "Buffer a stroked line with `width` + RGB colour."
  def page_stroke_line(%PageBuilder{ref: ref}, x1, y1, x2, y2, width, r, g, b),
    do:
      Native.pbld_stroke_line(
        ref,
        x1 * 1.0,
        y1 * 1.0,
        x2 * 1.0,
        y2 * 1.0,
        width * 1.0,
        r * 1.0,
        g * 1.0,
        b * 1.0
      )

  @doc """
  Buffer a dashed stroked rectangle. `dash` is a list of alternating on/off
  lengths (empty = solid); `phase` is the starting offset.
  """
  def page_stroke_rect_dashed(%PageBuilder{ref: ref}, x, y, w, h, width, r, g, b, dash, phase)
      when is_list(dash),
      do:
        Native.pbld_stroke_rect_dashed(
          ref,
          x * 1.0,
          y * 1.0,
          w * 1.0,
          h * 1.0,
          width * 1.0,
          r * 1.0,
          g * 1.0,
          b * 1.0,
          Enum.map(dash, &(&1 * 1.0)),
          phase * 1.0
        )

  @doc """
  Buffer a dashed stroked line. `dash` is a list of alternating on/off lengths
  (empty = solid); `phase` is the starting offset.
  """
  def page_stroke_line_dashed(%PageBuilder{ref: ref}, x1, y1, x2, y2, width, r, g, b, dash, phase)
      when is_list(dash),
      do:
        Native.pbld_stroke_line_dashed(
          ref,
          x1 * 1.0,
          y1 * 1.0,
          x2 * 1.0,
          y2 * 1.0,
          width * 1.0,
          r * 1.0,
          g * 1.0,
          b * 1.0,
          Enum.map(dash, &(&1 * 1.0)),
          phase * 1.0
        )

  @doc "Buffer text inside a rect. `align`: 0=Left, 1=Center, 2=Right."
  def page_text_in_rect(%PageBuilder{ref: ref}, x, y, w, h, text, align),
    do: Native.pbld_text_in_rect(ref, x * 1.0, y * 1.0, w * 1.0, h * 1.0, text, align)

  @doc "Buffer a same-size page transition; later ops land on the new page."
  def page_new_page_same_size(%PageBuilder{ref: ref}), do: Native.pbld_new_page_same_size(ref)

  @doc """
  Buffer a buffered table. `widths`/`aligns` are length-`n_columns` lists
  (`aligns`: 0/1/2); `cells` is a row-major list of `n_rows * n_columns`
  strings. `has_header` promotes the first row.
  """
  def page_table(%PageBuilder{ref: ref}, n_columns, widths, aligns, n_rows, cells, has_header)
      when is_list(widths) and is_list(aligns) and is_list(cells),
      do:
        Native.pbld_table(
          ref,
          n_columns,
          Enum.map(widths, &(&1 * 1.0)),
          aligns,
          n_rows,
          cells,
          has_header
        )

  @doc """
  Open a streaming table. `headers` is a list of column header strings;
  `widths`/`aligns` are parallel lists. `repeat_header` is non-zero to repeat
  the header on each page.
  """
  def page_streaming_table_begin(
        %PageBuilder{ref: ref},
        n_columns,
        headers,
        widths,
        aligns,
        repeat_header
      )
      when is_list(headers) and is_list(widths) and is_list(aligns),
      do:
        Native.pbld_streaming_table_begin(
          ref,
          n_columns,
          headers,
          Enum.map(widths, &(&1 * 1.0)),
          aligns,
          repeat_header
        )

  @doc """
  Open a streaming table with a column-width `mode` (0=Fixed, 1=Sample,
  2=AutoAll) plus sampling/rowspan parameters.
  """
  def page_streaming_table_begin_v2(
        %PageBuilder{ref: ref},
        n_columns,
        headers,
        widths,
        aligns,
        repeat_header,
        mode,
        sample_rows,
        min_w,
        max_w,
        max_rowspan
      )
      when is_list(headers) and is_list(widths) and is_list(aligns),
      do:
        Native.pbld_streaming_table_begin_v2(
          ref,
          n_columns,
          headers,
          Enum.map(widths, &(&1 * 1.0)),
          aligns,
          repeat_header,
          mode,
          sample_rows,
          min_w * 1.0,
          max_w * 1.0,
          max_rowspan
        )

  @doc "Set the auto-flush batch size for the open streaming table (0 = default 256)."
  def page_streaming_table_set_batch_size(%PageBuilder{ref: ref}, batch_size),
    do: Native.pbld_streaming_table_set_batch_size(ref, batch_size)

  @doc "Rows pushed since the last batch boundary."
  def page_streaming_table_pending_row_count(%PageBuilder{ref: ref}),
    do: Native.pbld_streaming_table_pending_row_count(ref)

  @doc "Complete batches recorded so far."
  def page_streaming_table_batch_count(%PageBuilder{ref: ref}),
    do: Native.pbld_streaming_table_batch_count(ref)

  @doc "Push one row (list of cell strings) into the open streaming table."
  def page_streaming_table_push_row(%PageBuilder{ref: ref}, cells) when is_list(cells),
    do: Native.pbld_streaming_table_push_row(ref, cells)

  @doc """
  Push one row with per-cell `rowspans` (list of ints, empty = all rowspan=1).
  """
  def page_streaming_table_push_row_v2(%PageBuilder{ref: ref}, cells, rowspans)
      when is_list(cells) and is_list(rowspans),
      do: Native.pbld_streaming_table_push_row_v2(ref, cells, rowspans)

  @doc "Mark a batch boundary in the open streaming table."
  def page_streaming_table_flush(%PageBuilder{ref: ref}),
    do: Native.pbld_streaming_table_flush(ref)

  @doc "Close the open streaming table."
  def page_streaming_table_finish(%PageBuilder{ref: ref}),
    do: Native.pbld_streaming_table_finish(ref)

  @doc """
  Commit this page's buffered ops to its parent builder. *Consumes* the handle —
  the wrapper must not be used (or `page_close/1`-d) afterwards.
  """
  def page_done(%PageBuilder{ref: ref}), do: Native.pbld_done(ref)

  @doc "Drop an uncommitted page builder's native handle now (idempotent)."
  def page_close(%PageBuilder{ref: ref}), do: Native.pbld_close(ref)

  # ── Certificate (phase 6) ────────────────────────────────────────────────────
  @doc """
  Load signing credentials from a PKCS#12 (.p12/.pfx) binary, decrypted with
  `password` (empty string for none).
  """
  def certificate_from_bytes(bytes, password \\ "") when is_binary(bytes),
    do: wrap_certificate(Native.cert_load_from_bytes(bytes, password))

  @doc "Load signing credentials from PEM-encoded certificate + private-key strings."
  def certificate_from_pem(cert_pem, key_pem),
    do: wrap_certificate(Native.cert_load_from_pem(cert_pem, key_pem))

  @doc "The certificate's subject distinguished name."
  def certificate_subject(%Certificate{ref: ref}), do: Native.cert_get_subject(ref)
  @doc "The certificate's issuer distinguished name."
  def certificate_issuer(%Certificate{ref: ref}), do: Native.cert_get_issuer(ref)
  @doc "The certificate's serial number (string)."
  def certificate_serial(%Certificate{ref: ref}), do: Native.cert_get_serial(ref)

  @doc """
  The certificate's validity window as `{:ok, {not_before, not_after}}` (Unix
  epoch seconds).
  """
  def certificate_validity(%Certificate{ref: ref}), do: Native.cert_get_validity(ref)

  @doc "Whether the certificate is currently valid (1 = valid; see C ABI codes)."
  def certificate_valid?(%Certificate{ref: ref}), do: Native.cert_is_valid(ref)

  @doc "Free a certificate's native handle now (idempotent)."
  def certificate_close(%Certificate{ref: ref}), do: Native.cert_close(ref)

  # ── Signing (phase 6) ─────────────────────────────────────────────────────────
  @doc "Sign raw PDF `bytes` with `certificate`, returning the signed PDF binary."
  def sign_bytes(bytes, %Certificate{ref: cref}, reason \\ "", location \\ "")
      when is_binary(bytes),
      do: Native.sign_bytes(bytes, cref, reason, location)

  @doc """
  PAdES-sign raw PDF `bytes`. `level`: 0=B-B 1=B-T 2=B-LT. `tsa_url` may be an
  empty string for B-B. `certs`/`crls`/`ocsps` are lists of DER binaries
  carrying B-LT revocation material (empty lists for B-B/B-T).
  """
  def sign_bytes_pades(bytes, %Certificate{ref: cref}, level, tsa_url \\ "", opts \\ [])
      when is_binary(bytes) and is_integer(level) and is_list(opts) do
    Native.sign_bytes_pades(
      bytes,
      cref,
      level,
      tsa_url,
      Keyword.get(opts, :reason, ""),
      Keyword.get(opts, :location, ""),
      Keyword.get(opts, :certs, []),
      Keyword.get(opts, :crls, []),
      Keyword.get(opts, :ocsps, [])
    )
  end

  @doc """
  Struct-options variant of `sign_bytes_pades/5` — marshals the same parameters
  into the C `PadesSignOptionsC` struct. Returns the signed PDF binary.
  """
  def sign_bytes_pades_opts(bytes, %Certificate{ref: cref}, level, tsa_url \\ "", opts \\ [])
      when is_binary(bytes) and is_integer(level) and is_list(opts) do
    Native.sign_bytes_pades_opts(
      bytes,
      cref,
      level,
      tsa_url,
      Keyword.get(opts, :reason, ""),
      Keyword.get(opts, :location, ""),
      Keyword.get(opts, :certs, []),
      Keyword.get(opts, :crls, []),
      Keyword.get(opts, :ocsps, [])
    )
  end

  # ── SignatureInfo (phase 6) ────────────────────────────────────────────────────
  @doc "The signer's name."
  def signature_signer_name(%SignatureInfo{ref: ref}), do: Native.sig_get_signer_name(ref)
  @doc "The stated signing reason."
  def signature_reason(%SignatureInfo{ref: ref}), do: Native.sig_get_signing_reason(ref)
  @doc "The stated signing location."
  def signature_location(%SignatureInfo{ref: ref}), do: Native.sig_get_signing_location(ref)
  @doc "The signing time as `{:ok, epoch_seconds}`."
  def signature_time(%SignatureInfo{ref: ref}), do: Native.sig_get_signing_time(ref)

  @doc "The signer's `Certificate` handle."
  def signature_certificate(%SignatureInfo{ref: ref}),
    do: wrap_certificate(Native.sig_get_certificate(ref))

  @doc "The signature's PAdES level as `{:ok, level}` (-1 if unknown)."
  def signature_pades_level(%SignatureInfo{ref: ref}), do: Native.sig_get_pades_level(ref)
  @doc "Whether the signature carries an embedded timestamp."
  def signature_has_timestamp?(%SignatureInfo{ref: ref}), do: Native.sig_has_timestamp(ref)

  @doc "The signature's embedded `Timestamp` handle."
  def signature_timestamp(%SignatureInfo{ref: ref}),
    do: wrap_timestamp(Native.sig_get_timestamp(ref))

  @doc "Attach `timestamp` to the signature; returns `{:ok, bool}`."
  def signature_add_timestamp(%SignatureInfo{ref: ref}, %Timestamp{ref: tref}),
    do: Native.sig_add_timestamp(ref, tref)

  @doc "Run the signer-attributes crypto check; returns `{:ok, code}` (1/0/-1)."
  def signature_verify(%SignatureInfo{ref: ref}), do: Native.sig_verify(ref)

  @doc """
  Verify the signature end-to-end against the full PDF `bytes`; returns
  `{:ok, code}` (1/0/-1).
  """
  def signature_verify_detached(%SignatureInfo{ref: ref}, bytes) when is_binary(bytes),
    do: Native.sig_verify_detached(ref, bytes)

  @doc "Free a signature's native handle now (idempotent)."
  def signature_close(%SignatureInfo{ref: ref}), do: Native.sig_close(ref)

  # ── Timestamp (phase 6) ────────────────────────────────────────────────────────
  @doc "Parse a DER-encoded RFC 3161 timestamp token into a `Timestamp`."
  def timestamp_parse(bytes) when is_binary(bytes),
    do: wrap_timestamp(Native.ts_parse(bytes))

  @doc "The raw DER token bytes."
  def timestamp_token(%Timestamp{ref: ref}), do: Native.ts_get_token(ref)
  @doc "The message-imprint hash bytes."
  def timestamp_message_imprint(%Timestamp{ref: ref}), do: Native.ts_get_message_imprint(ref)
  @doc "The timestamp time as `{:ok, epoch_seconds}`."
  def timestamp_time(%Timestamp{ref: ref}), do: Native.ts_get_time(ref)
  @doc "The timestamp serial number (string)."
  def timestamp_serial(%Timestamp{ref: ref}), do: Native.ts_get_serial(ref)
  @doc "The issuing TSA name."
  def timestamp_tsa_name(%Timestamp{ref: ref}), do: Native.ts_get_tsa_name(ref)
  @doc "The timestamp policy OID (string)."
  def timestamp_policy_oid(%Timestamp{ref: ref}), do: Native.ts_get_policy_oid(ref)
  @doc "The hash-algorithm code as `{:ok, code}`."
  def timestamp_hash_algorithm(%Timestamp{ref: ref}), do: Native.ts_get_hash_algorithm(ref)
  @doc "Verify the timestamp token; returns `{:ok, bool}`."
  def timestamp_verify(%Timestamp{ref: ref}), do: Native.ts_verify(ref)

  @doc "Free a timestamp's native handle now (idempotent)."
  def timestamp_close(%Timestamp{ref: ref}), do: Native.ts_close(ref)

  # ── TsaClient (phase 6) ────────────────────────────────────────────────────────
  @doc """
  Create an RFC 3161 TSA client for `url`. `opts`: `:username`, `:password`
  (empty for none), `:timeout` (seconds), `:hash_algo`, `:use_nonce`,
  `:cert_req`.
  """
  def tsa_client(url, opts \\ []) when is_list(opts) do
    wrap_tsa(
      Native.tsa_create(
        url,
        Keyword.get(opts, :username, ""),
        Keyword.get(opts, :password, ""),
        Keyword.get(opts, :timeout, 30),
        Keyword.get(opts, :hash_algo, 0),
        Keyword.get(opts, :use_nonce, true),
        Keyword.get(opts, :cert_req, true)
      )
    )
  end

  @doc "Request a timestamp over `data`, returning a `Timestamp`."
  def tsa_request_timestamp(%TsaClient{ref: ref}, data) when is_binary(data),
    do: wrap_timestamp(Native.tsa_request_timestamp(ref, data))

  @doc "Request a timestamp over a precomputed `hash` (with `hash_algo`)."
  def tsa_request_timestamp_hash(%TsaClient{ref: ref}, hash, hash_algo)
      when is_binary(hash) and is_integer(hash_algo),
      do: wrap_timestamp(Native.tsa_request_timestamp_hash(ref, hash, hash_algo))

  @doc "Free a TSA client's native handle now (idempotent)."
  def tsa_close(%TsaClient{ref: ref}), do: Native.tsa_close(ref)

  # ── Dss (phase 6) ──────────────────────────────────────────────────────────────
  @doc "Number of certs in the DSS."
  def dss_cert_count(%Dss{ref: ref}), do: Native.dss_cert_count(ref)
  @doc "Number of CRLs in the DSS."
  def dss_crl_count(%Dss{ref: ref}), do: Native.dss_crl_count(ref)
  @doc "Number of OCSP responses in the DSS."
  def dss_ocsp_count(%Dss{ref: ref}), do: Native.dss_ocsp_count(ref)
  @doc "Number of VRI (validation-related-info) entries in the DSS."
  def dss_vri_count(%Dss{ref: ref}), do: Native.dss_vri_count(ref)
  @doc "The `index`-th DSS cert as `{:ok, der_bytes}`."
  def dss_cert(%Dss{ref: ref}, index), do: Native.dss_get_cert(ref, index)
  @doc "The `index`-th DSS CRL as `{:ok, der_bytes}`."
  def dss_crl(%Dss{ref: ref}, index), do: Native.dss_get_crl(ref, index)
  @doc "The `index`-th DSS OCSP response as `{:ok, der_bytes}`."
  def dss_ocsp(%Dss{ref: ref}, index), do: Native.dss_get_ocsp(ref, index)

  @doc "Free a DSS native handle now (idempotent)."
  def dss_close(%Dss{ref: ref}), do: Native.dss_close(ref)

  # ── Validation (phase 6) ───────────────────────────────────────────────────────
  @doc "Validate a `Document` against PDF/A `level`, returning a `PdfAResult`."
  def validate_pdf_a(%Document{ref: ref}, level),
    do: wrap_pdf_a(Native.validate_pdf_a(ref, level))

  @doc "Validate a `Document` against PDF/UA `level`, returning a `PdfUaResult`."
  def validate_pdf_ua(%Document{ref: ref}, level),
    do: wrap_pdf_ua(Native.validate_pdf_ua(ref, level))

  @doc "Validate a `Document` against PDF/X `level`, returning a `PdfXResult`."
  def validate_pdf_x(%Document{ref: ref}, level),
    do: wrap_pdf_x(Native.validate_pdf_x(ref, level))

  @doc "Whether the PDF/A result is compliant; returns `{:ok, bool}`."
  def pdf_a_compliant?(%PdfAResult{ref: ref}), do: Native.pdf_a_is_compliant(ref)
  @doc "PDF/A validation errors as a list of strings."
  def pdf_a_errors(%PdfAResult{ref: ref}),
    do: result_strings(ref, &Native.pdf_a_error_count/1, &Native.pdf_a_get_error/2)

  @doc "PDF/A validation warnings count (no warning accessor in the C ABI)."
  def pdf_a_warning_count(%PdfAResult{ref: ref}), do: Native.pdf_a_warning_count(ref)
  @doc "Free a PDF/A result's native handle now (idempotent)."
  def pdf_a_close(%PdfAResult{ref: ref}), do: Native.pdf_a_close(ref)

  @doc "Whether the PDF/UA result is accessible; returns `{:ok, bool}`."
  def pdf_ua_accessible?(%PdfUaResult{ref: ref}), do: Native.pdf_ua_is_accessible(ref)
  @doc "PDF/UA validation errors as a list of strings."
  def pdf_ua_errors(%PdfUaResult{ref: ref}),
    do: result_strings(ref, &Native.pdf_ua_error_count/1, &Native.pdf_ua_get_error/2)

  @doc "PDF/UA validation warnings as a list of strings."
  def pdf_ua_warnings(%PdfUaResult{ref: ref}),
    do: result_strings(ref, &Native.pdf_ua_warning_count/1, &Native.pdf_ua_get_warning/2)

  @doc "Accessibility element counts as a `{:ok, %UaStats{}}`."
  def pdf_ua_stats(%PdfUaResult{ref: ref}) do
    with {:ok, {s, im, t, f, an, pg}} <- Native.pdf_ua_get_stats(ref) do
      {:ok, %UaStats{struct: s, images: im, tables: t, forms: f, annotations: an, pages: pg}}
    end
  end

  @doc "Free a PDF/UA result's native handle now (idempotent)."
  def pdf_ua_close(%PdfUaResult{ref: ref}), do: Native.pdf_ua_close(ref)

  @doc "Whether the PDF/X result is compliant; returns `{:ok, bool}`."
  def pdf_x_compliant?(%PdfXResult{ref: ref}), do: Native.pdf_x_is_compliant(ref)
  @doc "PDF/X validation errors as a list of strings."
  def pdf_x_errors(%PdfXResult{ref: ref}),
    do: result_strings(ref, &Native.pdf_x_error_count/1, &Native.pdf_x_get_error/2)

  @doc "Free a PDF/X result's native handle now (idempotent)."
  def pdf_x_close(%PdfXResult{ref: ref}), do: Native.pdf_x_close(ref)

  # ── log level (phase 6) ────────────────────────────────────────────────────────
  @doc "Set the global log level (0=Off 1=Error 2=Warn 3=Info 4=Debug 5=Trace)."
  def set_log_level(level) when is_integer(level), do: Native.oxide_set_log_level(level)
  @doc "Get the current global log level (0-5)."
  def get_log_level, do: Native.oxide_get_log_level()

  # ── helpers ──────────────────────────────────────────────────────────────────
  # ── phase 7: barcodes / QR ───────────────────────────────────────────────────
  @doc """
  Generate a QR code from `data`. `error_correction` (0=L 1=M 2=Q 3=H) and
  `size_px` tune the symbol. Returns `{:ok, %Barcode{}}`.
  """
  def generate_qr_code(data, error_correction \\ 1, size_px \\ 256),
    do: wrap_barcode(Native.barcode_generate_qr(data, error_correction, size_px))

  @doc """
  Generate a 1-D/2-D barcode from `data`. `format` is a barcode-format code;
  `size_px` is the rendered size. Returns `{:ok, %Barcode{}}`.
  """
  def generate_barcode(data, format \\ 0, size_px \\ 256),
    do: wrap_barcode(Native.barcode_generate(data, format, size_px))

  @doc "The barcode's encoded data string."
  def barcode_data(%Barcode{ref: ref}), do: Native.barcode_get_data(ref)
  @doc "The barcode's format code."
  def barcode_format(%Barcode{ref: ref}), do: Native.barcode_get_format(ref)
  @doc "The barcode's decode confidence (0.0–1.0)."
  def barcode_confidence(%Barcode{ref: ref}), do: Native.barcode_get_confidence(ref)

  @doc "Render the barcode to PNG bytes at `size_px`."
  def barcode_png(%Barcode{ref: ref}, size_px \\ 256),
    do: Native.barcode_get_image_png(ref, size_px)

  @doc "Render the barcode to an SVG string at `size_px`."
  def barcode_svg(%Barcode{ref: ref}, size_px \\ 256), do: Native.barcode_get_svg(ref, size_px)

  @doc "Place a `Barcode` on a (0-based) editor `page` at `(x, y, width, height)`."
  def add_barcode_to_page(
        %DocumentEditor{ref: ref},
        page,
        %Barcode{ref: bref},
        x,
        y,
        width,
        height
      ),
      do:
        Native.editor_add_barcode_to_page(
          ref,
          page,
          bref,
          x * 1.0,
          y * 1.0,
          width * 1.0,
          height * 1.0
        )

  @doc "Free a `Barcode`'s native handle now (idempotent)."
  def barcode_close(%Barcode{ref: ref}), do: Native.barcode_close(ref)

  # ── phase 7: OCR ─────────────────────────────────────────────────────────────
  @doc """
  Create an `OcrEngine` from detection/recognition model and dictionary file
  paths. Returns `{:ok, %OcrEngine{}}` or an error (e.g. missing models / the
  `ocr` feature disabled).
  """
  def ocr_engine(det_model_path, rec_model_path, dict_path),
    do: wrap_ocr(Native.ocr_engine_create(det_model_path, rec_model_path, dict_path))

  @doc "Free an `OcrEngine`'s native handle now (idempotent)."
  def ocr_engine_close(%OcrEngine{ref: ref}), do: Native.ocr_engine_close(ref)

  @doc "Whether a (0-based) page needs OCR (i.e. is scanned/hybrid)."
  def ocr_page_needs_ocr(%Document{ref: ref}, page), do: Native.ocr_page_needs_ocr(ref, page)

  @doc """
  Extract text from a (0-based) page using OCR. `engine` may be `nil` (native
  extraction only) or an `OcrEngine`.
  """
  def ocr_extract_text(doc, page, engine \\ nil)

  def ocr_extract_text(%Document{ref: ref}, page, nil),
    do: Native.ocr_extract_text(ref, page, nil)

  def ocr_extract_text(%Document{ref: ref}, page, %OcrEngine{ref: eref}),
    do: Native.ocr_extract_text(ref, page, eref)

  # ── phase 7: render variants ─────────────────────────────────────────────────
  @doc """
  Render a (0-based) `page` with the full render-options surface. `bg_*` are
  0.0–1.0 background channels; `transparent_background`, `render_annotations`
  are non-zero flags; `format` is an image-format code; `dpi`/`jpeg_quality`
  are integers. Returns `{:ok, %RenderedImage{}}`.
  """
  def render_page_with_options(
        %Document{ref: ref},
        page,
        opts \\ []
      ) do
    dpi = Keyword.get(opts, :dpi, 150)
    format = Keyword.get(opts, :format, 0)
    {br, bg, bb, ba} = Keyword.get(opts, :background, {1.0, 1.0, 1.0, 1.0})
    transparent = if Keyword.get(opts, :transparent_background, false), do: 1, else: 0
    annots = if Keyword.get(opts, :render_annotations, true), do: 1, else: 0
    jpeg_quality = Keyword.get(opts, :jpeg_quality, 85)

    wrap_image(
      Native.doc_render_page_with_options(
        ref,
        page,
        dpi,
        format,
        br * 1.0,
        bg * 1.0,
        bb * 1.0,
        ba * 1.0,
        transparent,
        annots,
        jpeg_quality
      )
    )
  end

  @doc """
  Like `render_page_with_options/3` plus `excluded_layers` — a list of OCG
  `/Name` strings to suppress.
  """
  def render_page_with_options_ex(
        %Document{ref: ref},
        page,
        excluded_layers,
        opts \\ []
      )
      when is_list(excluded_layers) do
    dpi = Keyword.get(opts, :dpi, 150)
    format = Keyword.get(opts, :format, 0)
    {br, bg, bb, ba} = Keyword.get(opts, :background, {1.0, 1.0, 1.0, 1.0})
    transparent = if Keyword.get(opts, :transparent_background, false), do: 1, else: 0
    annots = if Keyword.get(opts, :render_annotations, true), do: 1, else: 0
    jpeg_quality = Keyword.get(opts, :jpeg_quality, 85)

    wrap_image(
      Native.doc_render_page_with_options_ex(
        ref,
        page,
        dpi,
        format,
        br * 1.0,
        bg * 1.0,
        bb * 1.0,
        ba * 1.0,
        transparent,
        annots,
        jpeg_quality,
        excluded_layers
      )
    )
  end

  @doc """
  Render a rectangular region of a (0-based) `page`. `crop_*` are PDF user-space
  points (origin bottom-left); `format` is an image-format code.
  """
  def render_page_region(
        %Document{ref: ref},
        page,
        crop_x,
        crop_y,
        crop_width,
        crop_height,
        format \\ 0
      ),
      do:
        wrap_image(
          Native.doc_render_page_region(
            ref,
            page,
            crop_x * 1.0,
            crop_y * 1.0,
            crop_width * 1.0,
            crop_height * 1.0,
            format
          )
        )

  @doc "Render a (0-based) `page` to fit inside `w`×`h` pixels, preserving aspect ratio."
  def render_page_fit(%Document{ref: ref}, page, w, h, format \\ 0),
    do: wrap_image(Native.doc_render_page_fit(ref, page, w, h, format))

  @doc """
  Render a (0-based) `page` to a raw premultiplied RGBA8888 buffer at `dpi`,
  returned as a `RenderedImage` (its `data` holds the raw pixels).
  """
  def render_page_raw(%Document{ref: ref}, page, dpi \\ 150),
    do: wrap_image(Native.doc_render_page_raw(ref, page, dpi))

  @doc """
  Create a reusable `Renderer` with fixed `dpi`/`format`/`quality` and
  `anti_alias`. Returns `{:ok, %Renderer{}}`.
  """
  def renderer(dpi \\ 150, format \\ 0, quality \\ 85, anti_alias \\ true),
    do: wrap_renderer(Native.renderer_create(dpi, format, quality, anti_alias))

  @doc "Free a `Renderer`'s native handle now (idempotent)."
  def renderer_close(%Renderer{ref: ref}), do: Native.renderer_close(ref)

  @doc "Estimate the render time (ms) for a (0-based) `page`."
  def estimate_render_time(%Document{ref: ref}, page),
    do: Native.doc_estimate_render_time(ref, page)

  # ── phase 7: redaction (on an editor) ────────────────────────────────────────
  @doc """
  Queue a redaction rectangle on a (0-based) editor `page`. Corners
  `(x1, y1)`–`(x2, y2)` and fill colour `(r, g, b)` are in PDF user-space /
  DeviceRGB (channels 0.0–1.0).
  """
  def redaction_add(%DocumentEditor{ref: ref}, page, x1, y1, x2, y2, r, g, b),
    do:
      Native.redaction_add(
        ref,
        page,
        x1 * 1.0,
        y1 * 1.0,
        x2 * 1.0,
        y2 * 1.0,
        r * 1.0,
        g * 1.0,
        b * 1.0
      )

  @doc "Number of queued redaction regions for a (0-based) `page`."
  def redaction_count(%DocumentEditor{ref: ref}, page), do: Native.redaction_count(ref, page)

  @doc """
  Destructively apply all queued redactions. `scrub_metadata` also runs the
  document-scrub pass; `(r, g, b)` is the overlay colour (channels 0.0–1.0).
  Returns `{:ok, glyphs_removed}`.
  """
  def redaction_apply(
        %DocumentEditor{ref: ref},
        scrub_metadata \\ false,
        r \\ 0.0,
        g \\ 0.0,
        b \\ 0.0
      ),
      do: Native.redaction_apply(ref, scrub_metadata, r * 1.0, g * 1.0, b * 1.0)

  @doc "Sanitise the document (strip Info/XMP/JS/embedded files) without geometric redaction."
  def redaction_scrub_metadata(%DocumentEditor{ref: ref}),
    do: Native.redaction_scrub_metadata(ref)

  # ── phase 7: constructors ────────────────────────────────────────────────────
  @doc "Build a `Pdf` from an image file at `path`."
  def from_image(path), do: wrap_pdf(Native.pdf_from_image(path))
  @doc "Build a `Pdf` from raw image bytes."
  def from_image_bytes(bytes), do: wrap_pdf(Native.pdf_from_image_bytes(bytes))

  @doc """
  Build a `Pdf` from `html` + `css` with a single embedded font (`font_bytes`
  may be an empty binary for none).
  """
  def from_html_css(html, css, font_bytes \\ <<>>),
    do: wrap_pdf(Native.pdf_from_html_css(html, css, font_bytes))

  @doc """
  Build a `Pdf` from `html` + `css` with a multi-font cascade. `fonts` is a list
  of `{family, font_bytes}` tuples.
  """
  def from_html_css_with_fonts(html, css, fonts) when is_list(fonts) do
    families = Enum.map(fonts, fn {family, _} -> family end)
    font_bytes = Enum.map(fonts, fn {_, bytes} -> bytes end)
    wrap_pdf(Native.pdf_from_html_css_with_fonts(html, css, families, font_bytes))
  end

  @doc "Merge the PDFs at `paths` (list of file paths) into one PDF binary."
  def merge(paths) when is_list(paths), do: Native.pdf_merge(paths)

  # ── phase 7: page getters (on a Document) ────────────────────────────────────
  @doc "Width (PDF points) of a (0-based) `page`."
  def page_width(%Document{ref: ref}, page), do: Native.page_get_width(ref, page)
  @doc "Height (PDF points) of a (0-based) `page`."
  def page_height(%Document{ref: ref}, page), do: Native.page_get_height(ref, page)
  @doc "Rotation (degrees) of a (0-based) `page`."
  def page_rotation(%Document{ref: ref}, page), do: Native.page_get_rotation(ref, page)

  @doc """
  Get the elements of a (0-based) `page` as an opaque `ElementList`. Read its
  length with `element_count/1`.
  """
  def page_elements(%Document{ref: ref}, page),
    do: wrap_element_list(Native.page_get_elements(ref, page))

  @doc "Number of elements in an `ElementList`."
  def element_count(%ElementList{ref: ref}), do: Native.elements_count(ref)

  @doc "Free an `ElementList`'s native handle now (idempotent)."
  def element_list_close(%ElementList{ref: ref}), do: Native.elements_close(ref)

  # ── phase 7: timestamp ───────────────────────────────────────────────────────
  @doc """
  Add an RFC 3161 timestamp to the signature at `sig_index` of `pdf_data`,
  fetched from `tsa_url`. Returns `{:ok, timestamped_pdf_bytes}`.
  """
  def add_timestamp(pdf_data, sig_index, tsa_url),
    do: Native.add_timestamp(pdf_data, sig_index, tsa_url)

  # ── phase 8: office I/O ───────────────────────────────────────────────────────
  @doc "Open a DOCX document from a binary as a `Document`."
  def open_from_docx_bytes(bytes) when is_binary(bytes),
    do: wrap_doc(Native.doc_open_from_docx_bytes(bytes))

  @doc "Open a PPTX document from a binary as a `Document`."
  def open_from_pptx_bytes(bytes) when is_binary(bytes),
    do: wrap_doc(Native.doc_open_from_pptx_bytes(bytes))

  @doc "Open an XLSX document from a binary as a `Document`."
  def open_from_xlsx_bytes(bytes) when is_binary(bytes),
    do: wrap_doc(Native.doc_open_from_xlsx_bytes(bytes))

  @doc "Export the document to DOCX bytes (`{:ok, binary}` | `{:error, code}`)."
  def to_docx(%Document{ref: ref}), do: Native.doc_to_docx(ref)
  @doc "Export the document to PPTX bytes (`{:ok, binary}` | `{:error, code}`)."
  def to_pptx(%Document{ref: ref}), do: Native.doc_to_pptx(ref)
  @doc "Export the document to XLSX bytes (`{:ok, binary}` | `{:error, code}`)."
  def to_xlsx(%Document{ref: ref}), do: Native.doc_to_xlsx(ref)

  # ── phase 8: in-rect extractors ───────────────────────────────────────────────
  @doc "Reading-order text inside the rect `(x, y, w, h)` on a (0-based) `page`."
  def extract_text_in_rect(%Document{ref: ref}, page, x, y, w, h),
    do: Native.doc_extract_text_in_rect(ref, page, x / 1, y / 1, w / 1, h / 1)

  @doc "Words inside the rect `(x, y, w, h)` on a (0-based) `page` as `Word`s."
  def extract_words_in_rect(%Document{ref: ref}, page, x, y, w, h) do
    with {:ok, list} <- Native.doc_extract_words_in_rect(ref, page, x / 1, y / 1, w / 1, h / 1) do
      {:ok,
       Enum.map(list, fn {text, bx, by, bw, bh, font, size, bold} ->
         %Word{
           text: text,
           bbox: %Bbox{x: bx, y: by, width: bw, height: bh},
           font_name: font,
           font_size: size,
           bold: bold
         }
       end)}
    end
  end

  @doc "Text lines inside the rect `(x, y, w, h)` on a (0-based) `page`."
  def extract_lines_in_rect(%Document{ref: ref}, page, x, y, w, h) do
    with {:ok, list} <- Native.doc_extract_lines_in_rect(ref, page, x / 1, y / 1, w / 1, h / 1) do
      {:ok,
       Enum.map(list, fn {text, bx, by, bw, bh, word_count} ->
         %TextLine{
           text: text,
           bbox: %Bbox{x: bx, y: by, width: bw, height: bh},
           word_count: word_count
         }
       end)}
    end
  end

  @doc "Tables inside the rect `(x, y, w, h)` on a (0-based) `page`."
  def extract_tables_in_rect(%Document{ref: ref}, page, x, y, w, h) do
    with {:ok, list} <- Native.doc_extract_tables_in_rect(ref, page, x / 1, y / 1, w / 1, h / 1) do
      {:ok,
       Enum.map(list, fn {row_count, col_count, has_header, cells} ->
         %Table{row_count: row_count, col_count: col_count, has_header: has_header, cells: cells}
       end)}
    end
  end

  @doc "Images inside the rect `(x, y, w, h)` on a (0-based) `page`."
  def extract_images_in_rect(%Document{ref: ref}, page, x, y, w, h) do
    with {:ok, list} <- Native.doc_extract_images_in_rect(ref, page, x / 1, y / 1, w / 1, h / 1) do
      {:ok,
       Enum.map(list, fn {width, height, bpc, format, colorspace, data} ->
         %Image{
           width: width,
           height: height,
           bits_per_component: bpc,
           format: format,
           colorspace: colorspace,
           data: data
         }
       end)}
    end
  end

  # ── phase 8: auto extraction / classification ─────────────────────────────────
  @doc "Auto-mode (native + image-OCR) text for a (0-based) `page`."
  def extract_text_auto(%Document{ref: ref}, page), do: Native.doc_extract_text_auto(ref, page)
  @doc "Concatenated text of the whole document."
  def extract_all_text(%Document{ref: ref}), do: Native.doc_extract_all_text(ref)

  @doc """
  Auto-mode page extraction as a JSON string. `options_json` may be an empty
  string for defaults.
  """
  def extract_page_auto(%Document{ref: ref}, page, options_json \\ ""),
    do: Native.doc_extract_page_auto(ref, page, options_json)

  @doc "Classify a (0-based) `page` (returns a JSON classification string)."
  def classify_page(%Document{ref: ref}, page), do: Native.doc_classify_page(ref, page)
  @doc "Classify the whole document (returns a JSON classification string)."
  def classify_document(%Document{ref: ref}), do: Native.doc_classify_document(ref)

  # ── phase 8: header / footer / artifact ───────────────────────────────────────
  @doc "Erase the detected header on a (0-based) `page`. Returns the erased count."
  def erase_header(%Document{ref: ref}, page), do: Native.doc_erase_header(ref, page)
  @doc "Erase the detected footer on a (0-based) `page`. Returns the erased count."
  def erase_footer(%Document{ref: ref}, page), do: Native.doc_erase_footer(ref, page)
  @doc "Erase detected artifacts on a (0-based) `page`. Returns the erased count."
  def erase_artifacts(%Document{ref: ref}, page), do: Native.doc_erase_artifacts(ref, page)

  @doc "Remove repeating headers across pages above `threshold`. Returns the count."
  def remove_headers(%Document{ref: ref}, threshold \\ 0.5),
    do: Native.doc_remove_headers(ref, threshold / 1)

  @doc "Remove repeating footers across pages above `threshold`. Returns the count."
  def remove_footers(%Document{ref: ref}, threshold \\ 0.5),
    do: Native.doc_remove_footers(ref, threshold / 1)

  @doc "Remove repeating artifacts across pages above `threshold`. Returns the count."
  def remove_artifacts(%Document{ref: ref}, threshold \\ 0.5),
    do: Native.doc_remove_artifacts(ref, threshold / 1)

  # ── phase 8: forms ────────────────────────────────────────────────────────────
  @doc """
  The document's AcroForm fields as a list of `FormField` (an empty list when the
  document has no form).
  """
  def form_fields(%Document{ref: ref}) do
    with {:ok, list} <- Native.doc_get_form_fields(ref) do
      {:ok,
       Enum.map(list, fn {name, value, type, read_only, required} ->
         %FormField{
           name: name,
           value: value,
           type: type,
           read_only: read_only,
           required: required
         }
       end)}
    end
  end

  @doc "Export the filled form data to bytes (`format_type` 0=FDF, 1=XFDF, …)."
  def export_form_data_to_bytes(%Document{ref: ref}, format_type \\ 0),
    do: Native.doc_export_form_data_to_bytes(ref, format_type)

  @doc "Import form data from a file at `data_path` into the document."
  def import_form_data(%Document{ref: ref}, data_path),
    do: Native.doc_import_form_data(ref, data_path)

  @doc "Import FDF form data bytes into a `DocumentEditor`."
  def import_fdf_bytes(%DocumentEditor{ref: ref}, bytes) when is_binary(bytes),
    do: Native.editor_import_fdf_bytes(ref, bytes)

  @doc "Import XFDF form data bytes into a `DocumentEditor`."
  def import_xfdf_bytes(%DocumentEditor{ref: ref}, bytes) when is_binary(bytes),
    do: Native.editor_import_xfdf_bytes(ref, bytes)

  @doc "Import form data from a file at `filename` into the document."
  def form_import_from_file(%Document{ref: ref}, filename),
    do: Native.form_import_from_file(ref, filename)

  # ── phase 8: document structure / metadata ────────────────────────────────────
  @doc "The document outline (bookmarks) as a JSON string."
  def outline(%Document{ref: ref}), do: Native.doc_get_outline(ref)
  @doc "The document page labels as a JSON string."
  def page_labels(%Document{ref: ref}), do: Native.doc_get_page_labels(ref)
  @doc "The document XMP metadata as an XML/JSON string."
  def xmp_metadata(%Document{ref: ref}), do: Native.doc_get_xmp_metadata(ref)
  @doc "The document's original source bytes."
  def source_bytes(%Document{ref: ref}), do: Native.doc_get_source_bytes(ref)
  @doc "Whether the document carries an XFA form."
  def has_xfa?(%Document{ref: ref}), do: Native.doc_has_xfa(ref)
  @doc "Page count of a built `Pdf` handle."
  def pdf_page_count(%Pdf{ref: ref}), do: Native.doc_get_page_count(ref)

  @doc "Plan splitting the document by bookmarks; returns a JSON plan string."
  def plan_split_by_bookmarks(%Document{ref: ref}, options_json \\ ""),
    do: Native.doc_plan_split_by_bookmarks(ref, options_json)

  # ── phase 8: document-level signatures ────────────────────────────────────────
  @doc "Sign the document in place with `certificate`; returns the signature index."
  def sign(%Document{ref: ref}, %Certificate{ref: cert}, reason \\ "", location \\ ""),
    do: Native.doc_sign(ref, cert, reason, location)

  @doc "Number of signatures present in the document."
  def signature_count(%Document{ref: ref}), do: Native.doc_get_signature_count(ref)

  @doc "Get the `SignatureInfo` for the signature at (0-based) `index`."
  def signature(%Document{ref: ref}, index),
    do: wrap_signature(Native.doc_get_signature(ref, index))

  @doc "Verify all signatures; returns an aggregate status code."
  def verify_all_signatures(%Document{ref: ref}), do: Native.doc_verify_all_signatures(ref)
  @doc "Whether the document carries a document-level timestamp."
  def document_has_timestamp?(%Document{ref: ref}), do: Native.doc_has_timestamp(ref)
  @doc "Get the document's `Dss` (Document Security Store) handle."
  def document_dss(%Document{ref: ref}), do: wrap_dss(Native.doc_get_dss(ref))

  # ── phase 8: annotation extras ────────────────────────────────────────────────
  @doc "Packed RGBA color (uint32) of the annotation at `index` on a `page`."
  def annotation_color(%Document{ref: ref}, page, index),
    do: Native.annot_get_color(ref, page, index)

  @doc "Creation date (unix seconds) of the annotation at `index` on a `page`."
  def annotation_creation_date(%Document{ref: ref}, page, index),
    do: Native.annot_get_creation_date(ref, page, index)

  @doc "Modification date (unix seconds) of the annotation at `index` on a `page`."
  def annotation_modification_date(%Document{ref: ref}, page, index),
    do: Native.annot_get_modification_date(ref, page, index)

  @doc "Whether the annotation at `index` on a `page` is hidden."
  def annotation_hidden?(%Document{ref: ref}, page, index),
    do: Native.annot_is_hidden(ref, page, index)

  @doc "Whether the annotation at `index` on a `page` is marked deleted."
  def annotation_marked_deleted?(%Document{ref: ref}, page, index),
    do: Native.annot_is_marked_deleted(ref, page, index)

  @doc "Whether the annotation at `index` on a `page` is printable."
  def annotation_printable?(%Document{ref: ref}, page, index),
    do: Native.annot_is_printable(ref, page, index)

  @doc "Whether the annotation at `index` on a `page` is read-only."
  def annotation_read_only?(%Document{ref: ref}, page, index),
    do: Native.annot_is_read_only(ref, page, index)

  @doc "URI of the link annotation at `index` on a `page`."
  def link_annotation_uri(%Document{ref: ref}, page, index),
    do: Native.annot_link_get_uri(ref, page, index)

  @doc "Icon name of the text annotation at `index` on a `page`."
  def text_annotation_icon_name(%Document{ref: ref}, page, index),
    do: Native.annot_text_get_icon_name(ref, page, index)

  @doc "Quad-point count of the highlight annotation at `index` on a `page`."
  def highlight_quad_points_count(%Document{ref: ref}, page, index),
    do: Native.annot_highlight_quad_points_count(ref, page, index)

  @doc """
  The `quad_index`-th quad of the highlight annotation at `index` on a `page` as
  `{:ok, {x1, y1, x2, y2, x3, y3, x4, y4}}`.
  """
  def highlight_quad_point(%Document{ref: ref}, page, index, quad_index),
    do: Native.annot_highlight_quad_point(ref, page, index, quad_index)

  @doc "All annotations on a (0-based) `page` serialized as a JSON string."
  def annotations_to_json(%Document{ref: ref}, page),
    do: Native.annotations_to_json(ref, page)

  # ── phase 8: element / font / search JSON accessors ────────────────────────────
  @doc "Type string of the element at `index` in an `ElementList`."
  def element_type(%ElementList{ref: ref}, index), do: Native.element_get_type(ref, index)
  @doc "Text of the element at `index` in an `ElementList`."
  def element_text(%ElementList{ref: ref}, index), do: Native.element_get_text(ref, index)

  @doc "Bounding box of the element at `index` in an `ElementList` as a `Bbox`."
  def element_rect(%ElementList{ref: ref}, index),
    do: wrap_box(Native.element_get_rect(ref, index))

  @doc "An `ElementList` serialized as a JSON string."
  def elements_to_json(%ElementList{ref: ref}), do: Native.elements_to_json(ref)

  @doc "Font size of the font at `index` on a (0-based) `page`."
  def font_size(%Document{ref: ref}, page, index), do: Native.font_get_size(ref, page, index)
  @doc "The fonts on a (0-based) `page` serialized as a JSON string."
  def fonts_to_json(%Document{ref: ref}, page), do: Native.fonts_to_json(ref, page)

  @doc "Search the whole document for `term` and serialize the hits as JSON."
  def search_results_to_json(%Document{ref: ref}, term, case_sensitive \\ false),
    do: Native.search_results_to_json(ref, term, if(case_sensitive, do: 1, else: 0))

  # ── phase 8: crypto / FIPS ─────────────────────────────────────────────────────
  @doc "The active crypto provider name."
  def crypto_active_provider, do: Native.crypto_active_provider()
  @doc "The crypto Bill of Materials (CBOM) as a JSON string."
  def crypto_cbom, do: Native.crypto_cbom()
  @doc "The crypto inventory as a JSON string."
  def crypto_inventory, do: Native.crypto_inventory()
  @doc "The active crypto policy as a string."
  def crypto_policy, do: Native.crypto_policy()
  @doc "Whether a FIPS-validated crypto provider is available (nonzero = yes)."
  def crypto_fips_available, do: Native.crypto_fips_available()
  @doc "Switch the process to the FIPS crypto provider; returns a status code."
  def crypto_use_fips, do: Native.crypto_use_fips()
  @doc "Set the crypto policy from `spec`; returns a status code."
  def crypto_set_policy(spec) when is_binary(spec), do: Native.crypto_set_policy(spec)

  # ── phase 8: models / config ───────────────────────────────────────────────────
  @doc "The OCR/layout model manifest as a JSON string."
  def model_manifest, do: Native.model_manifest()
  @doc "Whether model prefetch is available (nonzero = yes)."
  def prefetch_available, do: Native.prefetch_available()

  @doc "Prefetch models for `languages_csv` (empty = defaults); returns JSON."
  def prefetch_models(languages_csv \\ ""), do: Native.prefetch_models(languages_csv)

  @doc "Set the per-content-stream operator cap; returns the previous limit."
  def set_max_ops_per_stream(limit) when is_integer(limit),
    do: Native.set_max_ops_per_stream(limit)

  @doc "Toggle preserving unmapped glyphs (nonzero to enable); returns the previous value."
  def set_preserve_unmapped_glyphs(preserve) when is_integer(preserve),
    do: Native.set_preserve_unmapped_glyphs(preserve)

  defp wrap_doc({:ok, ref}), do: {:ok, %Document{ref: ref}}
  defp wrap_doc(other), do: other
  defp wrap_pdf({:ok, ref}), do: {:ok, %Pdf{ref: ref}}
  defp wrap_pdf(other), do: other
  defp wrap_editor({:ok, ref}), do: {:ok, %DocumentEditor{ref: ref}}
  defp wrap_editor(other), do: other

  defp wrap_box({:ok, {x, y, w, h}}), do: {:ok, %Bbox{x: x, y: y, width: w, height: h}}
  defp wrap_box(other), do: other

  defp wrap_image({:ok, {ref, width, height, data}}),
    do: {:ok, %RenderedImage{ref: ref, width: width, height: height, data: data}}

  defp wrap_image(other), do: other

  defp wrap_font({:ok, ref}), do: {:ok, %EmbeddedFont{ref: ref}}
  defp wrap_font(other), do: other
  defp wrap_doc_builder({:ok, ref}), do: {:ok, %DocumentBuilder{ref: ref}}
  defp wrap_doc_builder(other), do: other
  defp wrap_page_builder({:ok, ref}), do: {:ok, %PageBuilder{ref: ref}}
  defp wrap_page_builder(other), do: other

  defp wrap_certificate({:ok, ref}), do: {:ok, %Certificate{ref: ref}}
  defp wrap_certificate(other), do: other
  defp wrap_signature({:ok, ref}), do: {:ok, %SignatureInfo{ref: ref}}
  defp wrap_signature(other), do: other
  defp wrap_dss({:ok, ref}), do: {:ok, %Dss{ref: ref}}
  defp wrap_dss(other), do: other
  defp wrap_timestamp({:ok, ref}), do: {:ok, %Timestamp{ref: ref}}
  defp wrap_timestamp(other), do: other
  defp wrap_tsa({:ok, ref}), do: {:ok, %TsaClient{ref: ref}}
  defp wrap_tsa(other), do: other
  defp wrap_pdf_a({:ok, ref}), do: {:ok, %PdfAResult{ref: ref}}
  defp wrap_pdf_a(other), do: other
  defp wrap_pdf_ua({:ok, ref}), do: {:ok, %PdfUaResult{ref: ref}}
  defp wrap_pdf_ua(other), do: other
  defp wrap_pdf_x({:ok, ref}), do: {:ok, %PdfXResult{ref: ref}}
  defp wrap_pdf_x(other), do: other

  defp wrap_barcode({:ok, ref}), do: {:ok, %Barcode{ref: ref}}
  defp wrap_barcode(other), do: other
  defp wrap_ocr({:ok, ref}), do: {:ok, %OcrEngine{ref: ref}}
  defp wrap_ocr(other), do: other
  defp wrap_renderer({:ok, ref}), do: {:ok, %Renderer{ref: ref}}
  defp wrap_renderer(other), do: other
  defp wrap_element_list({:ok, ref}), do: {:ok, %ElementList{ref: ref}}
  defp wrap_element_list(other), do: other

  # Collect `count`/`get(index)` validation strings into a plain list. `count`
  # returns a bare int; each `get` returns {:ok, binary} | {:error, code}.
  defp result_strings(ref, count_fun, get_fun) do
    n = count_fun.(ref)
    n = if is_integer(n) and n > 0, do: n, else: 0

    for i <- 0..(n - 1)//1 do
      case get_fun.(ref, i) do
        {:ok, s} -> s
        _ -> ""
      end
    end
  end
end