PaperForge

PaperForge is a pure Elixir PDF generation engine. It builds PDF object graphs, page content streams, resources, cross-reference tables, trailers, text layout, vector graphics, metadata, and image XObjects directly in Elixir.

No browser, wkhtmltopdf, Chromium, ImageMagick, Ghostscript, or external rendering service is required.

PaperForge is currently pre-1.0. The 0.6.x API is usable, but some details may still change while layout and image support mature.

Why PaperForge?

Highlights

Document Layout

PDF Engine

Which API Should I Use?

Use caseRecommended API
Reports, invoices, statements, contractsPaperForge.Flow
Automatic paginationPaperForge.layout/3
Manual graphics and precise coordinatesPaperForge.Page
Existing applications using older flow APIsadd_flow/4, add_table/4
Debugging layoutPaperForge.debug/2

Installation

Add PaperForge to your dependencies:

def deps do
[
{:paper_forge, "~> 0.6.0"}
]
end

Then run:

mix deps.get

To use the GitHub release directly:

def deps do
[
{:paper_forge,
github: "Manuel1471/paper_forge",
tag: "v0.6.0"}
]
end

For local development against main:

def deps do
[
{:paper_forge,
github: "Manuel1471/paper_forge",
branch: "main"}
]
end

Quick Start

alias PaperForge.Flow
{document, report} =
PaperForge.new(compress: true, pdf_version: "1.7")
|> PaperForge.page_template(
:default,
size: :a4,
margins: [top: 72, right: 54, bottom: 72, left: 54],
header: "Quarterly Report",
footer: "Page {page} of {total}"
)
|> PaperForge.layout(
fn flow ->
flow
|> Flow.heading("Quarterly Report", level: 1)
|> Flow.paragraph("""
PaperForge measures, paginates, and renders document blocks automatically.
""")
|> Flow.list(
["Unified layout", "Automatic pagination", "Reusable templates"],
type: :unordered
)
|> Flow.table(
["Metric", "Value"],
[
["Revenue", "$120K"],
["Margin", "24%"]
],
repeat_header: true
)
end,
template: :default
)
IO.inspect(report.pages, label: "Pages")
PaperForge.write!(document, "report.pdf")

Document Authoring

0.6.0 adds a higher-level authoring layer on top of PaperForge.Flow. Register shared styles, reusable components, and inherited page templates once; then compose documents from declarative blocks.

alias PaperForge.Flow
document =
PaperForge.new()
|> PaperForge.style(:body, size: 10, line_height: 14)
|> PaperForge.component(:customer, fn assigns ->
Flow.new()
|> Flow.rich_text([
{assigns.name, [weight: :bold]},
{"\n#{assigns.address}", [size: 9]}
])
end)
|> PaperForge.page_template(:base, size: :a4, margins: 54, footer: "Page {page} of {total}")
|> PaperForge.page_template(:invoice, extends: :base, header: "Invoice")
{document, _report} =
PaperForge.layout(document, fn flow ->
flow
|> Flow.table_of_contents()
|> Flow.heading("Invoice")
|> Flow.component(:customer, %{name: "Acme", address: "Monterrey, MX"})
|> Flow.grid(2, ["Subtotal\n$1,200", "Due\n30 days"], cell_height: 60)
|> Flow.columns(2, ["Terms and conditions...", "Payment instructions..."])
end, template: :invoice)

Available authoring blocks include rich_text/3, table_of_contents/2, reference/3, component/4, grid/4, and columns/4. Tables accept explicit :column_widths, :header_fill_color, :header_color, and :stripe_fill_color options. See paper_forge_0_6_authoring.exs and linkedin_document_showcase.exs for complete documents.

Images support fit: :fill | :contain | :cover, horizontal and vertical alignment, and focal_point: {x, y}. Numbered images and tables create stable destinations for page-aware references.

The complete release example is paper_forge_0_6_complete.exs. It combines navigation, advanced tables, footnotes, endnotes, charts, SVG, QR, barcode, attachments, components, and custom report panels in one PDF.

Page-aware navigation is resolved with bounded multi-pass pagination:

flow
|> Flow.table_of_contents(title: "Contents")
|> Flow.heading("Financial results", destination: :financial_results)
|> Flow.reference(:financial_results, prefix: "Financial results begin on page ")

