Elixir's PDF tooling delegates to system binaries — Ghostscript for rendering, ImageMagick for image handling. The Mogrify package wraps this in an Elixir-friendly API. For Elixir services running in Docker or on Fly.io where you don't want system package dependencies, the ChangeThisFile API offloads the rendering entirely.

Method 1: Mogrify + Ghostscript (idiomatic Elixir wrapper)

The mogrify Hex package wraps ImageMagick, which uses Ghostscript to render PDFs. Install both system tools and the Hex package.

# System deps
apt install imagemagick ghostscript
# macOS
brew install imagemagick ghostscript
# mix.exs
defp deps do
  [
    {:mogrify, "~> 0.9"}
  ]
end
defmodule PdfConverter do
  @moduledoc "Convert PDF pages to JPG using Mogrify/Ghostscript"

  @doc """
  Converts all pages of a PDF to JPG files in the given output directory.
  Returns {:ok, [path]} or {:error, reason}.
  """
  def pdf_to_jpg(input_path, output_dir, dpi \\ 150) do
    File.mkdir_p!(output_dir)

    # Mogrify reads PDFs via Ghostscript
    # [0] suffix selects first page; omit to render all pages
    result =
      Mogrify.open(input_path)
      |> Mogrify.custom("density", to_string(dpi))
      |> Mogrify.format("jpg")
      |> Mogrify.save(path: output_dir <> "/page.jpg")

    # Mogrify names multi-page output as page-0.jpg, page-1.jpg, etc.
    pages =
      output_dir
      |> File.ls!()
      |> Enum.filter(&String.ends_with?(&1, ".jpg"))
      |> Enum.sort()
      |> Enum.map(&Path.join(output_dir, &1))

    {:ok, pages}
  rescue
    e -> {:error, Exception.message(e)}
  end
end

# Usage
{:ok, pages} = PdfConverter.pdf_to_jpg("/tmp/document.pdf", "/tmp/pages", 150)
IO.puts("Wrote #{length(pages)} pages")

DPI guide: 72 = screen thumbnails, 150 = web quality (default), 300 = print quality. Higher DPI means larger image files and slower rendering.

ImageMagick PDF policy: Recent ImageMagick installs disable PDF rendering by default as a security precaution. If you get "not authorized" errors, edit /etc/ImageMagick-6/policy.xml and change the PDF rights from none to read|write.

Method 2: Ghostscript via System.cmd (direct, no Mogrify wrapper)

Call Ghostscript directly via System.cmd/3 for precise DPI control without the Mogrify layer. Fewer moving parts.

apt install ghostscript
defmodule GhostscriptConverter do
  @doc """
  Convert a PDF to JPGs using Ghostscript directly.
  Output files are named page_%03d.jpg in output_dir.
  """
  def pdf_to_jpg(input_path, output_dir, dpi \\ 150) do
    File.mkdir_p!(output_dir)
    output_pattern = Path.join(output_dir, "page_%03d.jpg")

    args = [
      "-dNOPAUSE",
      "-dBATCH",
      "-sDEVICE=jpeg",
      "-dJPEGQ=88",
      "-r#{dpi}",
      "-sOutputFile=#{output_pattern}",
      input_path
    ]

    case System.cmd("gs", args, stderr_to_stdout: true) do
      {_output, 0} ->
        pages =
          output_dir
          |> File.ls!()
          |> Enum.filter(&String.ends_with?(&1, ".jpg"))
          |> Enum.sort()
          |> Enum.map(&Path.join(output_dir, &1))

        {:ok, pages}

      {output, exit_code} ->
        {:error, "Ghostscript failed (exit #{exit_code}): #{output}"}
    end
  end
end

# Usage
{:ok, pages} = GhostscriptConverter.pdf_to_jpg("/tmp/document.pdf", "/tmp/pages", 150)

-dJPEGQ=88 sets JPEG quality (0-100). -sDEVICE=jpeg produces JPEG output directly. -r150 sets 150 DPI rendering resolution. This approach is slightly faster than Mogrify because it skips the ImageMagick intermediary layer.

Method 3: ChangeThisFile API via Req (no system deps)

No Ghostscript, no ImageMagick. POST the PDF to /v1/convert using Req (or HTTPoison) with multipart form data. Free tier: 1,000 conversions/month.

