<!-- SKILL-VERSION: pdf-fonts fbdd5e3@2026-05-21T17:55:13+10:00 -->
> **Skill version:** `fbdd5e3@2026-05-21T17:55:13+10:00` (`pdf-fonts`).
> 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 — fonts skill

Companion to [`pdf-template-author.md`](./pdf-template-author.md). That file
teaches the DSL; this one covers everything about fonts: which ones ship
embedded, when to upload your own, how to map a downloaded family onto our
six variant slots, and how an agent should drive the upload API end-to-end.

If you only need basic Latin output, you can ignore this file — `Inter`,
`NotoSans`, `Cousine`, and `NotoSymbols2` are baked in.

---

## What ships embedded

Available to every template with no setup:

| Family         | Use it for                                         |
| -------------- | -------------------------------------------------- |
| `Inter`        | Default UI / body text. Geometric sans-serif.      |
| `NotoSans`     | Body text. Humanist alternative; broader Unicode.  |
| `Cousine`      | Monospace / code blocks.                           |
| `NotoSymbols2` | Checkboxes, bullets, arrows. Auto-fallback target. |

These names are reserved — `POST /api/v1/fonts` rejects them with 400.

---

## When to upload a custom font

Three choices, in order of friction:

1. **Embedded family** (`Inter` / `NotoSans` / `Cousine`) — zero setup.
   Use this whenever it's acceptable.
2. **Per-request `fonts: [{ src, family, variants }]`** on `/preview` or
   `/render` — bytes fetched on each call, ~free for occasional use, wasteful
   if you render the same template thousands of times.
3. **Account-level upload** via `POST /api/v1/fonts` — upload once, reference
   `font-family: "X"` from any saved template forever. Production path for
   brand fonts and CJK.

**Resolver precedence (highest wins):** per-request `fonts` → account-level
`/api/v1/fonts` rows → embedded.

---

## The six variant slots

A "family" in makesPDF maps onto exactly six variant slots. The layout
engine picks one based on the CSS-style declaration in the template:

| Slot             | CSS that selects it            |
| ---------------- | ------------------------------ |
| `Regular`        | default                        |
| `Bold`           | `font-weight: bold` (or `700`) |
| `Italic`         | `font-style: italic`           |
| `BoldItalic`     | bold + italic                  |
| `SemiBold`       | `font-weight: 600`             |
| `SemiBoldItalic` | `font-weight: 600` + italic    |

You don't need to fill all six. Most templates only use `Regular` and
`Bold`. Start there; add italics later.

Other weights (Thin, Light, Medium, Black, ExtraBold, etc.) collapse onto
the nearest of the six. There are no Light or Medium slots — pick the
weight that visually best fits and upload it under one of the six names.

---

## Variable fonts: not supported

We embed **static instances only**. If you upload a variable font (a TTF
that contains an `fvar` table with weight/width axes), the API returns 400
with a clear error pointing you back here.

Why: PDF subsetting strips variation tables. A variable upload would
either fail to parse downstream or render the default master only —
silently wrong.

**Fix:** download a static instance instead. Every Google Fonts family
ships a `static/` folder of fixed-weight files alongside the variable TTF.
Use those.

---

## Non-Latin scripts

The four embedded families (`Inter`, `NotoSans`, `Cousine`, `NotoSymbols2`)
cover Latin only. Anything else is BYO — and how well it works depends on
how much shaping the script needs.

### Works today via BYO font

These scripts are 1:1 codepoint→glyph with no contextual shaping and no
bidirectional reordering, so the renderer draws them correctly the moment
you supply a font that contains the glyphs:

- Latin extended (Vietnamese, Polish, Turkish, Czech, etc.)
- Cyrillic (Russian, Ukrainian, Bulgarian, Serbian, …)
- Greek
- Georgian
- Armenian

### Doesn't work yet (don't try)

Uploading a font for these scripts will succeed, but the layout engine
won't render them correctly. Don't suggest a recipe — point users at the
tracking todos:

- **CJK** (Chinese, Japanese, Korean) — fonts upload fine, but the
  line-breaker splits on whitespace only, so a paragraph of Han / Kana /
  Hangul renders as one giant overflowing word. Tracked in
  [`docs/todo/i18n-cjk-support.md`](../docs/todo/i18n-cjk-support.md).