Custom blocks receive their measured block_x, block_y, block_width, and block_height in PageContext, so bespoke report panels can participate in normal flow without hard-coding page coordinates.

Typography And Report Visuals

Paragraph blocks can request hyphenation and minimum line counts around page breaks. Layout reports expose :measurements for each placed block.

flow
|> Flow.paragraph(long_copy, hyphenate: true, min_lines_at_top: 2, min_lines_at_bottom: 2)
|> Flow.chart([{"Q1", 418}, {"Q2", 432}, {"Q3", 451}], height: 140)
|> Flow.svg("<svg><rect x='0' y='0' width='80' height='30' fill='#0077b5'/></svg>", height: 40)
|> Flow.qr_code("https://example.com/pay/INV-2048", width: 96, height: 96)
|> Flow.barcode("20481234", width: 180, height: 64)

Advanced Tables And Notes

Table rows are measured from their wrapped cell content. :keep moves an oversized row to a fresh page, :split continues cell content across pages, and :error raises PaperForge.TableError when a row cannot fit.

Use Flow.cell/2 for composable cells with :colspan, :rowspan, :valign, per-cell colors, per-side borders, and nested flow blocks.

flow
|> Flow.table(
["Item", "Description"],
rows,
column_widths: [110, 340],
repeat_header: true,
row_split: :split,
cell_line_height: 12
)
|> Flow.footnote("Values are unaudited and shown in USD.")
|> Flow.endnotes([])

Footnotes reserve space at the bottom of the current flow page, number themselves when the number is omitted, append a visible call marker to the preceding paragraph, rich-text block, heading, or final table cell, and continue on another page when necessary. Pass marker: false to author the call marker manually. Flow.endnotes/3 emits the collected notes as a document section.

Document Options

PaperForge.new/1 accepts:

PaperForge.new()
PaperForge.new(compress: false)
PaperForge.new(pdf_version: "1.4")
PaperForge.new(default_font: :helvetica)

Low-level Page API

Use PaperForge.Page when you need manual graphics, exact coordinates, or a lower-level drawing surface. New structured documents should usually start with PaperForge.Flow and PaperForge.layout/3.

Add a page with default options:

document =
PaperForge.new()
|> PaperForge.add_page(fn page ->
Page.text(page, "Default A4 page", x: 72, y: 750)
end)

Add a page with options:

document =
PaperForge.new()
|> PaperForge.add_page(
[
size: :letter,
orientation: :landscape,
origin: :top_left,
margins: [top: 48, right: 54, bottom: 48, left: 54]
],
fn page ->
Page.text(page, "Landscape page", y: 48)
end
)

Supported page sizes:

:a3
:a4
:a5
:letter
:legal

Custom page sizes use {width, height} in PDF points:

Page.new(size: {500, 700})

All dimensions are expressed in PDF points.

1 point = 1/72 inch

Coordinates And Margins

PaperForge supports both PDF-native bottom-left coordinates and top-left coordinates.

Page.new(origin: :bottom_left)
Page.new(origin: :top_left)

You can also set the origin per operation:

Page.rectangle(page, x: 72, y: 72, width: 100, height: 40, origin: :top_left)

Margins can be uniform:

Page.new(margins: 72)

Or side-specific:

Page.new(margins: [top: 40, right: 50, bottom: 40, left: 50])

Content helpers:

Page.content_width(page)
Page.content_height(page)
Page.content_left(page)
Page.content_top(page)
Page.content_bottom(page)

Text

Draw a single line of text:

Page.text(
page,
"Centered title",
x: Page.content_left(page),
y: 72,
width: Page.content_width(page),
align: :center,
font: :helvetica_bold,
size: 24,
color: Color.black()
)

Draw wrapped multiline text:

Page.text_box(
page,
"""
PaperForge wraps text into multiple lines using built-in font metrics.
Explicit line breaks are preserved.
""",
x: Page.content_left(page),
y: 120,
width: Page.content_width(page),
height: 160,
font: :times_roman,
size: 12,
line_height: 17,
align: :left
)

Supported alignment values:

:left
:center
:right

Fonts And Unicode Text

PaperForge supports two font paths: the 14 standard PDF Type 1 fonts and embedded TrueType fonts.