# curl reference
curl -X POST https://changethisfile.com/v1/convert \
  -H "Authorization: Bearer ctf_sk_your_key_here" \
  -F "file=@document.pdf" \
  -F "target=jpg" \
  --output result.jpg
# mix.exs
defp deps do
  [
    {:req, "~> 0.5"}
  ]
end
defmodule CTFConverter do
  @api_url "https://changethisfile.com/v1/convert"
  @api_key "ctf_sk_your_key_here"

  @doc """
  Convert a PDF to JPG using the ChangeThisFile API.
  Returns {:ok, binary} with the JPG data, or {:ok, zip_binary} for multi-page PDFs.
  """
  def pdf_to_jpg(input_path) do
    file_data = File.read!(input_path)
    filename  = Path.basename(input_path)

    response =
      Req.post!(
        @api_url,
        headers: [{"Authorization", "Bearer #{@api_key}"}],
        form_multipart: [
          {:file, file_data,
           filename: filename,
           content_type: "application/pdf"},
          {:target, "jpg"}
        ],
        receive_timeout: 120_000
      )

    case response.status do
      200 ->
        content_type = Req.Response.get_header(response, "content-type") |> List.first() || ""
        if String.contains?(content_type, "zip") do
          {:ok, :zip, response.body}
        else
          {:ok, :jpg, response.body}
        end

      status ->
        {:error, "API returned #{status}"}
    end
  end

  @doc "Save API response to disk, unzipping if multi-page."
  def save_result({:ok, :jpg, data}, output_path) do
    File.write!(output_path, data)
    {:ok, [output_path]}
  end

  def save_result({:ok, :zip, data}, output_dir) do
    File.mkdir_p!(output_dir)
    zip_path = Path.join(output_dir, "pages.zip")
    File.write!(zip_path, data)
    :zip.unzip(to_charlist(zip_path), [{:cwd, to_charlist(output_dir)}])
    File.rm!(zip_path)
    pages = File.ls!(output_dir) |> Enum.filter(&String.ends_with?(&1, ".jpg")) |> Enum.sort()
    {:ok, Enum.map(pages, &Path.join(output_dir, &1))}
  end
end

# Usage
{:ok, type, body} = CTFConverter.pdf_to_jpg("/tmp/document.pdf")
CTFConverter.save_result({:ok, type, body}, "/tmp/pages")

Source format is auto-detected from file content. Multi-page PDFs return a ZIP — the code above handles both single-page and multi-page responses.

When to use each

ApproachBest forTradeoff
Mogrify + GhostscriptElixir-idiomatic, existing ImageMagick/GS installSystem deps required; ImageMagick PDF policy may block
Ghostscript directFaster pipeline, precise Ghostscript argsSystem dep required; no Hex abstraction
ChangeThisFile API (Req)No system deps, Docker-friendly, serverless ElixirNetwork latency, 25MB free-tier upload limit

Production tips

  • Fix ImageMagick's PDF policy. Modern ImageMagick disables PDF by default. In /etc/ImageMagick-6/policy.xml (or -7), find the PDF rights line and change rights="none" to rights="read|write". This is the most common reason Mogrify silently produces empty output.
  • Run Ghostscript in a Task for concurrent jobs. System.cmd is synchronous and blocks the calling process. Wrap in Task.async/Task.await for concurrent PDF jobs, with a timeout to prevent zombie GS processes.
  • Validate file extension before processing. System.cmd with user-supplied filenames can be risky. Always validate that the input is a PDF (check magic bytes: first 4 bytes are %PDF) before passing to GS.
  • Set receive_timeout in Req for large PDFs. The default Req timeout may not be enough for large PDFs. Pass receive_timeout: 120_000 (120s) for files with many pages.
  • Use a temp directory per conversion job. System.tmp_dir!/0 gives you a temp dir. Use a unique subdirectory per job (Path.join(System.tmp_dir!(), UUID.uuid4())) to avoid file collisions on concurrent jobs.

For self-hosted Elixir services with Ghostscript available, the direct System.cmd approach is the leanest. For Elixir-idiomatic code, Mogrify. For Docker images or Fly.io deployments where you don't want system packages, the API. Free tier covers 1,000 conversions/month.