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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- pdf-generation Installs to ~/.agents/skills/pdf-generation/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install pdf-generation The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project pdf-generation Installs to .agents/skills/pdf-generation/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/pdf-generation.zipunzip -o pdf-generation.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) pdf-generation PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The PDF must match a JavaScript-rendered web page: use html-to-pdf
- The document is an invoice or receipt with tax rules: use invoice-generation
- You are editing or extracting from an existing PDF: use pdf-processing
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.
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
| Situation | Engine | Install notes |
|---|---|---|
| Designers own an HTML/CSS template; content is text-heavy | WeasyPrint | pip install weasyprint; needs Pango system libraries |
| Pure Python, no system deps, precise drawing, huge row counts | ReportLab Platypus | pip install reportlab |
| Typeset look (math, footnotes, references), non-Python callers | Typst | typst CLI or pip install typst |
| Page must match a live web app exactly, JS-rendered charts | Headless Chromium | Use $html-to-pdf |
| Filling an existing PDF form | none of these | Use $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=Nonefor one column lets it take the remaining width. Paragraphtext is mini-HTML: escape user data withxml.sax.saxutils.escapeor 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 = 1before 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, installlibpango-1.0-0andlibpangoft2-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_fetcherthat 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
Helveticasubstitution 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
pdftotextorpypdf.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.yaml
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.