Standard Type 1 fonts are registered automatically when used:

:helvetica
:helvetica_bold
:helvetica_oblique
:helvetica_bold_oblique
:times_roman
:times_bold
:times_italic
:times_bold_italic
:courier
:courier_bold
:courier_oblique
:courier_bold_oblique
:symbol
:zapf_dingbats

Standard Type 1 fonts are convenient for simple Latin text, but they are not full Unicode fonts. For visible Unicode text, register a TrueType .ttf font before adding pages:

document =
PaperForge.new()
|> PaperForge.register_font(
:inter,
path: "assets/fonts/Inter-Regular.ttf"
)

You can also register a font from an in-memory binary:

document =
PaperForge.register_font(
document,
:inter,
data: File.read!("assets/fonts/Inter-Regular.ttf")
)

Then use the registered key in text operations:

Page.text(
page,
"El pingüino comió camarón — ¿listo? — Привет — Ω",
x: 72,
y: 720,
font: :inter,
size: 18
)

Embedded TrueType fonts are written as PDF Type 0 fonts with a CIDFontType2 descendant, Identity-H encoding, a /FontFile2 stream, widths from the TTF hmtx table, and a /ToUnicode CMap so text extraction and search can recover Unicode characters.

Supported embedded font input:

Current limitations:

Font Families

Register related TrueType files as a family:

document =
PaperForge.new()
|> PaperForge.register_font_family(
:inter,
regular: [path: "assets/fonts/Inter-Regular.ttf"],
bold: [path: "assets/fonts/Inter-Bold.ttf"],
italic: [path: "assets/fonts/Inter-Italic.ttf"],
bold_italic: [path: "assets/fonts/Inter-BoldItalic.ttf"]
)

Then select a variant with :weight and :style:

Page.text(
page,
"Important",
x: 72,
y: 720,
font: :inter,
weight: :bold,
style: :italic
)

Set a document default font when most text should use the same font:

document =
PaperForge.new()
|> PaperForge.register_font(:inter, path: "assets/fonts/Inter-Regular.ttf")
|> PaperForge.default_font(:inter)

Shapes

Lines

Page.line(
page,
x1: 72,
y1: 700,
x2: 300,
y2: 700,
width: 2,
color: Color.rgb255(40, 70, 140)
)

Rectangles

Page.rectangle(
page,
x: 72,
y: 560,
width: 220,
height: 100,
fill: true,
stroke: true,
fill_color: Color.rgb255(235, 240, 250),
stroke_color: Color.rgb255(40, 70, 140),
line_width: 2
)

Circles

Page.circle(
page,
x: 400,
y: 610,
radius: 50,
fill: true,
stroke: true,
fill_color: Color.rgb255(245, 180, 70),
stroke_color: Color.rgb255(120, 70, 20),
line_width: 2
)

PaperForge approximates circles using four cubic Bezier curves because PDF does not provide a native circle operator.

Colors

RGB values can be expressed from 0 to 1:

Color.rgb(1.0, 0.0, 0.0)

Or from 0 to 255:

Color.rgb255(255, 0, 0)

Grayscale helpers:

Color.gray(0.5)
Color.black()
Color.white()

Images

Page.image/3 accepts a supported image binary or a file path.

png = File.read!("logo.png")
page
|> Page.image(png, x: 72, y: 120, width: 200)
|> Page.image("photo.jpg", x: 72, y: 360, width: 200, height: 120)

When only one dimension is supplied, PaperForge preserves the source aspect ratio:

Page.image(page, "logo.png", x: 72, y: 120, width: 200)
Page.image(page, "logo.png", x: 72, y: 120, height: 80)

Supported JPEGs:

Supported PNGs:

PNG alpha is written as a PDF soft mask (/SMask). PNG grayscale/RGB images without alpha use the original compressed IDAT data directly with /FlateDecode and PNG predictor decode parameters. JPEG image data is embedded directly with /DCTDecode.

Images are deduplicated by SHA-256 hash, so drawing the same image several times does not embed duplicate image streams.

Unified Flow

PaperForge.flow/2 builds a document from layout blocks instead of manual page operations. The engine measures blocks, paginates them, calculates total pages, and then renders the final pages.

