PDF Generation skill for Codex

Data in, print-ready PDF out, every time. Codex picks one PDF engine for the job instead of mixing three, builds documents from a typed data model, and handles the parts that usually break: embedded fonts for every script, long tables that break with repeated headers, page X of Y footers, and base_url asset paths. Output is checked with pdffonts, pdftotext and rendered previews using worst-case fixtures.

  • Documents & Office
  • $pdf-generation
  • 205 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- pdf-generation

Installs to ~/.agents/skills/pdf-generation/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $pdf-generation, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Generates PDFs from application data with ReportLab (Platypus flowables), WeasyPrint (HTML and CSS paged media) or Typst, with embedded fonts, repeating table headers, page numbers, metadata and reproducible output for tests. Use when code must produce a PDF report, statement, certificate, label sheet or export, when choosing a PDF library for a service, or when generated PDFs have broken fonts, overflowing tables or missing page numbers.

Use it when

  • A service or script must produce PDF reports, statements or certificates
  • Choosing a PDF library for a new feature
  • Generated PDFs have missing glyphs, clipped tables or no page numbers
  • PDF output needs reproducible tests

Use something else when

How to check the result

Run pdffonts to confirm every font is embedded, pdftotext to confirm the text is extractable, and render previews of the empty, one-row and thousands-of-rows fixtures.

The mistake it prevents

Calling WeasyPrint without base_url, so fonts and images resolve to nothing and the PDF silently falls back to default fonts.

~/.agents/skills/pdf-generation/SKILL.md

--- name: pdf-generation description: Generates PDFs from application data with ReportLab (Platypus flowables), WeasyPrint (HTML and CSS paged media) or Typst, with embedded fonts,… license: MIT ---

Done well means: the PDF is produced by one code path from structured data, fonts are embedded and cover every character in the data, long tables break across pages with their header repeated, and a test can regenerate the file byte-for-byte or at least check its text and page count.

Pick the engine once

SituationEngineInstall notes
Designers own an HTML/CSS template; content is text-heavyWeasyPrintpip install weasyprint; needs Pango system libraries
Pure Python, no system deps, precise drawing, huge row countsReportLab Platypuspip install reportlab
Typeset look (math, footnotes, references), non-Python callersTypsttypst CLI or pip install typst
Page must match a live web app exactly, JS-rendered chartsHeadless ChromiumUse $html-to-pdf
Filling an existing PDF formnone of theseUse $pdf-forms

Read AGENTS.md and the dependency manifest first: if the project already ships one of these, extend it rather than adding a second engine. Package installs need network; in a sandbox with network off, ask for approval before installing.

Workflow

- [ ] 1. Define the data contract (a dataclass / pydantic model / JSON schema)
- [ ] 2. Choose the engine (table above) and the page size: A4 or US Letter, margins
- [ ] 3. Register fonts that cover the data's scripts (Latin, CJK, Arabic, etc.)
- [ ] 4. Build the document from the data model, never from ad-hoc strings
- [ ] 5. Add header/footer, page X of Y, metadata (title, author, language)
- [ ] 6. Render with worst-case fixture data: 0 rows, 1 row, 5,000 rows, long names
- [ ] 7. Verify (fonts embedded, text extractable, page count, visual check)

ReportLab Platypus

from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle

pdfmetrics.registerFont(TTFont("Inter", "fonts/Inter-Regular.ttf"))
pdfmetrics.registerFont(TTFont("Inter-Bold", "fonts/Inter-Bold.ttf"))


