PDF Export
PDF Export renders the same validated ZUI view shown by an application as a
production PDF. The request carries the ViewSpec or selected AppSpec page,
authorized datasets, active filters, view state, theme, and paper options. The
renderer does not maintain a second report template and does not fetch business
data.
browser ──▶ application backend ──authorize──▶ data/config sources
│
└── ViewSpec + authorized snapshot
│
▼
POST /v1/render
│
▼
branded PDF
The ZUI React packages remain usable without PDF Export. Use the ZUI Cloud module to avoid operating browsers, or deploy the same API with the Cloud Private operations runbook.
Render a minimal view
Only an organization API key is accepted by POST /v1/render. Keep it in a
trusted backend or secret manager.
curl --fail-with-body https://cloud.example.com/v1/render \
-H "Authorization: Bearer ${ZUI_CLOUD_KEY}" \
-H 'Content-Type: application/json' \
--data '{
"view": {
"version": 2,
"id": "service-check",
"title": "PDF service check",
"root": {
"type": "stack",
"children": ["PDF Export is ready"]
}
},
"page": {"format":"A4", "pageNumbers":true},
"fileName": "service-check"
}' \
--output service-check.pdf
Success returns 200 application/pdf. A valid PDF starts with %PDF-; also
open the document in a PDF parser during acceptance testing rather than
checking only the HTTP status.
Render from an application backend
The backend must authorize the user and minimize the data before calling PDF Export. This example returns both the bytes and correlation metadata to its caller:
type RenderResult = {
pdf: Buffer
contentDisposition: string
requestId: string
renderMs: number
issueCount: number
}
export async function renderRenewalRisk(
currentUser: {id: string},
input: {region: string},
): Promise<RenderResult> {
await authorize(currentUser, 'reports:renewal-risk')
const rows = await loadAuthorizedRenewals(currentUser, input.region)
const response = await fetch(`${process.env.ZUI_CLOUD_URL}/v1/render`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZUI_CLOUD_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
view: renewalRiskView,
datasets: {renewals: rows},
datasetSchemas: [renewalSchema],
params: {region: input.region},
filters: [],
page: {
format: 'A4',
margin: {top: '14mm', end: '12mm', bottom: '16mm', start: '12mm'},
media: 'print',
gridRows: 'all',
pageNumbers: true,
},
fileName: `renewal-risk-${input.region}`,
}),
signal: AbortSignal.timeout(65_000),
})
if (!response.ok) {
const failure = await response.json() as {
error: {code: string; message: string; details?: unknown}
}
throw new Error(`${failure.error.code}: ${failure.error.message}`)
}
return {
pdf: Buffer.from(await response.arrayBuffer()),
contentDisposition: response.headers.get('content-disposition') ?? 'attachment; filename="report.pdf"',
requestId: response.headers.get('x-request-id') ?? '',
renderMs: Number(response.headers.get('x-render-ms') ?? 0),
issueCount: Number(response.headers.get('x-render-issues') ?? 0),
}
}
Do not accept an arbitrary view, dataset, file name, or theme from a browser
unless the application has explicitly decided those fields are user-editable,
validated them, and authorized every referenced resource. A generic unaudited
render proxy turns a server-held organization key into an unintended public
rendering endpoint.
Request contract
The JSON body is strict: unknown top-level and page/theme fields are rejected.
Send exactly one of view or app.
| Field | Contract |
|---|---|
view | A structurally valid ViewSpec version 2. Required unless app is supplied. |
app | An AppSpec version 1 with at least one page. Required unless view is supplied. |
appPage | Page ID or route when app is used. Defaults to the first page. Invalid IDs/routes are rejected. |
datasets | JSON-safe values by dataset name. Values override datasets embedded in view.data. |
datasetSchemas | Dataset schema objects. Each entry requires a string name. |
params | Runtime parameter values merged into the view contract. |
filters | Active filters. Every item requires string dataset and field; operators and values follow the Apps filter contract. |
viewState | Saved component state, such as data-grid sorting and column state. |
theme | Optional CSS, root class names, and root data attributes. |
page | Paper, margin, scale, media, header/footer, grid, and settle behavior. Defaults are applied server-side. |
fileName | Suggested name, at most 200 characters. A safe .pdf name is derived from this, then view title, then view ID. |
A representative request is:
{
"view": {
"version": 2,
"id": "weekly-sales",
"title": "Weekly sales",
"root": {"type": "stack", "children": []}
},
"datasets": {"sales": []},
"datasetSchemas": [{"name": "sales", "columns": []}],
"params": {"region": "EMEA"},
"filters": [
{"dataset": "sales", "field": "status", "op": "in", "value": ["active"]}
],
"viewState": {
"sales-grid": {"sorting": [{"id": "amount", "desc": true}]}
},
"theme": {
"css": ".theme-ocean { --primary: #0f766e; }",
"className": "theme-ocean",
"attributes": {"data-zui-theme": "ocean"}
},
"page": {
"format": "A4",
"landscape": false,
"margin": "12mm",
"scale": 1,
"media": "print",
"gridRows": "all",
"pageNumbers": true,
"header": "<div style=\"font-size:9px\">Example · confidential</div>",
"settleMs": 250
},
"fileName": "weekly-sales"
}
Render one AppSpec page
An application can send a complete AppSpec and select a page without
extracting its view first:
{
"app": {
"version": 1,
"id": "revenue-operations",
"name": "Revenue Operations",
"pages": [
{
"id": "renewals",
"path": "/renewals",
"title": "Renewal risk",
"view": {
"version": 2,
"root": {"type": "stack", "children": []}
}
}
]
},
"appPage": "/renewals",
"datasets": {"renewals": []}
}
If fileName is omitted, an AppSpec request uses the selected page title.
Paper and rendering behavior
| Page field | Allowed values and default |
|---|---|
format | A3, A4, A5, Letter, Legal, or Tabloid; default A4. |
landscape | Boolean; default false. |
margin | One CSS length or {top,end,bottom,start}. Units: px, mm, cm, in; default 12mm. Omitted object sides become zero. |
scale | 0.1–2; default 1. |
media | print or screen; default print. |
header / footer | Chromium HTML fragments, each at most 20,000 characters. |
pageNumbers | Adds Page N of M when no explicit footer is supplied; default false. |
gridRows | all removes grid pagination and renders every row; paged retains the view's pagination. Default all. |
settleMs | Extra wait after fonts and the view report resolve; integer 0–10000, default 250. |
The renderer calculates the printable area at 96 CSS pixels per inch before
laying out the page. Responsive charts therefore measure against the width
they will print at. print media applies the ZUI print rules, including a
paper-oriented palette and removal of interactive chrome; screen retains the
monitor presentation.
Use real margins when enabling header or footer HTML. Chromium renders those
fragments inside the margin box; a small top/bottom margin can clip them. The
footer field takes precedence over pageNumbers, including when it is an empty
string.
Every render uses a fresh incognito browser context. The render page blocks
network requests except to its own service origin, applies reduced motion,
waits for document.fonts.ready, waits settleMs, and prints backgrounds. Put
all data and required theme CSS in the payload; external images, fonts, or data
fetches are not a supported dependency.
Unknown view node types and recoverable renderer problems become visible
placeholders plus render issues. They do not necessarily fail the PDF. Treat a
non-zero X-Render-Issues as a quality signal and inspect the request record
before distributing the document.
Response contract and history
Successful POST /v1/render responses include:
| Header | Meaning |
|---|---|
Content-Type | application/pdf. |
Content-Disposition | Sanitized attachment file name. |
Content-Length | PDF byte count. |
X-Request-Id | Organization-scoped render request identifier for support and history. |
X-Render-Ms | Server-side render duration in milliseconds. |
X-Render-Issues | Number of semantic/render issues recorded for the document. |
Inspect recent requests and quota from a session or API key:
GET /v1/requests?limit=100
Authorization: Bearer zui_pk_...
GET /v1/requests/{requestId}
Authorization: Bearer zui_pk_...
GET /v1/usage
Authorization: Bearer zui_pk_...
/v1/usage returns {usage: {month, used, quota, remaining}}. Request history
is organization-scoped and includes status, view metadata, format, bytes,
duration, issue details, error, key ID, and creation time. The limit query is
clamped to 1–500; there is no cursor or automatic retention API in this
release.
Quota counts accepted browser render attempts, including a render that fails
after consuming browser capacity. Payload validation failures and requests
rejected because the queue is full are not recorded as completed render
attempts. Check /v1/usage before offering high-volume batch exports.
Failure and retry policy
All non-PDF responses use the shared JSON error envelope.
| Status/code | Meaning | Retry decision |
|---|---|---|
400 invalid_body | Invalid ViewSpec, payload, page option, filter/schema shape, or unknown field. | Never retry unchanged. Surface safe field details to engineering. |
401 unauthenticated / api_key_invalid | Missing, unknown, or revoked key. | Fix or rotate the credential. |
403 wrong_credential | A session token was sent to the key-only render route. | Use an organization API key from the backend. |
413 payload_too_large | Body exceeds ZUI_CLOUD_PDF_BODY_LIMIT_MB. | Reduce rows/fields, simplify the view, or split the report. |
429 quota_exceeded | The organization used its calendar-month quota. | Stop automatic retries; inspect /v1/usage and quota policy. |
500 render_failed | Chromium or the render page failed. details.requestId identifies the recorded attempt. | Retry only a bounded number of times with backoff after ruling out deterministic payload failure. |
503 busy | Active render slots are full and the bounded wait queue reached its limit. | Retry with exponential backoff and full jitter; cap attempts and preserve the user deadline. |
503 render_unavailable | The process has no renderer. | Operator action; do not create a client retry storm. |
POST /v1/render has no idempotency key, but a successful render creates no
external side effect beyond usage/history. If a response is lost, a retry may
produce a second charged attempt. Communicate that tradeoff in batch systems.
Visual acceptance strategy
PDF correctness is visual as well as syntactic. Maintain representative golden cases for:
- every supported paper size and orientation used by the product;
- long data grids, explicit paged grids, and multi-page charts/text;
- light/dark or branded themes in both
printandscreenmedia; - headers, footers, page numbers, and non-ASCII text;
- missing data, empty states, unknown node types, and renderer issues;
- maximum normal dataset and page-count cases;
- the Chromium version shipped in production.
Render these cases after ZUI package, theme, font, or Chromium upgrades. Parse
the resulting PDF, compare page count and extracted text, and use image-based
visual diffs with reviewed tolerances. A 200 response alone does not prove a
report is distributable.
Self-host PDF Export
Follow the complete Cloud Private operations runbook. PDF-specific requirements and controls are:
- Build and deploy the complete
dist/webbeside the matching Go binary. The internal browser loads/render.htmlfrom that directory through the process's loopback origin. - Install the Playwright-pinned Chromium and dependencies. Set
ZUI_CLOUD_PDF_CHROMIUM_EXECUTABLEwhen automatic discovery cannot resolve it. Startup fails early if no compatible executable is found. - Run the process as a non-root service user with a writable temporary directory and enough shared memory. Do not weaken the host sandbox merely to make Chromium start.
- Start with
ZUI_CLOUD_PDF_CONCURRENCY=2. Each active render gets its own browser context;ZUI_CLOUD_PDF_QUEUE_LIMITbounds waiting work. Measure memory, CPU, output size, and p95/p99 duration with production-shaped payloads before changing either value. - Set the reverse proxy body limit slightly above
ZUI_CLOUD_PDF_BODY_LIMIT_MBand its read timeout aboveZUI_CLOUD_PDF_RENDER_TIMEOUT_MSplus expected queue wait. Keep every client deadline finite and longer than the proxy only when the user experience permits it. /healthproves only that the Go process responds and reports its current pending count. Chromium starts lazily on the first render, so use an authenticated synthetic PDF to prove readiness.- Alert on
503 busy, render failure rate,X-Render-Mspercentiles, non-zero issue counts, quota remaining, process restarts, host memory/CPU, and disk growth from request history. - The bundled renderer intentionally has no business-data network access. Keep application authorization and data resolution in the calling backend; do not add general egress as a workaround for incomplete payloads.
The present runtime is a single-process SQLite deployment. Adding replicas behind a load balancer is not a supported PDF scaling mechanism because accounts, quotas, and history share that local database. Scale vertically, control incoming concurrency, and use the bounded queue. Review the topology limits before committing an availability target.
Continue with Config Store when a saved head feeds an export, or Scheduled Delivery when artifacts must be generated and delivered later.