alias PaperForge.Flow
{document, report} =
PaperForge.new()
|> PaperForge.page_template(
:report,
size: :a4,
margins: [top: 72, right: 54, bottom: 72, left: 54],
header: "Quarterly report",
footer: "Page {page} of {total}"
)
|> PaperForge.layout(
fn flow ->
flow
|> Flow.heading("Quarterly report", level: 1)
|> Flow.paragraph("Summary text that wraps and splits across pages.")
|> Flow.list(["Revenue", "Expenses", "Cash"], type: :ordered)
|> Flow.table(
["Metric", "Value"],
[
["Revenue", "$120K"],
["Margin", "24%"]
],
repeat_header: true
)
|> Flow.separator()
|> Flow.page_break()
|> Flow.section(:appendix, [title: "Appendix"], fn section ->
section
|> Flow.paragraph("Section content receives section metadata.")
end)
end,
template: :report
)

The report returned by PaperForge.layout/3 contains page count, block count, placements, warnings, and rendered page values. Placements include block ID, block type, page number, coordinates, dimensions, and section metadata:

{document, report} =
PaperForge.layout(document, flow_function, template: :report)
report.pages
report.blocks
report.placements

Pagination options can be set on flow blocks:

flow
|> Flow.heading("Appendix", page_break_before: true, keep_with_next: true)
|> Flow.paragraph("This paragraph should stay visually connected.")
|> Flow.separator(page_break_after: true)

Sections group related content under a stable section ID. A section can add a title heading, start or end with page breaks, switch to a named page template, and pass section metadata into PageContext:

flow
|> Flow.section(:appendix, [title: "Appendix", template: :appendix], fn section ->
section
|> Flow.paragraph("Appendix content")
end)

Page templates can configure page geometry and reusable header/footer content:

document =
PaperForge.new()
|> PaperForge.page_template(
:appendix,
size: :letter,
orientation: :landscape,
margins: [top: 60, right: 48, bottom: 60, left: 48],
header: fn page, context ->
Page.text(page, "Appendix", x: context.content_left, y: 24)
end,
footer: "Page {page} of {total}"
)

Custom blocks receive the current Page and PageContext:

Flow.custom(flow, fn page, context ->
Page.text(
page,
"Page #{context.page_number} of #{context.total_pages}",
x: context.content_left,
y: context.content_top
)
end, height: 24)

Debug reports summarize the generated document:

PaperForge.debug(document,
show_margins: true,
show_blocks: true,
show_page_breaks: true
)

Existing Page, add_flow/4, and add_table/4 APIs remain supported for compatibility. New applications should prefer PaperForge.Flow and PaperForge.layout/3.

Legacy Flow And Page-level APIs

The APIs in this section remain supported for compatibility. New applications should prefer PaperForge.Flow and PaperForge.layout/3.

Flow text blocks across pages:

document =
PaperForge.new()
|> PaperForge.add_flow(
[
"First paragraph with enough text to wrap.",
"Second paragraph. PaperForge creates new pages as needed."
],
[size: :letter, margins: 72],
font: :helvetica,
size: 11,
line_height: 15,
gap: 8
)

Get flow overflow information:

{document, report} =
PaperForge.layout_flow(
PaperForge.new(),
["A long paragraph", "Another long paragraph"],
[size: :letter, margins: 72],
header: "Quarterly report",
footer: "Generated by PaperForge",
keep_together: true
)
report.pages_added
report.overflow?

Draw a basic table:

page =
page
|> Page.table(
[
["Name", "Score"],
["Ana", 10],
["Luis", 9]
],
x: Page.content_left(page),
y: 96,
width: Page.content_width(page),
header: true
)

Add a URI link annotation:

page =
page
|> Page.text("Project", x: 72, y: 720)
|> Page.link(
"https://github.com/Manuel1471/paper_forge",
x: 72,
y: 700,
width: 180,
height: 24
)

Create internal navigation:

document =
PaperForge.new()
|> PaperForge.add_page(fn page ->
page
|> Page.destination(:intro, y: 720)
|> Page.bookmark("Introduction", y: 720)
|> Page.text("Introduction", x: 72, y: 720)
end)
|> PaperForge.add_page(fn page ->
page
|> Page.text("Back to intro", x: 72, y: 720)
|> Page.link_to(:intro, x: 72, y: 700, width: 120, height: 24)
end)

