<!-- SKILL-VERSION: pdf-recipes 3ed568e@2026-06-05T18:19:05+10:00 -->
> **Skill version:** `3ed568e@2026-06-05T18:19:05+10:00` (`pdf-recipes`).
> 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.

# makesPDF — document recipes

Per-document-type assembly recipes for the makesPDF builder DSL. Companion
to [`pdf-template-author.md`](./pdf-template-author.md), which covers the
DSL primitives, style cascade, validation, and cross-cutting layout
techniques (bookmarks, stamps, internal anchors, dense tables).

This file is the "what does an X look like?" reference. Each recipe is a
self-contained skeleton you customise with real data and `styles`. They
all assume the standard style kit is in place (which is the default —
`doc()` opts you in unless you explicitly disable it).

If you don't know which doc type fits the user's request, the answer is
usually **invoice** for anything money-out, **receipt** for anything
money-in already paid, **quote** for "estimate / proposal", **letter** for
prose, **report** for prose with sections, **CV** for a résumé.

## Invoice

```
minHdr("Invoice", "{{company.name}}")
lvGrid([["Invoice #:", "{{number}}"], ["Date:", "{{date}}"], ["Due:", "{{dueDate}}"]])
gap(8)
addrs({ label: "From", lines: [...] }, { label: "Bill to", lines: [...] })
gap(8)
const cols = [["Description", "45%"], ["Qty", "15%", "center"], ["Price", "20%", "right"], ["Amount", "20%", "right"]];
table(cols, "item in items", [cells...])
totals([
  ["Subtotal", "{{subtotal | currency}}"],
  ["Tax",      "{{tax | currency}}"],
  ["Total",    "{{total | currency}}", true],
], cols)
terms("Payment Terms", "{{terms}}")
ftrPages("{{company.name}}")
```

## Receipt

```
minHdr("Receipt", "{{company.name}}")
lvGrid([["Receipt #:", "{{number}}"], ["Date:", "{{date}}"], ["Payment:", "{{paymentMethod}}"]])
addr(["{{customer.name}}", "{{customer.address}}"])
gap(8)
table([headers...], "item in items", [cells...])
totals([["Subtotal", "..."], ["Tax", "..."], ["Total Paid", "...", true]])
terms("Return Policy", "{{returnPolicy}}")
ftrPages()
```

## Quote / Estimate

```
minHdr("Quote", "{{company.name}}")
lvGrid([["Quote #:", "{{number}}"], ["Date:", "{{date}}"], ["Valid Until:", "{{validUntil}}"]])
addrs({ label: "From", lines: [...] }, { label: "To", lines: [...] })
table([headers...], "item in items", [cells...])
totals([...])
terms("Terms & Conditions", "{{terms}}")
sigBlock()
ftrPages("{{company.name}}")
```

## Statement / Report

```js
// Date column budget:
// - Full format ("2 March 2026"): 18% min of A4-page width at 10pt.
//   (16% wraps at default padding -- leave a safety margin.)
// - Short format ("02 Mar" or "2026-03-02"): 10-12% is fine.
// Columns are percentages of page content-area width (minus padding) and
// should sum to exactly 100%.
const cols = [
  ["Date", "18%"], // 18% min at default padding -- full-format dates wrap below this
  ["Description", "42%"],
  ["Debit", "13%", "right"],
  ["Credit", "13%", "right"],
  ["Balance", "14%", "right"],
];

minHdr("Statement", "{{company.name}}");
lvGrid([
  ["Period:", "{{period}}"],
  ["Account:", "{{accountNumber}}"],
]);
// Optional summary box:
col(
  {
    border: [1, 1, 1, 1],
    "border-color": "#d1d5db",
    "border-radius": 4,
    padding: [10, 12, 10, 12],
  },
  bold(["Opening Balance: ", "{{openingBalance | currency}}"]),
  s(["Closing Balance: ", "{{closingBalance | currency}}"])
);
table(cols, "tx in transactions", [
  ["{{tx.date}}", "18%"],
  ["{{tx.desc}}", "42%"],
  [when("tx.debit", s("{{tx.debit | currency}}")), "13%", "right"],
  [when("tx.credit", s("{{tx.credit | currency}}")), "13%", "right"],
  ["{{tx.balance | currency}}", "14%", "right"],
]);
// Totals: omit `cols` here. Statement labels ("Interest Earned", "Closing
// Balance") are 14-16 chars and need ~25% width. Passing `cols` would
// give the label only 13% (the N-2 Credit column) and wrap every line.
// The default layout is 60% spacer / 25% label / 15% value.
totals([
  ["Fees Charged", "{{fees | currency}}"],
  ["Interest Earned", "{{interestEarned | currency}}"],
  ["Closing Balance", "{{closingBalance | currency}}", true],
]);
ftrPages("{{company.name}}");
```