- **Arabic, Hebrew, Persian, Urdu** — need the Unicode Bidi algorithm
  plus, for Arabic, contextual joining forms via OpenType GSUB. Neither
  is implemented. Tracked in
  [`docs/todo/i18n-complex-scripts-shaping.md`](../docs/todo/i18n-complex-scripts-shaping.md).
- **Indic** (Devanagari, Bengali, Tamil, Telugu, Kannada, Malayalam,
  Gujarati, Gurmukhi, Oriya), **Thai, Lao, Khmer, Burmese** — need glyph
  reordering / conjunct formation / dictionary-based line-breaking. Same
  todo as Arabic/Hebrew.

### Pick a path

Two ways to get a font in, depending on how often you'll use it:

| Use case                                             | Path                                                                                                | Why                                                                                                    |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| One-off PDF, ad-hoc render, agent experimenting      | Per-request `fonts: [{ family, variants: [{ src }] }]` on `/preview` or `/render`                   | Zero setup. Bytes fetched on each call (KV-cached by URL hash) — fine occasionally, wasteful at scale. |
| Saved template, brand consistency, recurring renders | Account-level `POST /api/v1/fonts` upload once, then reference `font-family: "X"` from any template | Upload is paid for once. No `fonts` array on every render call. Survives template re-saves.            |

Resolver precedence is per-request `fonts` → account-level uploads →
embedded, so per-request always wins for a given render.

### Recipe — render Cyrillic + Greek via per-request `fonts`

```bash
curl -X POST "$API/api/v1/preview" \
  -H "Authorization: Bearer $MAKESPDF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "dsl": "const template = doc({ size: \"A4\", styles: { body: { \"font-family\": \"NotoSansLGC\", \"font-size\": 14 } } }, page(col(s({ class: \"body\" }, \"Hello / Привет / Γειά σου\"))));\nconst sampleData = {};",
    "fonts": [{
      "family": "NotoSansLGC",
      "variants": [{
        "src": "https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSans/full/ttf/NotoSans-Regular.ttf"
      }]
    }]
  }' -o mixed.pdf
```

Pick any family name you like as long as it doesn't collide with the four
embedded names (`Inter`, `NotoSans`, `Cousine`, `NotoSymbols2` — those
return 400). Reference it by the same name in `font-family` from your DSL.

For account-level use, upload the same TTF via `POST /api/v1/fonts` once
(see "Upload API" below) under e.g. `family: "NotoSansLGC"`, then drop the
`fonts` array from the request — saved templates that say
`font-family: "NotoSansLGC"` will resolve automatically.

### Reminders

- **Per-request limits**: 4 families per request, 6 variants per family,
  5 MB per file.
- **Embedded-name collision**: `Inter` / `NotoSans` / `Cousine` /
  `NotoSymbols2` are reserved and rejected with 400 on either upload
  path. Pick a different family name (e.g. `NotoSansLGC`).
- **Same family, multiple scripts**: a single TTF that covers Latin +
  Cyrillic + Greek (Noto Sans does, by default) renders mixed-script
  paragraphs from one variant — no per-script font-family swap needed.

---

## Mapping Google Fonts onto the slots — Roboto example

You downloaded `Roboto.zip` from Google Fonts. The folder contains:

```
Roboto-VariableFont_wdth,wght.ttf          ← variable, DO NOT upload
Roboto-Italic-VariableFont_wdth,wght.ttf   ← variable, DO NOT upload
static/
  Roboto-Regular.ttf
  Roboto-Bold.ttf
  Roboto-Italic.ttf
  Roboto-BoldItalic.ttf
  Roboto-SemiBold.ttf
  Roboto-SemiBoldItalic.ttf
  Roboto-Thin.ttf            ← skip (no Thin slot)
  Roboto-Medium.ttf          ← skip (no Medium slot)
  Roboto-Black.ttf           ← skip (no Black slot)
  Roboto_Condensed-Regular.ttf  ← different family, see below
  …
```

Upload only the six files in the right column of the slot table above,
under `family: "Roboto"`. The other static files (Thin, Medium, Black) and
the variable fonts get ignored.

**Width variants are separate families.** Roboto Condensed is a different
shape, not a different weight. Upload it under its own family name and
reference it via `font-family: "RobotoCondensed"`:

```bash
curl -X POST "$API/api/v1/fonts" \
  -H "Authorization: Bearer $MAKESPDF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "family": "RobotoCondensed", "variant": "Regular",
        "src": "https://example.com/Roboto_Condensed-Regular.ttf" }'
```

