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
| Approach | Best for | Tradeoff |
|---|---|---|
| Mogrify + Ghostscript | Elixir-idiomatic, existing ImageMagick/GS install | System deps required; ImageMagick PDF policy may block |
| Ghostscript direct | Faster pipeline, precise Ghostscript args | System dep required; no Hex abstraction |
| ChangeThisFile API (Req) | No system deps, Docker-friendly, serverless Elixir | Network 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"torights="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.