A cell's content slot accepts a `when()` wrapper for conditional rendering.
Use this for columns populated on some rows but not others (debit/credit,
discount, optional fees) — an empty cell stays blank instead of rendering
the literal string `undefined`.

> Wrapping inside a table cell is silent — nothing fails. If your data has
> long strings (full-format dates, multi-word descriptions, amounts with
> thousand separators), budget width generously and verify in the rendered
> PDF (see §Post-render verification in the main skill).

## Letter

```
minHdr("", "{{sender.name}}")
addr(["{{sender.name}}", "{{sender.address}}", "{{sender.city}}"])
s("{{date}}")
addr(["{{recipient.name}}", "{{recipient.address}}", "{{recipient.city}}"])
gap(12)
// Body paragraphs — each as a separate span with line-height 1.4.
// Inline style keys are kebab-case, not camelCase: "font-size", "line-height" (not fontSize/lineHeight).
s({ "font-size": 11, "line-height": 1.4 }, "{{salutation}}")
s({ "font-size": 11, "line-height": 1.4 }, "{{body}}")
gap(20)
sigBlock()
ftrPages()
```

## CV / Résumé

Assembly order: name + headline → contact row → summary → experience (loop) → education (loop) → skills.

```js
const template = doc(
  { size: "A4", title: "{{name}} — CV" },
  // Name + headline.
  // Style keys are kebab-case: "font-size", "font-weight" — NOT fontSize/fontWeight.
  // camelCase keys produce unknown-style-property warnings and the styles do not apply.
  s({ "font-size": 22, "font-weight": "bold" }, "{{name}}"),
  s({ "font-size": 11, color: "#555555" }, "{{headline}}"),
  // Contact row: inline icons/labels + link atoms. One row keeps width predictable.
  text(
    s("{{location}}"),
    s("  ·  "),
    link("mailto:{{email}}", "{{email}}"),
    s("  ·  "),
    link("{{github}}", "github.com/{{githubHandle}}")
  ),
  gap(8),
  // Summary paragraph
  s({ "font-size": 10, "line-height": 1.4 }, "{{summary}}"),
  gap(10),
  // Experience
  s({ class: "section-heading" }, "Experience"),
  each(
    "job in experience",
    r(
      { grid: ["70%", "30%"] },
      col("70%", bold("{{job.role}} — {{job.company}}")),
      col({ width: "30%", align: "right" }, s({ color: "#666666" }, "{{job.from}} – {{job.to}}"))
    ),
    each("b in job.bullets", bullet("{{b}}"))
  ),
  gap(8),
  // Education
  s({ class: "section-heading" }, "Education"),
  each(
    "ed in education",
    r(
      { grid: ["70%", "30%"] },
      col("70%", bold("{{ed.degree}} — {{ed.school}}")),
      col({ width: "30%", align: "right" }, s({ color: "#666666" }, "{{ed.year}}"))
    )
  ),
  gap(8),
  // Skills — inline each() with separator suppression
  s({ class: "section-heading" }, "Skills"),
  text(each("sk in skills", mono("{{sk}}"), when("!@last", s(" · ")))),
  ftrPages()
);
```

Common gotchas:

- **Date field names vary.** This recipe uses `{{job.from}}` / `{{job.to}}`
  to match JSON Resume and most real-world résumé data. Other shapes
  use `start`/`end` or `startDate`/`endDate` — match whatever the data
  actually has; don't rename the data to match the recipe.
- **Contact-line wrap.** If email + location are long, split the row into
  two columns (contact left / links right) or drop the line-height to 1.2.
- **Skills list** uses the inline-`each()` pattern — see Primitives in
  the main skill for the shape. If you pre-join into a single string you
  lose the per-skill `mono()` styling.
- **Two-page CVs** need column-flow layout, which the engine doesn't
  support yet. Keep to one page by trimming summary/bullets, not by
  shrinking the font below 9pt.