---

## Upload API

`POST /api/v1/fonts` accepts one variant at a time. Body:

```json
{
  "family": "Roboto",
  "variant": "Bold",
  "src": "https://example.com/Roboto-Bold.ttf"
}
```

Or with base64 bytes when there's no public URL:

```json
{
  "family": "Roboto",
  "variant": "Bold",
  "bytes": "AAEAAAA…"
}
```

Provide **exactly one** of `src` / `bytes`. `variant` must be one of the
six slot names verbatim — `Regular`, `Bold`, `Italic`, `BoldItalic`,
`SemiBold`, `SemiBoldItalic`.

Response on success:

```json
{
  "fontId": "f4c9…",
  "family": "Roboto",
  "variant": "Bold",
  "byteSize": 176432,
  "sha256": "9f2b…",
  "format": "ttf",
  "createdAt": "2026-04-25 14:30:11"
}
```

### Recipe — upload a Roboto family in one shell loop

```bash
API=https://makespdf.com
declare -A files=(
  [Regular]=Roboto-Regular.ttf
  [Bold]=Roboto-Bold.ttf
  [Italic]=Roboto-Italic.ttf
  [BoldItalic]=Roboto-BoldItalic.ttf
  [SemiBold]=Roboto-SemiBold.ttf
  [SemiBoldItalic]=Roboto-SemiBoldItalic.ttf
)
for variant in "${!files[@]}"; do
  bytes=$(base64 -i "static/${files[$variant]}")
  jq -n --arg f Roboto --arg v "$variant" --arg b "$bytes" \
    '{ family: $f, variant: $v, bytes: $b }' \
    | curl -sX POST "$API/api/v1/fonts" \
        -H "Authorization: Bearer $MAKESPDF_API_KEY" \
        -H "Content-Type: application/json" --data-binary @-
done
```

`base64 -i` (macOS) or `base64 -w0` (Linux) — strip newlines from the
output. The server rejects malformed base64 with a 400.

### List / get / delete

```bash
# List your uploads + remaining quota
curl -s "$API/api/v1/fonts" -H "Authorization: Bearer $MAKESPDF_API_KEY"

# Get one row
curl -s "$API/api/v1/fonts/$FONT_ID" -H "Authorization: Bearer $MAKESPDF_API_KEY"

# Delete a row. Templates referencing the family will start returning a
# missing-font error on the next render — no silent fallback.
curl -X DELETE "$API/api/v1/fonts/$FONT_ID" \
  -H "Authorization: Bearer $MAKESPDF_API_KEY"
```

---

## Quotas and limits

| Limit                   | Value      |
| ----------------------- | ---------- |
| Families per account    | 8          |
| Variants per family     | 6          |
| Bytes per file          | 5 MB       |
| Total bytes per account | 20 MB      |
| Upload rate             | 20 / hour  |
| Read rate               | 500 / hour |

`GET /api/v1/fonts` returns a `quota` object with current usage. Quota
errors come back as 400 with `details[]` and a structured `quota` object
showing remaining headroom — read those rather than guessing.

---

## Common mistakes

- **Uploading the variable TTF.** Returns 400 every time. Always pick from
  `static/`.
- **Using a CSS weight that has no slot** (`font-weight: 100` for Thin).
  The layout engine falls back to `Regular` — looks fine, just isn't the
  weight you asked for. Pre-bake the right slot.
- **Treating width as weight.** `Roboto Condensed` and `Roboto` are two
  families. Don't try to upload Condensed under `family: "Roboto"`.
- **Reusing an embedded name.** `Inter`, `NotoSans`, `Cousine`,
  `NotoSymbols2` are reserved — pick a different name.
- **Forgetting that delete is permanent.** No undo. R2 dedups by sha256, so
  the bytes survive in storage if another row points to the same hash, but
  your row is gone.

---

## In a template

After upload, reference the family by name in any DSL style:

```js
const template = doc(
  { size: "A4", styles: { body: { "font-family": "Roboto", "font-size": 11 } } },
  page(col(s({ class: "body" }, "Hello in Roboto.")))
);
```

No `fonts` array needed. The render server resolves `Roboto` from your
account-level uploads automatically.

If a saved template references a family you haven't uploaded (and isn't
embedded), the render fails with a clear missing-font error — fix it by
uploading the right variant or changing the template to use an embedded
family.