class NumberedCanvas(canvas.Canvas):
    """Defers page output so the footer can say 'Page X of Y'."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._saved = []

    def showPage(self):
        self._saved.append(dict(self.__dict__))
        self._startPage()

    def save(self):
        total = len(self._saved)
        for state in self._saved:
            self.__dict__.update(state)
            self.setFont("Inter", 8)
            self.drawRightString(A4[0] - 18 * mm, 10 * mm, f"Page {self._pageNumber} of {total}")
            super().showPage()
        super().save()


def build(path, title, rows):
    styles = getSampleStyleSheet()
    styles["Normal"].fontName = "Inter"
    doc = SimpleDocTemplate(
        path, pagesize=A4, title=title, author="Billing service", lang="en",
        leftMargin=18 * mm, rightMargin=18 * mm, topMargin=20 * mm, bottomMargin=18 * mm,
    )
    data = [["SKU", "Description", "Qty", "Amount"]] + [
        [r["sku"], Paragraph(r["desc"], styles["Normal"]), str(r["qty"]), f'{r["amount"]:,.2f}']
        for r in rows
    ]
    table = Table(data, colWidths=[25 * mm, None, 15 * mm, 28 * mm], repeatRows=1)
    table.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, 0), "Inter-Bold"),
        ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#EEF1F5")),
        ("ALIGN", (2, 1), (-1, -1), "RIGHT"),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("LINEBELOW", (0, 0), (-1, 0), 0.6, colors.black),
        ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#FAFAFA")]),
    ]))
    story = [Paragraph(title, styles["Title"]), Spacer(1, 6 * mm), table]
    doc.build(story, canvasmaker=NumberedCanvas)

ReportLab rules:

  • Wrap any cell that can be long in Paragraph; a bare string never wraps and runs off the page. colWidths=None for one column lets it take the remaining width.
  • Paragraph text is mini-HTML: escape user data with xml.sax.saxutils.escape or a stray & or < raises a parse error.
  • Built-in Helvetica/Times cover Latin-1 only. Register a TTF for anything else, and for CJK use a font that actually contains the glyphs (Noto Sans CJK).
  • For reproducible bytes in tests: from reportlab import rl_config; rl_config.invariant = 1 before building (removes timestamps and random IDs).

WeasyPrint (HTML + CSS paged media)

from jinja2 import Environment, FileSystemLoader, select_autoescape
from weasyprint import HTML

env = Environment(loader=FileSystemLoader("templates"), autoescape=select_autoescape())
html = env.get_template("statement.html").render(customer=customer, rows=rows)
HTML(string=html, base_url="templates/").write_pdf("statement.pdf")
@page {
  size: A4;
  margin: 20mm 18mm 18mm;
  @bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; }
  @top-left { content: string(doc-title); }
}
h1 { string-set: doc-title content(); }
@font-face { font-family: "Inter"; src: url("fonts/Inter-Regular.woff2"); }
body { font-family: "Inter", sans-serif; font-size: 10pt; }
thead { display: table-header-group; }      /* repeat header on each page */
tr, img, figure { break-inside: avoid; }
h2 { break-after: avoid; }
.page-break { break-before: page; }

WeasyPrint rules:

  • Always pass base_url; without it relative image, font and stylesheet paths fail silently and the PDF renders with fallback fonts and missing images.
  • It does not run JavaScript. Pre-render charts to SVG (matplotlib savefig(format="svg")) and inline them.
  • Flexbox and grid work but do not fragment across pages well; use block layout and tables for anything that may span a page break.
  • macOS with Homebrew Pango: if import fails with “cannot load library”, run with DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib. In Docker, install libpango-1.0-0 and libpangoft2-1.0-0 (Debian names).
  • Archival output: write_pdf("out.pdf", pdf_variant="pdf/a-3b"); tagged output for accessibility: pdf_tags=True (full PDF/UA work belongs to $pdf-accessibility).

Typst

Typst compiles fast, has real typography, and takes data as JSON.

#let data = json("data.json")
#set page(paper: "a4", margin: 18mm, footer: context align(right)[
  #counter(page).display("1 of 1", both: true)
])
#set text(font: "Inter", size: 10pt, lang: "en")
= #data.title
#table(
  columns: (auto, 1fr, auto),
  table.header[*SKU*][*Description*][*Amount*],
  ..data.rows.map(r => (r.sku, r.desc, str(r.amount))).flatten(),
)
typst compile --root . --font-path fonts report.typ out/report.pdf
typst compile --input customer=ACME report.typ out.pdf    # read with sys.inputs.customer

From Python: typst.compile("report.typ", output="report.pdf", font_paths=["fonts"]). For template design beyond this, use $typst-documents.

Gotchas

  • Generating in a request handler: large documents take seconds and hundreds of MB. Run in a worker/queue and stream the file to storage, not into memory.
  • Locale formatting (decimal comma, currency symbols, dates) belongs in the data layer (babel.numbers.format_currency), not in the template.
  • Do not embed user-uploaded SVG or HTML without sanitising; WeasyPrint fetches URLs it finds. Pass a restrictive url_fetcher that only allows local paths when the template includes user content.
  • Right-to-left scripts need a shaping engine: WeasyPrint and Typst handle Arabic and Hebrew; ReportLab needs extra work, so prefer the other two for RTL.

Verification

pdffonts out.pdf               # every font row must say "yes" under emb
pdfinfo out.pdf                # page count, title, page size
pdftotext -layout out.pdf - | head -50
pdftoppm -r 70 -png out.pdf preview && ls preview*
  • Every font embedded; no Helvetica substitution when a custom font was intended.
  • Text extracts correctly (copyable PDFs are searchable and accessible).
  • Look at the rendered PNGs for the worst-case fixtures: long text wraps, tables break with repeated headers, nothing clipped at the bottom margin.
  • Add a test that asserts page count and a few key strings from pdftotext or pypdf.PdfReader(...).pages[0].extract_text() rather than comparing raw bytes.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "PDF Generation"
  short_description: "Data in, print-ready PDF out, every time"
  default_prompt: "$pdf-generation Add a PDF export of the monthly statement to our Django app"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem