OkraPDF — Create
One Worker, three modes to produce designed PDFs:
POST /render— baked design system from minimax-pdf; pick atype(report, magazine, editorial, poster…) and send content blocks. Container renders body in Python (reportlab), Browser Rendering renders the cover, Worker merges.POST /browser/html-to-pdf— agent writes raw HTML; Chromium returns a PDF. Pixel-perfect custom covers and one-pagers.POST /codemode/run— agent submits JS that composespdf.render+browser.htmlToPdf+pdf.mergein one request. Custom cover + baked body in a single call.
Architecture
Request → Worker
│
├─ DO Container (Python: palette → cover-html → body → merge)
└─ env.BROWSER (Chromium via @cloudflare/puppeteer)
- Python container (~520MB image) — reportlab + matplotlib + pypdf.
Handles
/tokens,/cover-html,/body,/mergeendpoints. Durable-Object-backed, warm after first request, sleeps after 5 min. - Cloudflare Browser Rendering — renders agent-authored HTML via
puppeteer.launch(env.BROWSER, { keep_alive: 600000 }). - Worker Loader (codemode) — sandboxed JS runs with injected host fns.
Mode 1: baked designs (POST /render)
One call in, one merged PDF out. Worker orchestrates all stages.
curl -X POST https://pdf-render-agent.steventsao.workers.dev/render \
-H 'content-type: application/json' \
--data @spec.json -o out.pdf
Request shape:
{
"title": "Q3 Review",
"type": "report", // or: proposal, resume, portfolio, academic,
// general, minimal, stripe, diagonal,
// frame, editorial, magazine, darkroom,
// terminal, poster
"author": "Strategy Team",
"date": "April 2026",
"accent": "#2D5F8A", // muted hex; avoid default blue
"cover_bg": "#1A1A1A", // optional
"abstract": "...", // magazine/darkroom only
"cover_image": "https://...jpg", // magazine/darkroom/poster
"skipCover": false, // true = body-only (~400ms warm)
"content": [ // minimax-pdf block types:
{ "type": "h1", "text": "Executive Summary" },
{ "type": "body", "text": "Justified paragraph with <b>markup</b>." },
{ "type": "callout", "text": "Highlighted insight." },
{ "type": "bullet", "text": "Point one." },
{ "type": "numbered", "text": "Counter resets automatically." },
{ "type": "table",
"headers": ["Col", "B", "C"],
"col_widths": [0.5, 0.25, 0.25], // fractions of usable width
"rows": [["a","1","2"]],
"caption": "..." },
{ "type": "chart", "chart_type": "bar",
"labels": ["Q1","Q2"], "datasets": [{"label":"Rev","values":[100,200]}] },
{ "type": "code", "text": "print('x')", "language": "python" },
{ "type": "math", "text": "E = mc^2", "label": "(1)" },
{ "type": "divider" }, { "type": "pagebreak" }, { "type": "spacer", "pt": 18 }
]
}
Perf (warm container):
skipCover: true→ ~400ms-2s (body only, via container alone)- full pipeline → ~6-8s (adds Browser Rendering cover + merge)
Mode 2: custom HTML cover (POST /browser/html-to-pdf)
Agent writes the HTML. Browser Rendering returns a PDF.
curl -X POST https://pdf-render-agent.steventsao.workers.dev/browser/html-to-pdf \
-H 'content-type: application/json' \
-d '{"html":"<!doctype html>...","width":794,"height":1123}' \
-o cover.pdf
Request:
{
"html": "<!doctype html>...",
"width": 794, // px, default 794 (A4 @ 96dpi)
"height": 1123, // px, default 1123
"waitForSelector": "#ready", // optional
"waitForTimeoutMs": 5000
}
Non-negotiable template rules (see templates/editorial-hero.html):
@page { size: 794px 1123px; margin: 0; }— locks PDF page dims.html, body { width: 794px; height: 1123px; overflow: hidden; }— viewport anchor.- Use CSS Grid rows (no absolute positioning) to guarantee no overlaps.
grid-template-columns: minmax(0, 1fr) 220px;— theminmax(0, …)prevents content from expanding a flexible column past its share.overflow: hiddenon the page container — last-line defense against bleeds.@importGoogle Fonts at the top; Worker useswaitUntil: "domcontentloaded"(notnetworkidle0— fonts can stall that).
See templates/editorial-hero.html for a working magazine-cover reference.
Mode 3: agent composition via codemode (POST /codemode/run)
Agent submits JS. Worker injects host fns: pdf.render, pdf.merge,
browser.htmlToPdf. Sandbox has globalOutbound: null — only the host fns
are reachable.
// user.js (the string you send as body.code)
export default async function ({ pdf, browser }) {
const cover = await browser.htmlToPdf({ html: MY_COVER_HTML });
const body = await pdf.render({ ...spec, skipCover: true });
const out = await pdf.merge([cover.pdfBase64, body.pdfBase64]);
return { pdfBase64: out.pdfBase64, size: out.size };
}
curl -X POST https://pdf-render-agent.steventsao.workers.dev/codemode/run \
-H 'content-type: application/json' \
-d '{"code":"...","returnPdf":true}' -o final.pdf
Build the request from Python/Node with JSON.stringify to embed HTML
and spec safely:
req = {
"code": f"""
const COVER_HTML = {json.dumps(cover_html)};
const BODY_SPEC = {json.dumps(body_spec)};
export default async ({{ pdf, browser }}) => {{
const cover = await browser.htmlToPdf({{ html: COVER_HTML }});
const body = await pdf.render(BODY_SPEC);
const out = await pdf.merge([cover.pdfBase64, body.pdfBase64]);
return {{ pdfBase64: out.pdfBase64, size: out.size }};
}};
""",
"returnPdf": True
}
Accent color — pick from content, not a palette
The minimax-pdf design system rule: don't default to blue. Muted, desaturated, pulled from the document's industry.
| Context | Suggested hex |
|---|---|
| Legal / compliance / finance | #1C3A5E, #2E3440 |
| Healthcare | #2A6B5A, #3A7D6A |
| Tech / engineering | #2D5F8A, #3D4F8A |
| Sustainability | #2E5E3A, #4A5E2A |
| Creative / arts | #6B2A35, #5A2A6B, #8A3A2A |
| Academic / research | #2A5A6B, #2A4A6B |
| Premium / luxury | #1A1208, #4A3820 |
Gotchas learned the hard way
Browser Rendering hangs on first call.
- Cause: missing
keep_aliveonpuppeteer.launch()orwaitUntil: "networkidle0"with@imported fonts. - Fix (already in Worker):
puppeteer.launch(env.BROWSER, { keep_alive: 600000 })+setContent(html, { waitUntil: "domcontentloaded" }).
Content clips on the right edge of the PDF.
- Cause: Worker didn't set viewport before
setContent; Chrome used its default viewport, layout math didn't match PDF size. - Fix (in Worker):
page.setViewport({ width: 794, height: 1123 })beforesetContent;page.pdf({ width: "794px", height: "1123px" }).
Table cells wrap digits like 90.74 → 90.7 / 4.
- Cause:
col_widthsfraction too small for the numeric precision. - Fix: give numeric columns ≥ 0.10; data fits at 0.11-0.12 for 4-char decimals.
"specialized" wraps as "specializ/ed" in table rows.
- Cause: Category col < 0.13 of usable width.
- Fix: either widen to 0.13+ or abbreviate to "spec" / "VLM".
Reportlab justification stretches short paragraphs.
- Every
bodyblock is full-justified. For closing 1-2 sentence paragraphs, fold into the previous body block or accept the rivering.
Trailing caption block orphans to a nearly-empty last page.
- Drop the trailing caption or inline it in the last body paragraph.
Worker endpoints at a glance
GET /health container liveness
GET /browser/ping launch+close diagnostic (~3.5s)
GET /browser/limits puppeteer.limits(env.BROWSER)
POST /render body: RenderSpec -> application/pdf
POST /browser/html-to-pdf body: {html, width?...} -> application/pdf
POST /codemode/run body: {code, returnPdf?} -> json | application/pdf
Deploy your own Worker
The reference impl lives in okra meta-repo at
apps/api/packages/pdf-render-agent/. Wrangler config requires:
"containers": [{ "class_name": "PdfRender", "image": "./Dockerfile",
"max_instances": 5, "instance_type": "standard-1" }],
"durable_objects": { "bindings": [{ "name": "PDF_RENDER", "class_name": "PdfRender" }] },
"worker_loaders": [{ "binding": "LOADER" }],
"browser": { "binding": "BROWSER" }
Needs: Workers Paid (Containers + Browser Rendering both require it). First deploy pushes the ~520MB container image to CF's registry.
Template library
templates/editorial-hero.html— magazine cover with big display number, side attribution, title + lede + TOC + footer. CSS Grid rows, all safety rails in place. Works as-is viaPOST /browser/html-to-pdf. Swap the content between<body>tags or parametrize later with a builder function.