# makesPDF > Beautiful PDFs from your data. Send JSON or Markdown, get a compliant PDF > back. Designed to be driven by AI agents — there is a dedicated skill file > that teaches any LLM how to author PDF templates. > **URLs in this document are HTTP URLs, not filesystem paths.** If you need > to re-fetch the skill file later, use a web fetch / HTTP tool — do not > call your filesystem `Read` tool with `/skills/pdf-template-author.md`. > The full URL is always `https://makespdf.com/skills/pdf-template-author.md`. ## Quick links for AI agents - AI & Agent landing (production): https://ai.makespdf.com/ - Documentation index: https://makespdf.com/docs - Skill file (single source of truth for the template DSL): https://makespdf.com/skills/pdf-template-author.md - Companion skills (fetch on demand from the same origin): - Authentication / OAuth device flow: https://makespdf.com/skills/pdf-auth.md - API endpoints (preview / render / validate): https://makespdf.com/skills/pdf-api.md - Per-doc-type recipes: https://makespdf.com/skills/pdf-recipes.md - Custom font uploads: https://makespdf.com/skills/pdf-fonts.md - Server-side application integration (API key, error handling, per-language snippets): https://makespdf.com/skills/pdf-integration.md - API reference: https://makespdf.com/docs/api - Agent setup walkthrough: https://makespdf.com/docs/ai-setup - Model benchmark digest (which LLMs author makesPDF templates well, with failing-DSL snippets for diagnosis): https://makespdf.com/ai/benchmark.md Structured companion (same data, JSON shape, no DSL snippets): https://makespdf.com/ai/benchmark.json - CLI (`@makespdf/cli` on npm): https://github.com/makesPDF/makespdf-cli ## Authentication (read this first) All /api/v1/* endpoints need a Bearer token by default. Two carve-outs exist (full policy at https://makespdf.com/docs/api): the public veraPDF utility at `POST /api/v1/pdf/validate` and — when the operator enables the `promo:md_anonymous_enabled` flag — `POST /api/v1/md` itself. The `/md` carve-out is rate-limited per IP (60/hour, 200/day, 20 pages per render) and intended for low-volume callers (IDE plugins, quick scripts, demos). Authenticate anyway for higher limits, artifact capture, and the rest of the API. Stale / invalid Bearer tokens always 401 — the server never silently downgrades a failed auth attempt to anonymous. There are two paths to get a Bearer token; **AI agents should use the device flow — never ask the user for a password or API key directly, and never register an account or sign in on the user's behalf.** If the device approval page (https://makespdf.com/device?code=…) shows a login screen when you open it in a browser MCP, you are done with the browser. Hand control back to the user so they can sign in themselves — the account owner is the human whose credits get charged, and the approval must be their decision, not yours. Do not fill the registration form, do not auto-submit credentials you scraped from earlier messages. Just poll /api/v1/device/token and wait. ### Device authorization flow (preferred for AI agents and CLIs) OAuth 2.0 RFC 8628. The user authenticates in their browser; you receive a token without ever touching their credentials. 1. `POST https://makespdf.com/api/v1/device/code` (no auth required, optional body `{ "client_name": "My Agent" }`). Response: ```json { "device_code": "", "user_code": "ABCD-1234", "verification_uri": "https://makespdf.com/device", "verification_uri_complete": "https://makespdf.com/device?code=ABCD-1234", "expires_in": 600, "interval": 5 } ``` 2. **Tell the user:** "Open https://makespdf.com/device?code=ABCD-1234 in your browser and approve this device." Show both the URL and the user_code so they can verify it matches what's shown in the browser. 3. **Wait for the user to approve**, then make **one** request: `POST https://makespdf.com/api/v1/device/token` with `{ "device_code": "..." }`. On success you get HTTP 200 `{ "access_token": "mpdf_...", "token_type": "Bearer" }`. Save the access_token; that's the user's API key. *Automated harnesses* with no interactive user (CI, scripted demos) may fall back to polling every `interval` seconds — HTTP 400 `{ "error": "authorization_pending" }` means keep waiting. But when a human is present, ask them to confirm before calling. 4. Use `Authorization: Bearer ` on every subsequent request. ### API key (for humans setting up CI or scripting their own account) Generate at https://makespdf.com/settings/api-keys. Same Bearer token format. ## Quickstart: I have JSON data, I want a PDF Three paths, cheapest first. All require a Bearer token (see Authentication above). 1. **Markdown → PDF** (simplest, sub-second, no AI call). Format your data as GitHub Flavored Markdown, POST to `/api/v1/md`: ``` curl -X POST https://makespdf.com/api/v1/md \ -H "Authorization: Bearer $MAKESPDF_API_KEY" \ -H "Content-Type: application/json" \ -d '{"markdown": "# Invoice #123\n\n| Item | Price |\n|---|---|\n| Widget | $10 |"}' \ -o invoice.pdf ``` 2. **DSL → PDF** (custom layouts, sub-second, no AI call). Read the skill file, write a short JavaScript DSL snippet, POST to `/api/v1/preview`: ``` curl -X POST https://makespdf.com/api/v1/preview \ -H "Authorization: Bearer $MAKESPDF_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dsl": "const template = doc(...); const sampleData = {...};", "data": {...}}' \ -o invoice.pdf ``` 3. **AI-generated template** (one-shot, ~20s). If the agent can't author a template itself, POST to `/api/v1/render` with a document type and data and the server will generate + render in one call. Details at https://makespdf.com/docs/api. **Always read the skill file first for path 2.** It is ~25KB and teaches the full DSL, document recipes (invoice, receipt, quote, report, CV, etc.), style properties, and worked examples. ## Endpoints - POST /api/v1/device/code — Start OAuth device flow (no auth required) - POST /api/v1/device/token — Poll for access_token (no auth required) - POST /api/v1/md — Markdown to PDF (no AI call, sub-second; anonymous-callable when promo:md_anonymous_enabled is set) - POST /api/v1/preview — Render a DSL template with data - POST /api/v1/render — One-shot AI generation + render (~20s) - POST /api/v1/md/validate — Pre-flight accessibility check for markdown - POST /api/v1/preview/validate — Pre-flight structure + a11y check for templates All /api/v1/* endpoints except the device-flow pair require Bearer token (API key) or a session cookie. Unauthenticated calls return a 401 whose body includes a `device_authorization` block with the exact steps above, plus `agent_guide` and `skill` URLs so agents can self-recover. ## Pay-per-call (x402) — for agents without an account Agents and wallets that don't have a Bearer token can pay per request in USDC on Base mainnet. No signup, no API key, no session — just a signed EIP-3009 authorization on each call. This implements the [x402 spec](https://www.x402.org/) v2 wire format. **Pricing (production, Base mainnet `eip155:8453`):** - POST /api/v1/md — $0.01 per render (USDC, 6 decimals → atomic `10000`) - POST /api/v1/render — $0.02 per render (inline `{ dsl, data }` body only; `{ templateId }` is not available to wallet callers in v1) `/api/v1/preview`, validation, and the template / font / library endpoints are not paid-callable — use a Bearer token for those. **Flow:** 1. Send the request with no auth. Server replies HTTP **402 Payment Required** with a JSON body that advertises what to pay: ```json { "x402Version": 2, "error": "Payment required", "resource": { "url": "https://makespdf.com/api/v1/md", "description": "Convert GitHub-flavoured Markdown to a tagged, accessible PDF.", "mimeType": "application/pdf", "serviceName": "makesPDF", "tags": ["pdf", "markdown", "accessibility", "pdf-ua", "pdf-a"] }, "accepts": [ { "scheme": "exact", "network": "eip155:8453", "amount": "10000", "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "payTo": "0x9bB96Acb3fD11D8949D862736B96e7e12D319DC4", "maxTimeoutSeconds": 60, "extra": { "name": "USD Coin", "version": "2" } } ], "extensions": { "bazaar": { "info": {}, "schema": {} } }, "docs": "https://makespdf.com/docs/api", "agent_guide": "https://makespdf.com/llms.txt", "skill": "https://makespdf.com/skills/pdf-template-author.md" } ``` This is the x402 **v2** body shape: a top-level `resource` object, an `accepts` array of payment requirements, and an `extensions.bazaar` discovery object (abbreviated above — it carries the endpoint's input `info` + JSON `schema`). 2. Build an EIP-3009 `transferWithAuthorization` payload paying `payTo` the advertised `amount` of the advertised `asset`, sign it, base64-encode the JSON `PaymentPayload`, and resend the original request with header `PAYMENT-SIGNATURE: `. Echo the 402's `resource` and `extensions` back in the payload so the settlement is cataloged for discovery. 3. Server verifies + settles via the Coinbase CDP facilitator (`https://api.cdp.coinbase.com/platform/v2/x402`), then renders. Response is the PDF bytes plus header `X-PAYMENT-RESPONSE` carrying the settlement receipt. 4. **Idempotency.** Resending the same `PAYMENT-SIGNATURE` within 5 minutes returns the cached PDF bytes verbatim with header `X-Idempotent-Replay: 1`. No double settlement, safe to retry on network failure. 5. **Refunds.** If verification succeeds but rendering fails, the payment is marked `failed` and processed off-band as a refund to the payer wallet. No automatic retry. **Rate limits.** Wallet callers are rate-limited by payer address (`wallet:0x…`) at the same per-endpoint thresholds as authed callers. **Watermark.** Paid renders are watermark-free by construction. **Discovery (for indexes and crawlers).** This endpoint set is also auto-discoverable via Coinbase Bazaar; no submission step is required. For the full spec, scheme negotiation, and SDK pointers see [x402.org](https://www.x402.org/). ## Embedded skill file The block below is the full contents of pdf-template-author.md, included so crawlers and agents can ingest it without a second fetch. --- > **Skill version:** `fbdd5e3@2026-05-21T17:55:13+10:00` (`pdf-template-author`). > If you have a cached copy of this skill with a different version token, > discard it and use this copy. Re-fetch this URL at the start of each > session to detect updates. # PDF Template Author Skill > **Note to AI agents:** This document is served over HTTP at > `https://makespdf.com/skills/pdf-template-author.md` (or > `http://localhost:8788/skills/pdf-template-author.md` in dev). It is **not > a local file.** If you need to re-fetch it, use a web fetch / HTTP tool — > do **not** call your filesystem `Read` tool with `/skills/...`. Paths > that begin with `/api/v1/`, `/device`, or `/skills/` in this document are > URL paths relative to the makespdf origin, not filesystem paths. You are an expert PDF template designer. You create templates using a compact builder DSL that renders into professional PDF documents via the makespdf.com engine. ## Companion skills Read these alongside this file when the topic comes up — they are not duplicated here in full. Each is fetchable from the makespdf origin at the URL shown. - **Authentication** — [`/skills/pdf-auth.md`](https://makespdf.com/skills/pdf-auth.md). How to obtain a Bearer token via the OAuth 2 device flow (RFC 8628). Read this if you don't already have a token. Covers the four-step flow, the do-not-do-this list (no asking for passwords, no clicking through `/device` yourself in browser MCP), and the 401 response shape that points back here. - **Paying without an account (x402)** — wallet-owning agents can skip signup entirely and pay per call in USDC on Base mainnet. POST your request with no auth; the server replies HTTP 402 advertising the price (`$0.01` for `/api/v1/md`, `$0.02` for `/api/v1/render` with an inline `{ dsl, data }` body — `templateId` is account-only). Sign an EIP-3009 `transferWithAuthorization` for the advertised amount, base64-encode the JSON `PaymentPayload`, and resend the same request with header `PAYMENT-SIGNATURE: `. Idempotent on retry within 5 minutes. Full spec, V2 wire shape, and a real example body live in [`/llms.txt`](https://makespdf.com/llms.txt) under §Pay-per-call. - **API endpoints** — [`/skills/pdf-api.md`](https://makespdf.com/skills/pdf-api.md). How to call `POST /api/v1/preview`, `POST /api/v1/render`, and `POST /api/v1/preview/validate` — request/response shapes, error codes, billing rules, and the `jq`-based shell pattern for sending multi-line DSL. Read after authoring a template. - **Document recipes** — [`/skills/pdf-recipes.md`](https://makespdf.com/skills/pdf-recipes.md). Per-doc-type assembly skeletons (Invoice, Receipt, Quote, Statement, Letter, CV). Self-contained — each fits on a screen. Fetch only the one matching the user's request. - **Custom fonts** — [`/skills/pdf-fonts.md`](https://makespdf.com/skills/pdf-fonts.md). Uploading TTF/OTF fonts to your account so any saved template can reference them by `font-family` name. Covers variable-font handling (rejected — use static instances), Google-Fonts → variant-slot mapping, and the `POST /api/v1/fonts` upload API. Read this before generating a template that uses anything other than `Inter`, `NotoSans`, `Cousine`, or `NotoSymbols2` (the embedded families). - **Server-side integration** — [`/skills/pdf-integration.md`](https://makespdf.com/skills/pdf-integration.md). How to wire makesPDF into an application codebase once a template exists: API-key setup, where to store the `templateId`, error handling, retry policy, per-language snippets (Node, Python, Go, Ruby, PHP), and patterns for delivering the PDF (browser stream, S3, email, background jobs). Read this if the user is asking "integrate this into my app" rather than authoring a new template. ## How This Works 1. You write a **builder DSL script** (JavaScript) that defines a `template` and `sampleData` 2. The engine converts it to a DocumentDefinition, resolves `{{variables}}`, lays out elements, and returns a **PDF/A-2A + PDF/UA-1 dual-compliant** PDF (sub-second, no AI) 3. All output is archival-grade and accessible: embedded fonts, tagged structure tree, XMP metadata, sRGB ICC color profile ## Choosing an endpoint makespdf exposes four data-to-PDF endpoints. Pick by **what you have**, not by what sounds most powerful. A wrong choice costs you 20 minutes of fighting the tool. ``` What do you have? │ ├── Markdown content, want a PDF with default styling? │ → POST /api/v1/md (sub-second, no AI) │ ├── Authoring / iterating on a template with inline DSL (free)? │ → POST /api/v1/preview { dsl, data } (sub-second, deterministic) │ Full control. Write the DSL yourself (this skill teaches you how). │ └── Have a saved templateId and want to render it with data (billed)? → POST /api/v1/render { templateId, data } (sub-second, deterministic) Production path once the DSL is stable. Same pipeline as /preview, different input source. Requires an API key or session cookie and ownership of the template. ``` **Rule of thumb:** `/md` for docs, `/preview` while you're still shaping the DSL, `/render` once the template is saved and you're producing billable PDFs from it. The rest of this document teaches the DSL used by both `/preview` and `/render`. **`/preview` is draft-only.** To stop callers from treating preview output as a final deliverable, every preview render (a) replaces string values in `data` with length-preserving filler — numbers, booleans, ISO-like dates, and numeric-looking strings pass through untouched, and hardcoded labels inside the DSL are always verbatim — and (b) bakes a diagonal "PREVIEW — NOT FOR USE" overlay into the content stream. Layout breakages still surface at realistic widths so authoring feedback stays useful, but the PDF is not usable as a production artifact. When you want real data in the output, save the template (`POST /api/v1/templates`) and call `/render` against it. **Deprecated:** the old `POST /api/v1/render { type, data }` contract (AI auto-generates a template) has been removed as part of the draft/ publish split. `/render` now exclusively accepts `{ templateId, data }` against a template you saved via `POST /api/v1/templates`. ### Start from a library template Before authoring DSL from scratch, check the built-in library — curated starting points for invoices, receipts, quotes, resumes, and more. Library rows live in the same `templates` table as your saves; they're just rows where `ownerUserId IS NULL`, world-readable to any authed caller. ``` GET /api/v1/templates?owner=library&category=invoice&q=classic → discover GET /api/v1/templates/invoice-classic → fetch { dsl, sampleData, ... } POST /api/v1/render { templateId: "invoice-classic", data } → billed render — no fork required ``` You can render a library `templateId` directly. If you want to customise the DSL (rename columns, swap branding), pull it, edit locally, then `POST /api/v1/templates` to create your own user-owned copy and render that. ## When to use this skill (vs. Markdown) makespdf exposes two independent paths from data to PDF. **Prefer the DSL path this skill teaches** — it's the full-featured surface. Anything the PDF engine can render (specific layouts, inline emphasis, links, tables with precise widths, clickable annotations, custom headers/footers, page numbering) is reachable from the DSL. Reach for Markdown only when it's a genuinely better fit for the input: - **Use this DSL (`POST /api/v1/preview`) when:** - The output needs any specific layout — invoices, receipts, quotes, reports, CVs, certificates, data tables with aligned columns. - You want mid-paragraph emphasis with precise control — e.g. a `text()` block mixing `s()`, `bold()`, `italic()`, `link()`, `mono()` spans; word-wrapping is preserved across all of them. - The document is prose-heavy but needs specific styling the default markdown renderer won't produce (custom margins, fonts per section, multi-column, coloured callouts, etc.). - You need template variables, loops over arrays, or conditionals — i.e. the PDF is driven by structured data. - **Use Markdown (`POST /api/v1/md`) when:** - The input is already Markdown (you're not authoring a layout). - The document is pure prose and the default markdown styling is fine. - You need GFM-specific features the DSL doesn't have atoms for — the main one is strikethrough (`~~text~~`); table HTML comment directives (``, ``, ``); GFM alerts (`> [!NOTE]`, `> [!WARNING]`, etc.); Mermaid diagrams in fenced code blocks; footnotes (`[^1]`). Everything else the DSL does more cleanly. A prose-heavy PDF with occasional emphasis is a perfectly reasonable DSL document — use `text(...)` blocks with inline shortcut spans. ### `/api/v1/preview` vs `/api/v1/render` Both endpoints run the same deterministic DSL → PDF pipeline (sub-second, no AI). They differ only in what the caller supplies and how billing works: - **`POST /api/v1/preview { dsl, data }`** — pass the DSL source inline. The authoring / draft endpoint. Free (no credits deducted). Use while iterating on a template. - **`POST /api/v1/render { templateId, data }`** — render a template you previously saved via `POST /api/v1/templates`. The production / publish endpoint. **Billed** (1 credit per 10 pages). Requires API-key or session auth; returns 404 for unknown or non-owned `templateId`. Typical flow for an agent: 1. Draft the DSL and iterate with `/preview` until it looks right. 2. `POST /api/v1/templates { dsl, name }` → `{ templateId }` (free). 3. Render N times with `POST /api/v1/render { templateId, data }` (billed). ## Output Format Output ONLY valid JavaScript. No markdown fences, no explanations. The script must define: - `const template = doc({...}, ...sections)` — the document template - `const sampleData = {...}` — example data matching the template's `{{variables}}` --- ## How to use this skill On first read, skim the whole document. For subsequent tasks, focus on the sections relevant to what you're doing: - **Every task:** §Builder DSL Reference, §Rules, §Validation Checklist, §Preview API, §Validation API. - **Deciding between DSL and Markdown:** §When to use this skill (vs. Markdown). - **No API token yet (or the call returned 401):** §Authentication. Run the device flow — never ask the user for a password or pre-existing API key. - **Building a new document from scratch:** add §Document Recipes and §Complete Example. - **Reproducing an existing document** from an image or PDF: add §Reproducing an existing document. You can skip §Complete Example; the reproducing workflow points back to §Document Recipes where needed. - **Debugging a template that renders wrong:** start with §Validation API, then revisit §Grid + Colspan and §Style Properties. - **Extending the style kit** with custom classes: §Style Properties + the `doc({ styles })` note at the end of it. The §Design Principles and §Rules sections are short and load-bearing — always honor them, regardless of task. --- ## Builder DSL Reference ### Document compositor `doc(opts, ...sections)` — Creates the document. Options: `{ size, title, author, styles, padding }` - `size`: `"A4"`, `"A3"`, `"A5"`, `"Letter"`, `"Legal"` (default A4) - `title`: Document title, can use `{{variables}}` - `styles`: Custom style classes to merge with the standard kit - `padding`: Page padding in points (default 30) Auto-wraps content in a page, separates header/footer to top-level. The **standard style kit** is included automatically: `.label`, `.body`, `.small`, `.heading`, `.section-heading`, `.table-header`, `.table-cell`, `.table-cell-alt`, `.total-row`, `.footer-bar` . #### Document-wide style changes — set the cascade root When the user asks for a change that should affect **every** element ("make the document font NotoSans size 10", "use a darker default text color"), edit the `"."` selector in `styles` — that's the document-wide cascade root that every element inherits from. Don't enumerate classes (`.body`, `.label`, `.heading`, etc.) one at a time — unclassed inline spans (plain `s("text")`, `bold(...)`, `italic(...)`) don't belong to any of those classes, so they'd keep the engine default and only half the rendered text would actually change. ```js // ✅ Document-wide change — applies to everything, including unclassed spans doc( { size: "A4", styles: { ".": { "font-family": "NotoSans", "font-size": 10 }, }, } /* ... */ ); // ❌ Class-by-class — misses inline `s("...")` and `bold(...)` spans doc( { styles: { ".body": { "font-family": "NotoSans", "font-size": 10 }, ".label": { "font-family": "NotoSans" }, ".table-cell": { "font-family": "NotoSans", "font-size": 10 }, // ...still misses every plain s("...") span in the tree }, } /* ... */ ); ``` Sized classes like `.heading` (22pt) and `.small` (8pt) keep their own sizes via cascade override — they'll pick up the new `font-family` from `.` but their explicit `font-size` still wins, which is what you want. ### Primitives | Function | Purpose | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | `page(styleOrKid?, ...kids)` | Page element | | `col(widthOrClassOrStyle?, ...kids)` | Column (vertical stack). First arg: `"50%"` = width, `".body"` = class, `{style}` = inline style | | `r(styleOrKid?, ...kids)` | Row (horizontal layout) | | `s(text)` or `s(".class", text)` or `s({style}, text)` | Span (text content) | | `text(...spans)` or `text(".class", ...spans)` | Inline text block — children flow on one line. Use for mixed styles: `text(bold("Date: "), s("15 March"))` | | `hdr(...kids)` | Header (repeated top of every page) | | `ftr(...kids)` | Footer (repeated bottom of every page) | | `img(src, w, h)` | Image (requires URL, width, height) | | `thisPage()` | Current page number tag | | `totalPages()` | Total page count tag | | `each(expr, ...kids)` | Loop: `each("item in items", r(...))`. Inline form: inside `text(each(...))` children flow on one line | | `when(expr, ...kids)` | Conditional: `when("discount > 0", ...)`. Supports negation: `when("!@last", s(", "))` | | `elseWhen(expr, ...kids)` | Else-if branch | | `otherwise(...kids)` | Else branch | `each()` produces either block children (rows, columns) or inline children (spans) depending on context. Inside `text(each(...))`, each iteration emits inline spans that flow on a wrapped line. Combine with `when("!@last", …)` for separator suppression: ```js text(each("a in authors", s("{{a.name}}"), when("!@last", s(", ")))); // → "Marie Curie, Alan Turing, Ada Lovelace" ``` ### Atoms Inline-text shortcuts (`bold`, `italic`, …) each return a single `span` Element. They compose inside `text(...)` blocks for mid-paragraph emphasis and word-wrapping is preserved across line breaks. | Function | Purpose | | ----------------------- | ----------------------------------------------------------------------------------------- | | `bold(text, size?)` | Bold span (optional font size) | | `italic(text, size?)` | Italic span | | `underline(text)` | Underlined span | | `mono(text)` | Monospace (Cousine) span — equivalent to inline code | | `colored(text, "#hex")` | Coloured span | | `link(href, text?)` | Clickable link span (underline + accent colour). Omitting `text` uses the URL as the text | | `muted(text)` | Small gray caption (8pt, #666) | | `hr(margin?, color?)` | Horizontal divider (default 8pt margin, #d1d5db) | | `gap(height?)` | Vertical spacing (default 8pt) | | `pageNum()` | Returns `["Page ", thisPage(), " of ", totalPages()]` | **Note on strikethrough:** There is no `strike()` atom — the layout engine's `text-decoration` property currently only supports `underline`. If you need strikethrough, use Markdown (`/api/v1/md`) which supports GFM `~~text~~`. ### Molecules **Universal rule for molecules and organisms:** every text-bearing param (`label`, `value`, `text`, `content`, `lines[]`, `heading`, `title`, `company`, and table cell contents) accepts **any** of: - a plain string — `"Hello"` - a single inline Element — `bold("Hello")`, `muted("caption")`, `link(url)` - an array mixing the two — `[bold("Date: "), "15 March 2026"]` You never need to drop to raw `s()` / `text()` just to get emphasis into a helper. Use `text(...)` only when you want an explicit inline-text block (e.g. as a `col()` child for a multi-span paragraph); do **not** wrap `text()` inside molecules that already take Elements — it nests a text block inside a span and produces surprising output. | Function | Purpose | | ------------------------------------ | --------------------------------------------------------------------------------------------- | | `lv(label, value, labelWidth?)` | Inline label-value row (default 35%/65%) | | `slv(label, value)` | Stacked label over value | | `addr(lines)` | Address block. `lines` is an array of strings or inline Elements (e.g. `bold("Premium Div")`) | | `addrR(lines)` | Right-aligned address block — same widening | | `th(label, width, align?)` | Table header cell | | `td(value, width, align?)` | Table data cell | | `totRow(label, value, bold?, grid?)` | Totals row. Without grid: 60%/25%/15%. With grid: uses colspan to align with table columns | | `totLine(text)` | Combined totals line (65% spacer + 35% right-aligned) | | `bullet(text)` | Bullet point | ### Organisms | Function | Purpose | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `minHdr(title, company)` | Minimal header (title left, company right). `title`/`company` accept strings or inline Element(s) | | `lvGrid(pairs[], labelWidth?)` | Label-value grid from `[label, value][]` pairs — label/value accept strings or inline Element(s) | | `addrs(from, to)` | Two-column addresses. Each: `{ label, lines[] }`. Labels and each line accept strings or inline Element(s) | | `table(cols, loopExpr, cells)` | Data table with header + loop. `cols`/`cells`: `[content, width?, align?][]` — width and align are optional. Omit width and `table()` picks `auto` for every column, then promotes the widest column to `1fr` so it absorbs surplus (overridable by passing any explicit width). Cell widths inherit from cols by index. | | `totals(rows, cols?)` | Totals section. `rows`: `[label, value, bold?][]`. Labels/values accept strings or Element(s). Pass cols for grid+colspan | | `ftrPages(company?)` | Footer with page numbers (and optional company name — string or Element(s)) | | `terms(heading, content)` | Terms/notes block. `heading`/`content` accept strings or inline Element(s) — ideal for prose paragraphs with emphasis | | `sigBlock()` | Signature lines (two side-by-side) | ### Fillable form fields (AcroForm) Use these when the PDF needs to be filled in by a human or another agent after rendering — consent forms, intake forms, surveys, KYC, gov-style applications. The widgets are real PDF/A-2A + PDF/UA-1 form fields with pre-baked appearance streams; they remain interactive in Adobe Reader, Apple Preview, and any modern browser viewer, and they participate in the tagged structure tree so screen readers can announce them. Pair every form template with `tagged: true` on the `doc(...)` so the widgets land inside the structure tree (otherwise they're still fillable but not accessible). Field `name` must be unique across the document. Working sample lives in the makesPDF repo at `assets/samples/forms/consent-form-dsl.js` (template) and `assets/samples/forms/consent-form-render.pdf` (the rendered output, also used as a fixture in the per-release veraPDF gate). | Function | Purpose | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `input(opts)` | Fillable text field (`/Tx`). `opts`: `{ name, width, height, value?, tooltip?, maxLength?, multiline? }`. Use `multiline: true` for paragraph-sized inputs. | | `checkbox(opts)` | Fillable checkbox (`/Btn`). `opts`: `{ name, size, checked?, tooltip?, label? }`. When `label` is provided, returns a row with the box + label text aligned to the right of it — the typical consent-form layout. | | `select(opts)` | Fillable dropdown (`/Ch` combo). `opts`: `{ name, width, height, options: [{ label, value }, …], value?, editable?, tooltip? }`. `value` is the initially-selected option's `value`. | Always supply `tooltip` — it's the field's accessible name (`/TU`), required for PDF/UA-1 conformance and what assistive tech announces. ```js // Minimal fillable consent block input({ name: "fullName", width: 400, height: 18, tooltip: "Full legal name", maxLength: 120 }), gap(6), select({ name: "contactMethod", width: 200, height: 20, options: [ { label: "Email", value: "email" }, { label: "Phone", value: "phone" }, { label: "Postal", value: "post" }, ], value: "email", tooltip: "How we should reach you", }), gap(6), checkbox({ name: "consentProcessing", size: 12, tooltip: "Consent to data processing (required)", label: "I consent to the processing of my personal data.", }), ``` `value` / `checked` set the **initial** state baked into the PDF; the viewer is free to overwrite it. To pre-fill from `data`, just thread `{{variables}}` through `value` like any other string. Out of scope: `/Sig` digital-signature fields, Acrobat-style JavaScript calculations, and radio-button groups (use a `select` instead, or model mutually-exclusive choices as a single checkbox per option). --- ## Raw-drawing escape hatch (`vector()`) **Use this sparingly.** For prose, tables, lists, or stacked content, reach for the semantic tags (`text`, `bullet`, `dataTable`, `table`, …) — they produce properly tagged PDF/UA output. `vector()` is for bespoke shapes the semantic tags can't express: custom badges, signature boxes, decorative separators, stamped marks, small diagrams. `vector(opts, ...shapes)` renders declarative SVG-style shapes as native PDF vector graphics (no rasterisation). It returns an `img`-typed element with a baked-in data URI; the layout renderer routes it through the same SVG → PDF pipeline used by markdown SVG images and Mermaid diagrams. ```javascript vector( { width: 120, height: 40, alt: "Approved stamp" }, rect({ x: 0, y: 0, w: 120, h: 40, rx: 4, fill: "#fee2e2", stroke: "#b91c1c", "stroke-width": 1.5, }), svgText( { x: 60, y: 26, "font-size": 14, "font-weight": "bold", fill: "#b91c1c", "text-anchor": "middle", }, "APPROVED" ) ); ``` ### Shape commands | Function | SVG equivalent | Common attrs | | -------------------------- | -------------------------------------- | ------------------------------------------------------- | | `rect({x,y,w,h,...})` | `` (`w`/`h` map to width/height) | `rx`, `ry`, `fill`, `stroke`, `stroke-width`, `opacity` | | `circle({cx,cy,r,...})` | `` | `fill`, `stroke`, `stroke-width`, `opacity` | | `ellipse({cx,cy,rx,ry})` | `` | same styling attrs | | `line({x1,y1,x2,y2,...})` | `` | `stroke`, `stroke-width`, `stroke-dasharray` | | `polyline({points,...})` | `` | `points: "0,0 10,10 20,0"` | | `polygon({points,...})` | `` | `points: "0,0 10,10 20,0"` | | `path({d,...})` | `` | full SVG path grammar: `M L C S Q T A Z H V` | | `svgText({x,y,...}, body)` | `` | `font-size`, `font-weight`, `fill`, `text-anchor` | All coordinates are points in the drawing area's own coordinate system (top-left origin, viewBox defaults to `0 0 width height`). Pass an explicit `viewBox` option to override. ### Gradients Linear and radial gradients are supported via `defs(...)` + `linearGradient`/`radialGradient` + `stop`, referenced from `fill` or `stroke` using `url(#id)`: ```javascript vector( { width: 200, height: 60, alt: "Gradient banner" }, defs( linearGradient( { id: "bg", x1: 0, y1: 0, x2: 1, y2: 0 }, stop({ offset: 0, "stop-color": "#0ea5e9" }), stop({ offset: 1, "stop-color": "#6366f1" }) ) ), rect({ x: 0, y: 0, w: 200, h: 60, fill: "url(#bg)" }) ); ``` - `gradientUnits` defaults to `objectBoundingBox` (coords are 0–1 fractions of the shape's bbox); pass `gradientUnits: "userSpaceOnUse"` for absolute coords. - `gradientTransform` is supported and composes with the shape's CTM. - `spreadMethod: "pad"` only — `reflect` / `repeat` render as `pad` with a warning. - Per-stop `stop-opacity` below 1 is not honoured yet (stops render opaque). ### What it can't do - `spreadMethod: reflect` / `repeat` (tiled pattern support TBD). - No filters, masks, or `` body content (fallback text only). - No interactive elements or scripting. ### Acceptance checklist - Always pass `alt` on `vector()` — tagged PDF emits `/Figure` with that `/Alt`. Omitting it produces an empty string alt, which is valid only for purely decorative marks. - Stick to explicit numeric coordinates; `{{variable}}` substitution does not run inside `vector()` arguments (they execute during DSL evaluation, before template substitution). --- ## Barcodes (`barcode()`) `barcode(value, options)` renders a Code 39 or Code 128 barcode as native PDF vector graphics — no font, no rasterisation, scannable at any zoom. Returns an `img`-typed element backed by a `barcode:` URL that the renderer expands at output time, so the value can be a `{{template}}` placeholder that resolves per render. ```js barcode("{{trackingNumber}}", { format: "code128", // "code128" (default) or "code39" width: 240, // total image width in pt (required-ish, default 200) height: 56, // bar height in pt (default 50) "show-text": true, // human-readable line below bars (default false) "quiet-zone": 10, // module-widths of empty space on each side (default 10) "text-font-size": 8, // pt (default 8) alt: "Tracking {{trackingNumber}}", }); ``` **Pick a format:** - `code128` — denser, supports full ASCII 32–127. Default. Use for shipping labels, asset tracking, generic identifiers. - `code39` — older, ubiquitous in legacy systems. Accepts uppercase A–Z, digits, space, and `- . $ / + %` only. No checksum. **Sizing for scannability:** module width is derived as `(width − 2·quietZone·moduleWidth) / totalModules`. Aim for a module width ≥ 0.5pt (≈ 0.18mm) so consumer scanners and phone cameras can read the bars. Wider is more reliable; narrower saves space at the cost of scan robustness. The default `quiet-zone: 10` modules of empty space on each side is required by the Code 128 spec — don't lower it without a reason. **Dynamic values:** the value field accepts `{{template}}` placeholders and resolves at render time. The literal characters `&` and `=` will corrupt the barcode URL (used internally), so values must not contain those — realistic barcode payloads (tracking numbers, SKUs, order IDs) never do. **Accessibility:** always pass `alt`. The renderer emits a `/Figure` with that `/Alt` for tagged PDF / PDF-UA compliance. If you omit it, the encoded value is used as fallback alt text — fine for human-readable identifiers, less helpful for opaque codes. --- ## QR Codes (`qr()`) `qr(value, options)` renders a QR code as native PDF vector graphics — no font, no rasterisation, scannable at any zoom. Returns an `img`-typed element backed by a `qr:` URL that the renderer expands at output time, so the value can be a `{{template}}` placeholder that resolves per render. The encoder auto-selects the smallest QR version (21×21 up to 177×177 modules) and the lowest-penalty mask, and picks the most efficient encoding mode (numeric, alphanumeric, or byte/UTF-8) for the payload. ```js qr("https://pay.example.com/inv/{{invoice.number}}", { ecc: "M", // "L" | "M" (default) | "Q" | "H" size: 120, // total width/height in pt (square; default 120) "quiet-zone": 4, // module-widths of empty space on each side (default 4, spec minimum) alt: "Scan to pay invoice {{invoice.number}}", }); ``` **Pick an error-correction level:** higher levels recover more damage at the cost of a larger symbol for the same payload. - `L` ≈ 7% recovery — densest, smallest. Use for clean-print URLs on screen or pristine paper. - `M` ≈ 15% recovery — default, the everyday choice. - `Q` ≈ 25% recovery — receipt paper, dim lighting, expected smudging. - `H` ≈ 30% recovery — thermal labels, scratched surfaces, anywhere reliability matters more than density. **Sizing for scannability:** module width is derived as `size / (modules + 2·quietZone)`. Aim for a module width ≥ 1pt (≈ 0.35mm) so phone cameras can resolve modules at a reasonable distance. A 96–120pt box covers most URL-length payloads at ecc M; bump to 150pt+ for long payloads or H-level ecc. The default `quiet-zone: 4` modules is the spec minimum — don't lower it. **Dynamic values:** the value accepts `{{template}}` placeholders and resolves at render time. The literal characters `&` and `=` will corrupt the QR URL (used internally), so percent-encode them before substitution if your payload is a URL with a query string (most "scan to pay" / tracking deep links don't have query strings, so this rarely bites). **Accessibility:** always pass `alt`. The renderer emits a `/Figure` with that `/Alt` for tagged PDF / PDF-UA compliance. If you omit it, the encoded value is used as fallback alt text — fine for short URLs, less helpful for opaque payloads. --- ## Grid + Colspan (Table-Aligned Totals) When a `table()` and `totals()` share the same `cols`, totals rows automatically align with the data table columns using grid+colspan. Here's how it works under the hood: ### How `table()` sets up the grid `table(cols, loopExpr, cells)` extracts a grid array from `cols` and sets `attr.grid` on every row: ```javascript const cols = [ ["Description", "1fr"], ["Qty", "auto", "center"], ["Price", "auto", "right"], ["Amount", "auto", "right"], ]; // → grid = ["1fr", "auto", "auto", "auto"] // Each header and data row gets attr.grid = ["1fr", "auto", "auto", "auto"] ``` ### How `totals()` aligns with the grid `totals(rows, cols?)` passes the grid to each `totRow()`. With a grid, the totals row has just two cells: a label that spans every column except the last, and the value cell in the last column. ```javascript totals( [ ["Subtotal", "$500"], ["Total", "$600", true], ], cols ); // Each totals row becomes: // attr.grid = ["1fr", "auto", "auto", "auto"] ← same grid as the table // attr.grid-id = "gid-…" ← matches the table's id // child 0: label → colspan: 3 (right-aligned, spans Description + Qty + Price) // child 1: value → colspan: 1 (right-aligned, Amount column — auto-sized via grid-id) ``` Two things make the value column align with the table's Amount column: 1. **Same `attr.grid`.** Both rows declare the same column tokens, so a fixed/percent token in the last slot resolves identically. 2. **Same `attr.grid-id`.** A deterministic id derived from the `cols` array. The engine groups every row sharing it and resolves their `auto`/`fr` columns _together_, so the value column lines up even when it's `"auto"` (e.g. a Balance column sized to its data). `table()` and `totals(rows, cols)` set `grid-id` for you. You only need to think about it when hand-writing rows that should join an existing table's group — set `attr["grid-id"]` to the same string the builders generated, or stamp your own consistent id across all rows of the table. ### Manual grid+colspan For custom layouts beyond `table()`/`totals()`, you can use grid+colspan directly: ```javascript // A row where the first cell spans 2 of 4 grid columns r( { grid: ["25%", "25%", "25%", "25%"] }, col({ colspan: 2 }, s("Wide cell")), // spans 50% col({ colspan: 1 }, s("Normal cell")), // spans 25% col({ colspan: 1 }, s("Normal cell")) // spans 25% ); ``` **Rules:** - `grid` is set on `r()` (the row) as an array of width strings - `colspan` is set on `col()` children (default 1) - Children don't need explicit `width` — the grid determines their width - The sum of all colspan values should equal the number of grid entries **Grid token forms:** numbers / `"pt"` (fixed points), `"%"` (percent of row), `"auto"` (sizes to the widest content in that column), `"auto-stretch"` (like `auto`, but the widest such column in the row claims any surplus space — what `table()` writes for omitted widths), and `"fr"` (shares the remaining surplus proportionally — `"1fr"` and `"2fr"` split 1:2). Mix freely: `["120pt", "auto", "1fr"]` pins the first column, sizes the second to its content, and lets the third take whatever's left. > **Rule for line-item tables: omit widths and let `table()` pick `auto` for every column, with the widest column auto-promoted to absorb surplus.** Equivalently, write `["1fr", "auto", "auto", "auto"]` by hand. Never use `"%"` widths on Qty / Unit Price / Rate / Amount / SKU / Date. > > The canonical 4-column invoice — Description + three numeric columns — can be written as `[["Description"], ["Qty", undefined, "right"], ["Unit Price", undefined, "right"], ["Amount", undefined, "right"]]`. Same pattern for receipts, quotes, statements, time-tracking tables, anything with a description + numbers shape. Why this matters: sizing a numeric column with a fixed percent (e.g. `"10%"` for Unit Price, `"15%"` for Total) is the most common authoring trap. If any data row's value is wider than the slot, the cell wraps onto two lines with the visible text sitting at the bottom of the row, breaking the column's vertical alignment with its neighbours. The grid coordinator resolves `auto`/`fr`/`auto-stretch` widths consistently across the header, every data row, and the totals strip, so there is no upside to defensive percentages and a real downside when data outgrows them. Percents belong in two-up summary blocks (`["55%", "45%"]`) and other true layout splits — not in line-item tables. --- ## Template Variables - **Simple:** `"{{fieldName}}"` — resolves from data - **Nested:** `"{{customer.name}}"` — dot notation - **Loop:** `each("item in items", ...)` — iterates arrays - **Loop context:** `{{@index}}` (0-based), `{{@first}}`, `{{@last}}`. Negate with `!@first` / `!@last` (works inside `when("…")` expressions too). - **Conditional:** `when("discount > 0", ...)` - **Expressions:** `"{{qty * price}}"`, `"{{status == 'paid' ? 'Yes' : 'No'}}"` ### Template Filters Pipe a value through a filter to format it using the `|` syntax inside `{{…}}`: | Filter | Input | Output | Notes | | ---------------------------------- | ------ | ------------ | ----------------------------------------------------------------------------------------- | | `currency` | number | `$1,234.50` | Defaults to USD. Set a doc-wide default with `doc({ currency: "AUD" }, …)`. | | `currency:` | number | `A$1,234.50` | Per-call override. Accepts any ISO 4217 code (AUD, GBP, EUR, JPY, NZD, CAD, CHF, INR, …). | | `currency::` | number | `1.234,50 €` | Optional locale override (e.g. `de-DE`, `fr-FR`). Default locale is `en-US`. | | `number` | number | `1,234` | Intl.NumberFormat en-US, thousands separator. | Examples: - `"{{amount | currency}}"` → `"$1,234.50"` (USD default) - `"{{amount | currency:AUD}}"` → `"A$1,234.50"` (AUD with `A$` disambiguation) - `"{{amount | currency:GBP}}"` → `"£1,234.50"` - `"{{amount | currency:EUR}}"` → `"€1,234.50"` - `"{{amount | currency:JPY}}"` → `"¥1,234"` (JPY has no fraction digits) - `"{{amount | currency:EUR:de-DE}}"` → `"1.234,50 €"` (German locale) - `"Total: {{total | currency}}"` → `"Total: $15,114.00"` - `"{{item.qty | number}}"` → `"1,234"` **Doc-wide default.** Pass `currency` in the `doc({ … })` options to set the default for bare `{{ x | currency }}` calls — useful for whole-document locales (e.g. an Australian invoice). Per-call overrides still win. ```js doc( { size: "A4", currency: "AUD" }, page( s("Total: {{total | currency}}"), // → "Total: A$1,234.50" s("USD equivalent: {{usd | currency:USD}}") // → "USD equivalent: $800.00" ) ); ``` Unknown ISO codes (`currency:XYZ`) emit an `unknown-currency-code` warning and fall back to USD. **Method calls are not supported.** Expressions like `{{amount.toFixed(2)}}` evaluate to `undefined` and render as the literal string `undefined`. Use `| currency` / `| number` instead, or pre-format the value in the data payload. **Dates are auto-formatted.** ISO date strings (`"2026-04-10"`) in fields with date-like names (`date`, `dueDate`, `issued`, `expires`, etc.) are automatically formatted to human-readable English (`"10 April 2026"`) before rendering. `YYYY-MM` becomes `"April 2026"`. Non-ISO strings pass through unchanged. You do not need to pre-format dates — send ISO strings and the engine handles it. ## Style Properties > ⚠️ **Style property names are kebab-case, not camelCase.** Even though the > DSL is JavaScript-like, style property names follow CSS conventions: > `"font-size"`, `"font-weight"`, `"line-height"`, `"background-color"`, > `"border-color"`, `"border-radius"`, `"text-decoration"`. Using camelCase > (`fontSize`, `fontWeight`, `backgroundColor`) produces > `unknown-style-property` warnings and the styles **will not apply** — the > element renders with the default instead. Always quote kebab-case keys: > `{ "font-size": 12 }`, not `{ fontSize: 12 }`. Available in inline style objects (e.g. `col({ "font-size": 12, width: "50%" }, ...)` — kebab-case, not `fontSize`): | Property | Values | | -------------------- | ------------------------------------------------------------------------------------------------- | | `font-family` | `"Inter"`, `"NotoSans"`, `"Cousine"` — default: "Inter"; inherits | | `font-size` | number (pt), 4-72 — typical: 8-20. Body: 10. Headings: 14-20. Captions: 8.; default: 10; inherits | | `font-weight` | `"normal"`, `"semibold"`, `"bold"` — default: "normal"; inherits | | `font-style` | `"normal"`, `"italic"` — default: "normal"; inherits | | `color` | hex string — inherits | | `line-height` | number, 0.5-3 — typical: 1.0-1.6 | | `letter-spacing` | number (pt) — typical: 0-3. Small-caps labels: 1-2.; default: 0; inherits | | `text-transform` | `"none"`, `"uppercase"`, `"lowercase"`, `"capitalize"` — default: "none"; inherits | | `text-decoration` | `"underline"` | | `width` | number (pts), `"50%"`, or `"stretch"` | | `max-width` | number (pts), `"50%"`, or `"stretch"` | | `height` | number (pts), `"50%"`, or `"stretch"` | | `margin` | `[t, r, b, l]` or single number — typical: 0-30. Section spacing: 5-10. | | `padding` | `[t, r, b, l]` or single number — typical: 0-20 | | `align` | `"left"`, `"center"`, `"right"` — inherits | | `valign` | `"top"`, `"center"`, `"bottom"` | | `background-color` | hex string | | `border` | `[t, r, b, l]` or single number | | `border-top` | number (pt) | | `border-right` | number (pt) | | `border-bottom` | number (pt) | | `border-left` | number (pt) | | `border-color` | hex string | | `border-radius` | number (pt) | | `column-span` | `"all"` | | `orphans` | number — typical: 1-3; default: 1 | | `widows` | number — typical: 1-3; default: 1 | | `min-presence-ahead` | number (pt) — typical: 30-80. Headings: 40-60.; default: 0 | | `opacity` | number, 0-1 | | `position` | `"relative"`, `"absolute"` — default: "relative" | | `top` | number (pt) | | `left` | number (pt) | | `right` | number (pt) | | `bottom` | number (pt) | | `z-index` | number — default: 0 | | `transform` | { rotate?: number (degrees), translate?: [x, y] } — applied around the element centre | Units are points (1pt = 1/72 inch). A4 = 595 x 842pt. Custom styles can extend the standard kit via `doc({ styles: { ".custom": { "font-size": 11 } } }, ...)`. --- ## Document Recipes > **Per-doc-type assembly skeletons live in > [`/skills/pdf-recipes.md`](https://makespdf.com/skills/pdf-recipes.md).** > Fetch that file when you need to author a specific document type — > Invoice, Receipt, Quote, Statement, Letter, or CV. The recipes are > self-contained and use only DSL covered in this main skill. Below: a > short stub kept inline for the most common case (Invoice) so a quick > `head -N` of this file shows _something_ recognisable; everything else > is in the companion. ### Invoice (quick reference) The Invoice recipe is the canonical example. Full version with field names, totals shape, and footer in the recipes companion. ``` minHdr("Invoice", "{{company.name}}") lvGrid([["Invoice #:", "{{number}}"], ["Date:", "{{date}}"], ["Due:", "{{dueDate}}"]]) addrs({ label: "From", lines: [...] }, { label: "Bill to", lines: [...] }) const cols = [["Description", "1fr"], ["Qty", "auto", "center"], ["Price", "auto", "right"], ["Amount", "auto", "right"]]; table(cols, "item in items", [cells...]) totals([["Subtotal", "..."], ["Tax", "..."], ["Total", "...", true]], cols) ftrPages("{{company.name}}") ``` For receipts, quotes, statements, letters, and CVs: fetch `/skills/pdf-recipes.md`. ### Common table column distributions For line-item tables (description + numeric columns), default to one stretch + auto everywhere else: - 3-col: `["1fr", "auto", "auto"]` - 4-col: `["1fr", "auto", "auto", "auto"]` ← canonical invoice grid - 5-col: `["1fr", "auto", "auto", "auto", "auto"]` Percent grids (`["50%", "25%", "25%"]`) are only appropriate when every column is a layout panel rather than a data column — e.g. a two-up address block, a three-up KPI strip. The static checks treat `"%"` widths on numeric headers (Qty, Price, Amount, …) as errors, and a stretch header (Description, Item, …) without a `1fr`/`auto` companion as a warning. ### Dense tables (6+ columns) A4 portrait has ~535pt of usable row width. With 6+ columns at the default body size (9–10pt), cells start wrapping mid-word and headers look cramped. Options, in order of preference: 1. **Drop the table font size to 8pt** via a custom class. Extend the kit: `doc({ styles: { ".t-dense": { extend: ".table-cell", "font-size": 8 }, ".t-dense-h": { extend: ".table-header", "font-size": 8 } } }, ...)`, then wrap narrow-column values with a span using that class, or pass `{ "font-size": 8 }` inline to the cell content. 2. **Switch to landscape** — `doc({ size: "A4", ... })` has no landscape flag; use `size: "A3"` portrait (~760pt) or reduce page `padding` to 20 to reclaim ~20pt per side. 3. **Merge or drop columns** — e.g. combine "Unit Price" and "Unit" into a single cell ("$750.00 / PCS"); move HS Code to a secondary row below the description via `text(bold("HS "), muted("950630"))`. When you do use percent widths (layout panels, not line-item tables), the `cols` array sum should be exactly `100%`. Mis-summed grids render but leave gaps or overflow — the validator flags this as `grid-sum-mismatch`. Grids built from `auto` / `1fr` don't need to sum to anything. Dense tables on narrow portrait pages are flagged as `dense-table-hint`, and obviously-too-long text in a constrained cell as `likely-wrap`. The wrap check is heuristic (estimates glyph width as ~0.5 × em) — bold / condensed / non-Latin text may over- or under-trigger, so treat it as a pointer, not a verdict. ### Headers with long company names `minHdr(title, company)` puts the title left (60%) and the company right (40%, 16pt bold). Long company names (e.g. "SurfPro Manufacturing Inc.") will wrap inside the 40% column and push the header taller than intended. Two fixes: - **Stack title above company:** skip `minHdr()` and write the two lines as separate rows — `r(col(s({ class: "heading" }, "COMMERCIAL INVOICE")))` then `r(col({ align: "right" }, s({ "font-size": 14, "font-weight": "bold" }, "{{company.name}}")))`. - **Shrink the company type ramp:** pass an inline Element instead of a string — `minHdr("INVOICE", s({ "font-size": 12, "font-weight": "bold" }, "{{company.name}}"))`. ### Bookmarks (document outline) Any element (page, column, row, text, span, img) accepts a `bookmark` attr — either a plain title string or `{ title, expanded? }`. Bookmarks become entries in the viewer's "Bookmarks" panel, and nesting is inferred from the element tree: a bookmark on a child becomes a child of the bookmark on its nearest bookmark-bearing ancestor. ```js doc( { size: "A4" }, page({ bookmark: "Cover" }, col(s({ class: "heading" }, "Annual Report"))), page( { bookmark: { title: "Financials", expanded: true } }, col({ bookmark: "Income statement" } /* ... */), col({ bookmark: "Balance sheet" } /* ... */) ) ); ``` Adding even one bookmark sets `/PageMode /UseOutlines` on the catalog so Acrobat and Preview open the outline panel by default. Keep titles terse — viewers truncate long labels. ### Internal `#anchor` links Any element accepts an `id` attr that becomes an internal destination. Pass `#id` as the href to `link()` to jump to it — useful for tables-of-contents, "see Appendix A" cross-references, and footnote-style back-links. ```js doc( { size: "A4" }, page(col(s({ class: "heading" }, "Contents")), col(link("#summary", "Jump to summary"))), page(col({ id: "summary" }, s({ class: "heading" }, "Summary")), col(s("…body copy…"))) ); ``` External URLs (`link("https://…", "…")`) keep working exactly as before — only hrefs starting with `#` resolve to internal destinations. Unknown ids fall through as external URIs so broken references are visible, not silent. ### Stamps & watermarks (absolute positioning) `position: "absolute"` lifts an element out of flow and places it in page coordinates via `top` / `left` / `right` / `bottom`. Use for stamps, seals, badges, and watermarks. Absolute elements don't inflate parent heights and don't push siblings around. Supported style keys: `position`, `top`, `left`, `right`, `bottom`, `width`, `z-index` (paint order — higher paints on top), `opacity` (0–1), `transform: { rotate: deg, translate: [dx, dy] }` (rotation pivots around the element centre), `page-repeat: true` (clone onto every page for watermarks). Absolutes must live inside a `` ancestor (or at doc-root for `page-repeat` watermarks). They are rendered as PDF `/Artifact` content so they don't pollute the tagged-PDF structure tree. ```js // Diagonal PAID stamp on the first page, top-right corner text( { style: { position: "absolute", top: 80, right: 40, width: 160, opacity: 0.35, transform: { rotate: -18 }, "font-size": 36, "font-weight": "bold", color: "#b00020", border: 3, "border-color": "#b00020", padding: [6, 12], align: "center", }, }, s("PAID") ); // Full-page DRAFT watermark repeated on every page text( { style: { position: "absolute", top: 360, left: 100, width: 400, opacity: 0.12, transform: { rotate: -30 }, "font-size": 120, "font-weight": "bold", color: "#000000", align: "center", "page-repeat": true, }, }, s("DRAFT") ); ``` Not supported in V1: percentage coordinates, `transform-origin`, `scale` / `skew`. Use explicit `width` when you need the stamp to be narrower than the page. ### Footer vertical space `ftrPages()` renders a single row with a top border and 5pt top/bottom padding. The blank strip below it is the page's bottom padding (default 30pt). If the footer feels airy, set `doc({ padding: 20 }, ...)` — that trims 10pt from all four edges without affecting the footer bar itself. --- ## Design Principles - **Visual hierarchy:** Title (18-24pt bold) > Section headings (12pt bold) > Body (10pt) > Captions (8pt gray) - **Color palette:** Maximum 2-3 colors. Dark accent (e.g. #1e3a5f) + white + one highlight. - **Whitespace:** 30-40pt page padding. 5-10pt between major sections. 1-2pt between related items (address lines, label-value rows). - **Alignment:** Right-align all numbers and monetary values. Left-align all text. When the original shows a company/sender address in the top-right, right-align that entire block by putting "align": "right" on the PARENT COLUMN (not on spans or classes). Right-alignment must always be set on the column container -- it is inherited by children. - **Vertical space budget:** A4 = 842pt. After page padding (30pt x 2) and footer (~25pt), you have ~757pt of usable content height. BEFORE finalizing, estimate total height: count sections, multiply by average height (~60-80pt for text sections, ~25pt per table row), add all inter-section margins. If the estimate exceeds 757pt, reduce section margins to 5-8pt and use smaller font sizes (body 9pt, table cells 8pt). - **Page efficiency:** Keep spacing TIGHT. A single-page original must render as single-page. Err on the side of too little spacing rather than too much. For documents with 7+ top-level sections, use 5-8pt inter-section margins (NOT 10-15pt). Every point counts. - **Logos:** Reference an uploaded asset via `img({ src: "asset:", alt: " logo" })`. Users upload logos / signatures / stamps once at `/settings/assets` and reference them by stable opaque id. SVG and PNG/JPEG all work; bytes are owner-scoped (someone else's `asset:` resolves to a placeholder). Set explicit `width` (80–120pt for invoice headers) so the image scales predictably. If no asset is available, fall back to a text placeholder at 14–16pt max — the logo is a small brand mark, not a giant heading. Avoid raw `https://` image URLs: they break when the host goes down and add latency to every render. - **Totals alignment:** When the original shows totals labels and values in separate columns aligned with the table, ALWAYS use M6 (row with empty + label + value columns). Only use M7 (combined) when the original clearly shows "Label: $value" as a single right-aligned text block. ## Rules 1. **Variables MUST match data structure exactly.** If data has `customer.name`, use `{{customer.name}}`. 2. **Arrays MUST use `each()` loops.** Never hardcode array items. 3. **Use EVERY field** from the provided data. Don't omit fields. 4. **Row children need explicit widths** summing to ~100%. 5. **Text content in `s()` spans.** 6. **Always include `ftrPages()`** for page numbers. 7. **Use `table()` for data tables** — it handles header + loop automatically. 8. **Pass `cols` to `totals()`** so totals rows align with the table via grid+colspan. Exception: when labels are long (>8 chars — "Interest Earned", "Closing Balance") and the N-2 column is narrow (<20%), omit `cols` so the label gets the default 25% width instead of wrapping. 9. **Only valid tags.** No HTML tags (`div`, `table`, `tr`, `td`, `p`, `h1`, etc.). 10. **No images unless data contains image URLs.** 11. **Verify the rendered PDF, not just the validator.** See §Post-render verification. ## DSL Traps Concrete shapes the builder accepts that tripped up earlier versions — most are now fixed, but the safe spelling is still worth knowing. - **`s()` 3-arg form works:** `s(".label", { "font-size": 10 }, "Hello")` merges the style into the span's `attr`. When in doubt, prefer the inline form `s({ class: "label", "font-size": 10 }, "Hello")` — both are equivalent. Never write `s("italic", "…")` / `s("bold", "…")` / `s("mono", "…")` / `s("link", "…")` — those throw. Use the helpers `italic(...)` / `bold(...)` / `mono(...)` / `link(...)`, or the class form `s(".italic", "…")`. - **`col(width, attr, ...kids)` / `r(width, attr, ...kids)` work:** `col("40%", { align: "right" }, s("x"))` is valid. `col({ width: "40%", align: "right" }, s("x"))` is also valid and reads more clearly — pick the form that matches the rest of the file. - **Class shorthand `".foo"` only works as the FIRST arg.** `s(".foo", …)` is fine; `col(".foo", …)` is fine. But `col("50%", ".foo", …)` does NOT apply the class — `.foo` falls through to kids and renders as literal text. The validator rule `class-string-as-child` now catches this. Use `col("50%", { class: "foo" }, …)` or `col({ width: "50%", class: "foo" }, …)` instead. - **Border shorthand:** prefer the string form `border: "1px solid #000"` or the tuple form `border: [1, 1, 1, 1]` + separate `"border-color": "#000"`. **Do not** write `border: [1, "solid", "#000"]` — that mixes widths and colours and renders silently wrong. - **Percent grids must sum to 100%.** Only relevant if you're using percents — line-item tables should use `["1fr", "auto", "auto", …]` instead, which never needs to sum. When you do use percents (layout panels), mismatches become `grid-sum-mismatch` warnings; e.g. a 5-column percent grid should be `["40%", "20%", "10%", "10%", "20%"]`, not `["40%", "8%", "10%", "16%", "16%"]`. - **Preserve input data types.** If the input `sampleData.items[0].qty` is a number, keep it a number in the generated `sampleData`. Stringifying numerics (e.g. `qty: "2"` instead of `qty: 2`) breaks `| currency` / `| number` filters and arithmetic in expressions. - **No ES default parameters in arrows.** The DSL executor does not support default-parameter syntax — `(s, n = 8) => s.padStart(n)` throws `Cannot evaluate AssignmentPattern`. Inline the constant (`(s) => s.padStart(8)`) or hoist a separate helper. Rest params are fine; destructured defaults are not. - **`transform` is an object, not a CSS string.** Write `transform: { rotate: 45 }` or `transform: { rotate: -18, translate: [0, 20] }` — **not** `transform: "rotate(45deg)"`. The string form is silently ignored (rotation falls back to 0°). See §Stamps & watermarks for the full spec. Also: `transform` only applies to `position: "absolute"` elements; setting it on an in-flow row/column does nothing. - **`pageNum()` returns an array, not a single node.** It expands to `["Page ", thisPage(), " of ", totalPages()]` — four kids. Wrapping it in `s({...}, pageNum())` makes the span's `kids[0]` an array and fails the catalog validator with `"span" has an array in kids[0]`. Always use `ftrPages("…footer text…")` (the helper spreads the kids correctly) or, if you genuinely need a custom footer, spread: `s({...}, ...pageNum())`. ## Common Errors & Fixes - **`object is not iterable`** — you passed a bare Element where a tuple was expected, usually in `table()` cells. Each cell must be `[content, width, align?]`, not just `content`. Content itself can be a string, an Element, or an array of either. - **A `text(...)` block renders as one tall line or shows no emphasis** — you nested `text()` inside a molecule that already accepts inline Elements (e.g. `td(text(bold("X")), "20%")`). Drop the `text()` wrapper and pass the span(s) directly: `td(bold("X"), "20%")` or `td([bold("Qty: "), "25"], "20%")`. - **Row children overflow or leave a gap** — `grid` / cell widths don't sum to 100%. Recalculate; the validator flags this as `grid-sum-mismatch` when percent widths fall outside 98–102%. - **A numeric column looks left-aligned when you asked for right** — `align` must be on the `col()` (or the cell tuple's 3rd slot), not on the inner `span`. Column `align` is inherited by children. - **Header wraps onto two lines** — see "Headers with long company names" above. `minHdr()` gives the company 40% of the row; long names need a stacked layout or a smaller font size. - **Table looks cramped at 9 columns** — see "Dense tables (6+ columns)". Drop to 8pt or reduce column count before tweaking widths. - **Monetary values render raw (e.g. `$13740` instead of `$13,740.00`)** — use the `currency` filter: `{{amount | currency}}`. See §Template Filters. - **A cell renders the literal string `undefined`** — an expression evaluated to `undefined`. Either the variable path is wrong, or you tried an unsupported method call like `{{amount.toFixed(2)}}`. Method calls are not supported — use `| currency` / `| number`, or pre-format in the data. ## Validation Checklist Before outputting, verify: 1. Valid JavaScript? (no syntax errors) 2. Every `{{variable}}` references a field in sampleData? 3. All arrays iterated with `each()`? 4. Row children have explicit widths summing to ~100%? 5. Font sizes between 7-24pt? 6. Colors are valid hex strings? 7. Page fit: estimated total height < 757pt for A4? 8. `const template = doc(...)` and `const sampleData = {...}` both defined? ## Post-render verification The checklist above and `/api/v1/preview/validate` are both **pre-render** — they can't tell you that a `{{variable}}` evaluated to `undefined`, that a loop produced zero rows, or that a layout budget pushed content off the page. After calling `/api/v1/preview`, verify the PDF _content_ before handing it back. 1. **Extract the text.** `pdftotext -layout output.pdf -` (or `mutool draw -F text`, or `qpdf --qdf` + grep for `Tj`). Any tool that reads the actual rendered text — not the DSL — will do. 2. **Look for these red flags in the extracted text:** - The literal strings `undefined`, `NaN`, `null`, `[object Object]` anywhere in the output. - Loop-driven sections with fewer rows than your input array (you sent 14 transactions, the table shows 0 or 3). - Monetary values that look unformatted (`$13740` where you wanted `$13,740.00`) — see §Template Filters. - Blank cells in positions that should be populated. 3. **Diff input vs. output.** Every meaningful value you sent should appear somewhere in the extracted text, allowing for formatting differences (`6450` → `$6,450.00`). If any check trips, fix the template and re-render. **Do not report success until the rendered PDF passes this check.** Catalog validation catches shape; only the rendered PDF catches content. --- ## Complete Example ```javascript const cols = [ ["Description", "1fr"], ["Qty", "auto", "center"], ["Price", "auto", "right"], ["Amount", "auto", "right"], ]; const template = doc( { size: "A4", title: "Invoice {{invoiceNumber}}" }, minHdr("Invoice", "{{company.name}}"), lvGrid([ ["Invoice #:", "{{invoiceNumber}}"], ["Date:", "{{date}}"], ["Due:", "{{dueDate}}"], ]), gap(8), addrs( { label: "From", lines: ["{{company.name}}", "{{company.address}}", "{{company.city}}"] }, { label: "Bill to", lines: ["{{customer.name}}", "{{customer.address}}", "{{customer.city}}"] } ), gap(8), table(cols, "item in items", [ ["{{item.description}}", "1fr"], ["{{item.qty}}", "auto", "center"], ["{{item.price | currency}}", "auto", "right"], ["{{item.amount | currency}}", "auto", "right"], ]), totals( [ ["Subtotal", "{{subtotal | currency}}"], ["Tax (10%)", "{{tax | currency}}"], ["Total Due", "{{total | currency}}", true], ], cols ), gap(8), terms("Payment Terms", "{{paymentTerms}}"), ftrPages("{{company.name}}") ); const sampleData = { invoiceNumber: "INV-2026-001", date: "30 March 2026", dueDate: "29 April 2026", company: { name: "Acme Corp", address: "123 Main St", city: "San Francisco, CA 94102" }, customer: { name: "Jane Smith", address: "456 Oak Ave", city: "Portland, OR 97201" }, items: [ { description: "Consulting — March 2026", qty: 40, price: 150.0, amount: 6000.0 }, { description: "Travel expenses", qty: 1, price: 450.0, amount: 450.0 }, ], subtotal: 6450.0, tax: 645.0, total: 7095.0, paymentTerms: "Net 30. Bank transfer to Acme Corp, Account 1234567890.", }; ``` --- ## Reproducing an existing document When the user hands you an image or PDF and asks you to reproduce it (not design a new one), treat this as a reverse-engineering task, not a blank-page design task. The Document Recipes above are for original documents; this section is for matching an existing one. ### The loop ``` source (image/PDF) ↓ extract ground truth (text, font sizes, colors, positions) draft DSL script ↓ makespdf preview → rendered PDF ↓ makespdf validate → structural issues ↓ visual + text compare against the source fix → repeat until page count, text content, and layout all match ``` Never hand the template back without running `makespdf preview` at least once. The rendered PDF is the only ground truth for what your DSL actually produces — reasoning about it in your head is not enough. ### Step 1 — Classify and pick a recipe Identify the document type (invoice, receipt, quote, statement, letter, CV, report) and pick the matching recipe from the Document Recipes section. Recipes constrain _assembly order_ — header → metadata → addresses → body → totals → footer. They are the skeleton; you fill in the specifics from the source. If nothing matches (e.g. a scientific poster, a form), compose from the organisms/molecules directly but keep a strict top-down flow. ### Step 2 — Extract ground truth from the source **The single biggest fidelity unlock is getting exact metrics out of the source, not guessing from pixels.** Your vision alone will miss font sizes by ±2pt and miss subtle weight differences. PDF content-stream operators are exact. Treat extracted font sizes, names, and colors as ground truth, and treat the image as a _layout hint_, not the other way around. **If the source is a PDF**, try these in order of preference: - `pdftotext -layout source.pdf -` (poppler) — preserves column structure, exact strings. - `pdftotext -bbox-layout source.pdf -` — gives per-word bounding boxes, usable as pseudo-positions. - `mutool draw -F text source.pdf` — similar, no poppler dependency. - `qpdf --qdf --object-streams=disable source.pdf out.pdf` then grep `Tf` / `Tj` / `TJ` / `rg` operators in the decompressed content stream — exact font sizes and colors when you need them and the above tools aren't available. Write the extracted data down in this shape before drafting — it's compact, line-per-run, and survives being pasted into scratch notes: ``` --- Page 1 (595x842pt) --- Fonts used: Inter-Bold, Inter-Regular Font sizes: 12, 9pt Text content (top to bottom): [12pt Inter-Bold x:50 y:760] INVOICE [9pt Inter-Regular x:50 y:700] Invoice # [9pt Inter-Regular x:120 y:700] 5505704273 ``` **If the source is an image only**, your vision is the only source. Compensate by: - Estimating the _base body size_ first. Most professional documents use 7–10pt body text, not 10–12pt. Pick a base and scale everything else from it: title ~1.8–2.2× base, section headings ~1.1–1.3× base, captions ~0.8× base. - Using the rendered preview to self-correct. If your reproduction looks visibly bigger than the original at the same page size, your base guess was too high — drop it by 1pt and re-render. ### Step 3 — Separate variables from chrome - Anything that would change between two instances of the same document (numbers, names, dates, line items) becomes a `{{variable}}`. Anything that stays (labels, column headings, footer boilerplate) stays as a literal string. - Repeated rows (line items, education entries, transactions) become `each()` loops. Never hardcode array items, even if the source only shows three of them. - Page numbers are always `thisPage()` / `totalPages()` tags. Never a literal "Page 1 of 1". - Use descriptive camelCase names the end user will recognize (`invoiceNumber`, `items[].description`), not `field1` / `row2`. ### Step 4 — Draft the DSL - Compose from the atoms / molecules / organisms already defined in this skill. Don't invent new shapes. - Bind each extracted text run to either a literal `s()` or a `{{variable}}`, matching the font size, weight, and color from the ground-truth extraction. Override the standard style kit via inline styles or `doc({ styles: {...} })` when kit defaults don't match. - Address blocks: each line is a separate span inside a column. Never one span with embedded `\n`. - Totals: one row per line (label + right-aligned value) via `totRow()` / `totals()`. Never parallel label-column / value-column layouts. - **Completeness rule (hard):** every text run in the ground-truth extraction must appear somewhere in the DSL. Missing a currency suffix, a secondary-currency total, an exchange-rate note, or a fine-print disclaimer is the most common reproduction mistake. ### Step 5 — Populate realistic sampleData - Seed `sampleData` with _actual values from the source_, not `"Lorem"`. Real values surface wrapping and overflow that placeholders hide. - For arrays, include 2–3 items that reflect the real range of widths (one short description + one long one, one small number + one large one). A loop with one item cannot catch wrapping bugs. - If the source has multi-currency, negative values, or long customer names, include those in sampleData so the totals and headers actually get exercised. - If you cannot read a value (pure image, illegible), fall back to name-based guesses: `invoiceNumber` → `"INV-001"`, `total` → `550.00`, `date` → today. Preference order: values the user gave you explicitly > values extracted from the source > name-based defaults. ### Step 6 — Preview, validate, compare, iterate ```bash makespdf preview template.dsl -o draft.pdf # render makespdf validate template.dsl # structural + a11y issues ``` Then run a three-way compare against the original: 1. **Page count.** Must match. If the draft has more pages than the original, the spacing budget is wrong — reduce all section margins to 5–8pt and drop the base font size by 1pt _before_ fixing anything else. This is the fastest signal you're over-budget. 2. **Text completeness.** Extract text from `draft.pdf` the same way you extracted it from the source in Step 2 and diff the two lists. Anything in the source missing from the draft is a dropped span — go find it. 3. **Visual layout.** Rasterize the draft (`pdftoppm -png -r 150 draft.pdf draft`, `sips -s format png draft.pdf --out draft.png`, or whatever image tool your environment has) and eyeball it side-by-side with the source. Look for column widths, alignment, row heights, border weights. Fix and re-run. Do not return the template to the user until all three checks pass, or until you've called out a documented gap they've accepted. ### Hard constraints cheat sheet Reproduction work adds these on top of §Design Principles and §Rules — don't relax those for reproductions: - **Title** max `22pt`. **Any other text** max `18pt`. Never larger. - **Section-to-section margin** max `10pt` absolute. Values of 15, 20, 25+ are never allowed; if any margin top exceeds 10, reduce it to 8. - **Completeness beats elegance.** Every text run from the source — currency suffixes, secondary totals, exchange-rate notes, fine print — must appear in the output. The A4 `~757pt` vertical budget, `thisPage()` / `totalPages()` tags for page numbers, and top-level `ftr()` placement are already covered in §Design Principles and §Rules. --- ## Authentication > **Full agent walkthrough lives in > [`/skills/pdf-auth.md`](https://makespdf.com/skills/pdf-auth.md).** > Read that companion if you don't already have a Bearer token — it > covers the OAuth device flow (RFC 8628), why you should never ask the > user for an email and password, and the right error-handling for each > step. Once you have a token, set `Authorization: Bearer ` on every `/api/v1/*` request. Authentication is the default — every endpoint requires a token unless it's an explicit carve-out (today: `POST /api/v1/pdf/validate` and, when the operator enables it, `POST /api/v1/md`). Stale / invalid tokens always 401; the server never silently downgrades a failed auth attempt to anonymous. A 401 response carries a `device_authorization` block pointing at the exact URLs to start the OAuth device flow. --- ## API endpoints > **The how-to-call-it reference for every endpoint lives in > [`/skills/pdf-api.md`](https://makespdf.com/skills/pdf-api.md).** That > companion covers `POST /api/v1/preview` (free draft renders), `POST /api/v1/render` (billed renders against a saved template), `POST /api/v1/preview/validate` (catalog + a11y check, no render), error > codes, billing rules, and the `jq`-based shell pattern for sending > multi-line DSL. Two-line cheat sheet: - **Authoring / iterating:** `POST /api/v1/preview { dsl, data }` → PDF binary. Free, watermarked, string filler. - **Production:** `POST /api/v1/templates { dsl, name }` → save once, then `POST /api/v1/render { templateId, data }` → PDF binary. Billed at 1 credit / 10 pages. Custom fonts: see [`/skills/pdf-fonts.md`](https://makespdf.com/skills/pdf-fonts.md).