Add a paginated table with repeated headers:

document =
PaperForge.add_table(
document,
rows,
[size: :a4, margins: 72],
repeat_header: true,
row_split: :keep
)

Metadata

document =
PaperForge.new()
|> PaperForge.metadata(
title: "Reporte de Mexico",
author: "Manuel Garcia",
subject: "Informacion \u65E5\u672C\u8A9E",
keywords: ["report", "elixir", "pdf"],
creator: "PaperForge",
producer: "PaperForge",
creation_date: DateTime.utc_now(),
modification_date: DateTime.utc_now()
)

Metadata is written into the PDF Info dictionary and referenced from the document trailer. Latin-1-compatible strings are stored as PDF literal strings. Other Unicode strings are stored as UTF-16BE hexadecimal strings.

Binary Output

PaperForge can return the complete PDF as a binary:

pdf_binary =
PaperForge.to_binary(document)

This can be used in Phoenix or Plug responses:

conn
|> put_resp_content_type("application/pdf")
|> put_resp_header(
"content-disposition",
~s(attachment; filename="document.pdf")
)
|> send_resp(200, PaperForge.to_binary(document))

Write to disk:

PaperForge.write(document, "document.pdf")
PaperForge.write!(document, "document.pdf")

Architecture

PaperForge separates public drawing operations from low-level PDF objects.

PaperForge
|-- Document
| |-- object allocation
| |-- font registry
| |-- image registry
| `-- metadata reference
|-- Page
| `-- high-level drawing operations
|-- Flow
| `-- block-based document layout builder
|-- Layout
| |-- Block
| |-- Engine
| `-- two-pass pagination and rendering
|-- PageCompiler
| |-- coordinate transforms
| |-- font registration
| |-- image registration
| `-- resource dictionaries
|-- Graphics
| |-- Text
| |-- TextBox
| |-- Line
| |-- Rectangle
| |-- Circle
| `-- Image
|-- Serializer
| `-- Elixir values to PDF syntax
`-- Writer
|-- PDF header
|-- indirect objects
|-- cross-reference table
|-- trailer
`-- EOF marker

The generated PDF uses traditional cross-reference tables. Tests verify that xref offsets point to the start of their corresponding indirect objects.

Examples

Run the included examples:

mix run examples/hello.exs
mix run examples/graphics.exs
mix run examples/two_pages.exs
mix run examples/new_features.exs
mix run examples/png.exs
mix run examples/multilingual_layout.exs
mix run examples/complete_showcase.exs
mix run examples/paper_forge_0_4_showcase.exs
mix run examples/paper_forge_0_5_showcase.exs
mix run examples/paper_forge_0_6_authoring.exs
mix run examples/linkedin_document_showcase.exs
mix run examples/paper_forge_0_6_complete.exs

Generated files are written under tmp/.

Development

Clone the repository:

git clone git@github.com:Manuel1471/paper_forge.git
cd paper_forge

Run the test suite:

mix test

Compile with warnings treated as errors:

mix compile --warnings-as-errors

Format the source code:

mix format

Run all checks:

mix do format, compile --warnings-as-errors, test

Run the TrueType and Unicode benchmark script:

mix run benchmarks/truetype.exs

Current Limitations

Performance Envelope

mix run benchmarks/document_scale.exs measures a 5,000-row report. On the reference development machine it generated 179 pages in about 805 ms, serialized in about 62 ms, produced a 617 KB PDF, and increased total BEAM memory by about 67 MB. Treat these values as a reproducible baseline, not fixed assertions.

See API.md for the public compatibility policy and MIGRATING.md for the developing 1.0 upgrade contract.

Production Hardening

See MIGRATING.md for the developing 1.0 compatibility contract.

Roadmap

0.6.x - Document Authoring

Remaining 1.0 Gate

1.x - Production And Distributed Generation

2.x - HTML And CSS

Project Status

PaperForge is pre-1.0 and suitable for experimentation, prototypes, internal tools, and early production evaluation.

The unified layout API is usable, but public API details may still change before version 1.0.0.

Contributing

Contributions, bug reports, architecture discussions, and PDF examples are welcome.

Before opening a pull request:

mix format
mix compile --warnings-as-errors
mix test

License

PaperForge is available under the terms specified in the LICENSE.