# Production (Build · Verify · Troubleshoot)
The engineering runbook for kami: from HTML / Python / Markdown sources to PDF,
PPTX, browser-deck, and DOCX deliverables. It covers each supported render path,
verification and debugging, and the known pitfalls gathered from production use.
---
## Part 1 · HTML -> PDF (WeasyPrint)
### Install
```bash
pip install weasyprint pypdf --break-system-packages --quiet
```
Linux first-time:
```bash
apt install -y libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-cjk
```
### Generate
```python
from weasyprint import HTML
HTML('doc.html').write_pdf('output.pdf')
```
**CWD matters**: `@font-face { src: url("xxx.ttf") }` uses relative paths, so run from the directory containing the font file.
```bash
cd /path/to/html-and-font
python3 -c "from weasyprint import HTML; HTML('doc.html').write_pdf('out.pdf')"
```
### Fonts
**Most stable setup**: font file alongside HTML, `@font-face` with relative path.
```html
```
**No commercial font available**: fallback chains are embedded in every template.
```css
/* English */
font-family: Charter, Georgia, Palatino,
"Times New Roman", serif;
/* Chinese */
font-family: "TsangerJinKai02", "Source Han Serif SC",
"Noto Serif CJK SC", "Songti SC", Georgia, serif;
/* Japanese */
font-family: "YuMincho", "Yu Mincho", "Hiragino Mincho ProN",
"Noto Serif CJK JP", "Source Han Serif JP",
"TsangerJinKai02", Georgia, serif;
/* Korean */
font-family: "Source Han Serif K", "Source Han Serif KR",
"Noto Serif KR", "Apple SD Gothic Neo", AppleMyungjo,
Charter, Georgia, serif;
```
**Font fallback affects page count**. Any font swap requires re-running the page-count check. If output overflows, first confirm the intended font actually loaded. If it did, edit content using pitfall "Hard-limit overflow"; spacing comes later and font size is the last resort.
**Claude Desktop skill ZIPs do not bundle large CJK font files**: `TsangerJinKai02-W04.ttf`, `TsangerJinKai02-W05.ttf`, `SourceHanSerifKR-Regular.otf`, and `SourceHanSerifKR-Medium.otf` can make Claude.ai / Desktop skill upload or execution time out. The ZIP you upload must be the `scripts/package-skill.sh` output under the 6MB package ceiling, never a hand-zipped checkout. `package-skill.sh` excludes those large font files. Templates still keep local-first and jsDelivr fallback `@font-face` paths.
When Chinese or Korean fonts are missing (the skill case), `scripts/ensure-fonts.sh` downloads them to the XDG user font dir (`${XDG_DATA_HOME:-~/.local/share}/fonts/kami`, override with `KAMI_FONT_DIR`), **not** into the skill's `assets/fonts`. fontconfig scans that dir by default on macOS and Linux, so WeasyPrint resolves `TsangerJinKai02` and `Source Han Serif K` from there while the installed skill stays small; online renders still use the jsDelivr `@font-face` URL.
**Standalone HTML export** (sending a filled HTML file to someone else): this is not guaranteed to work outside the project tree. If the recipient cannot set up the font environment, use the PDF output instead.
If you do need to share HTML: the font file and the HTML must live in the same directory, and the `@font-face src` must use a bare filename with no path prefix:
```css
@font-face {
font-family: "TsangerJinKai02";
src: url("TsangerJinKai02-W04.ttf") format("truetype");
}
```
Remove the `../fonts/` prefix that templates use when fonts are in the project tree. The recipient must place the `.ttf` file alongside the `.html` file before running WeasyPrint. When in doubt, deliver the PDF.
### Page spec
```css
@page {
size: A4; /* or 210mm 297mm / A4 landscape / 13in 10in */
margin: 20mm 22mm;
background: #f5f4ed; /* extend past margins to avoid white printed edge */
}
```
### Print / white-paper variant (opt-in)
Parchment is the default and keeps shipping. Override to white only when a single
document is **headed for a home / office printer**: a full-page `#f5f4ed` tint
bands unevenly and burns toner, where white paper prints clean. This is the one
sanctioned print exception to design.md invariant #1, and it is
opt-in per document, never the default render.
White is not a one-line background swap. Parchment also serves as the surface that
`--ivory` cards, code blocks, and striped rows lift *off* of (they are "brighter
than parchment"). Flip the page to white and those surfaces, only 1.5% lighter than
white, vanish. So relocate the warmth instead of deleting it: sink parchment down
into the lift surface. Three edits on the copied, filled template:
```css
@page { background: #ffffff; } /* was #f5f4ed */
html, body { background: #ffffff; } /* was var(--parchment) */
:root { --ivory: #f5f4ed; } /* parchment becomes the card/code/table lift */
```
Everything else is unchanged: ink-blue accent, warm text grays, and `--border`
hairlines all read fine on white. Leave the `--parchment` token itself alone (any
`var(--parchment)` section fill then reads as an intentional warm band on white).
A lifted surface that separated from parchment by fill *alone* (rare, most already
carry a `0.5pt var(--border)` edge) wants that border added so it holds an edge on
white.
Notes:
- Lint-safe by construction: `scripts/lint.py` off-palette and token-sync guards
scan only registered templates, not generated documents, so `#ffffff` in a
filled output never trips them.
- Page-count and `--check-density` contracts are unaffected; still inspect that
cards, code, and tables read on white before shipping.
- If white-paper output ever becomes a first-class, frequent ask, promote this from
a recipe to a `--page-bg` / `--surface` token pair in every template `:root`
(ships commented, like the `.brand-logo` slot). `@page { background: var(--page-bg) }`
is verified to resolve in WeasyPrint (68.1), so the token path is viable. Until
then, keep it a recipe: the project's contract is "no new mode unless a request
can't be met otherwise", and this recipe already meets the request.
### Headers & footers
Long-doc running headers use `string(section-title)`. The default templates set
that string on chapter `h1` elements, not on every `h2`, so a table of contents
heading cannot pin the header to "Contents" / "目录". If a filled document uses a
different element for chapter titles, add `.running-title` to that element.
```css
@page {
@top-right {
content: counter(page);
font-family: serif; font-size: 9pt; color: #6b6a64;
}
@bottom-center {
content: "{{DOC_NAME}} · {{AUTHOR}}";
font-size: 9pt; color: #6b6a64;
}
}
@page:first {
@top-right { content: ""; }
@bottom-center { content: ""; }
}
```
### WeasyPrint support matrix
| Solid | Partial | Unsupported |
|---|---|---|
| CSS Grid / Flexbox | CSS filter / transform (partial) | JavaScript |
| `@page` rules | inline SVG (some attrs) | `position: sticky` |
| `@font-face` | gradients (slow, use sparingly) | CSS animations / transitions |
| `break-before` / `break-inside: avoid` | | |
| CSS variables `var(--name)` | | |
| `target-counter(attr(href), page)` for rendered TOC page numbers | | |
| `::before` / `::after` | | |
### Strict LaTeX mathematics
WeasyPrint has no JavaScript runtime, so MathJax/KaTeX script tags do **not** render mathematics. Kami therefore uses a local MathJax SVG path backed by Node.js 20 or Node.js 22 and newer. Author source formulas strictly as inline `\( ... \)` or display `\[ ... \]` in rendered body text. Delimiters inside title metadata, form options, literal/code/script/style/SVG/template/MathML regions are ignored. Do not ship Unicode approximations, raw TeX, or screenshot formulas.
```bash
# One-time dependency check/install
bash scripts/ensure_mathjax.sh
# Convert every standard LaTeX delimiter pair in delivered HTML to MathJax SVG
python3 scripts/math_render.py --in-place filled.html
# A completed HTML must have no raw TeX delimiters
python3 scripts/math_render.py --check filled.html
```
`render_pdf` performs the same conversion in memory as a hard fallback and fails on unmatched delimiters, invalid TeX, author-controlled color/HTML/link commands, unsafe MathJax markup, or a missing locked runtime. Formulas inherit the document's text color. The in-place command writes atomically and remains required when HTML itself is delivered, reviewed, or reused. MathJax SVG is vector output and prints sharply in WeasyPrint PDFs.
### PDF metadata
WeasyPrint reads standard meta tags in `
` and writes them into the PDF (Title / Author / Subject / Keywords). All templates have pre-built placeholders:
```html
{{DOC_TITLE}}
```
**Auto-inference rules** (Claude fills these from the document content without asking):
| Field | Source |
|---|---|
| `` | H1 heading or `.header .title` text |
| `author` | Resume / letter / portfolio: person's name from the document; everything else: `"Kami"` |
| `description` | One sentence extracted from the first 2 paragraphs, ≤150 characters |
| `keywords` | 3-5 keywords from title + section headings, comma-separated |
| `generator` | Fixed `"Kami"`, already set in template, do not change |
**Verify**:
```bash
pdfinfo assets/examples/one-pager-en.pdf # shows Title / Author / Subject
```
---
## Part 2 · Python -> PPTX (python-pptx)
PPT shares the same design language but the medium (screen, 16:9, one-idea-per-slide) changes the details: fonts larger, layouts more rigid.
### Install
```bash
pip install python-pptx --break-system-packages --quiet
```
### Dimensions
- **16:9 widescreen** (preferred): 13.33 × 7.5 inch
- **4:3 traditional**: 10 × 7.5 inch
- **Safe zone**: 0.5 inch margin on all sides (projector crop), plus 0.3 inch at bottom for page number
### Palette (mirrors `assets/templates/slides.py`)
```python
from pptx.dml.color import RGBColor
PARCHMENT = RGBColor(0xf5, 0xf4, 0xed)
IVORY = RGBColor(0xfa, 0xf9, 0xf5)
BRAND = RGBColor(0x1B, 0x36, 0x5D)
NEAR_BLACK = RGBColor(0x14, 0x14, 0x13)
DARK_WARM = RGBColor(0x3d, 0x3d, 0x3a)
OLIVE = RGBColor(0x50, 0x4e, 0x49)
STONE = RGBColor(0x6b, 0x6a, 0x64)
BORDER = RGBColor(0xe8, 0xe6, 0xdc)
```
The PPTX path defines no tag constant; tag styling is print- and screen-only. `references/tokens.json` is the source of truth for the values; `scripts/tokens.py` pins the ones in its `PY_TOKEN_MAP` (`BORDER` is not among them, so change it in `slides.py` and here together).
### Type (bigger than print, optimized for projection)
| Role | Size | Font |
|---|---|---|
| Title | 48pt | Serif 500 |
| Subtitle | 24pt | Serif 400 |
| H2 chapter | 32pt | Serif 500 |
| H3 subtitle | 20pt | Serif 500 |
| Body | 18pt | Serif 400 |
| Caption | 14pt | Serif 400 |
| Footer | 12pt | Serif 400 |
English stack on PowerPoint:
- Serif: `Charter` -> `Georgia` -> `Palatino`
- Sans: same as serif (single-font-per-page rule)
### Nine standard layouts
1. **Cover**: parchment background, centered display title + brand-colored short line + subtitle / author / date
2. **Contents**: parchment, left-aligned `01 Chapter title` (number serif brand-colored)
3. **Chapter divider**: full brand ink-blue background, centered white title - the **only** fully chromatic slide in the deck
4. **Content slide**: eyebrow (serif stone) + core claim (serif near-black) + brand line + body (serif dark-warm)
5. **Data slide**: top takeaway + 2-4 metric cards (big number serif brand + small label serif olive)
6. **Comparison**: eyebrow + left column (muted, OLIVE/STONE) vs. right column (full-weight, DARK_WARM/NEAR_BLACK), separated by a 1pt BORDER warm-gray vertical divider. Left = "Before/Old/Problem"; right = "After/New/Solution". Use `comparison_slide()`.
7. **Pipeline**: eyebrow + title + serif numerals 01/02/03 + step title + step description, laid out in equal-width columns. All steps visible at once (no click-reveal). Use `pipeline_slide()`.
8. **Quote**: parchment, minimal, centered serif quote + `- Source`
9. **Closing**: parchment, centered "Thank you / Q&A / Contact"
### Template-bound PPTX inventory
Use this only when the user provides a real PPTX or brand template and explicitly asks to preserve its layout system. First inspect the template visually, identify the few reusable layout families, and map each planned section to one existing slide family. Then edit content while preserving the template's shape structure. Do not run this inventory step for Kami's default WeasyPrint or Marp paths; those already have fixed template contracts.
### Script skeleton
Full working example in `assets/templates/slides-en.py`. Key bits:
```python
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN
PARCHMENT = RGBColor(0xf5, 0xf4, 0xed)
BRAND = RGBColor(0x1B, 0x36, 0x5D)
SERIF = "Charter"
SANS = SERIF
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
def blank_slide():
slide = prs.slides.add_slide(prs.slide_layouts[6])
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE,
0, 0, prs.slide_width, prs.slide_height)
bg.fill.solid(); bg.fill.fore_color.rgb = PARCHMENT
bg.line.fill.background(); bg.shadow.inherit = False
return slide
```
### PPT notes
1. **One idea per slide** - if it runs over three lines, split it
2. **No default PowerPoint template** - it's cool-blue-gray, clashes with parchment
3. **Animations**: none by default. When motion carries meaning, use `fade` only
4. **Export to PDF** for sharing - cross-machine consistency is better than .pptx
- macOS: Keynote -> Export to PDF
- Linux: `libreoffice --headless --convert-to pdf output.pptx`
### PPTX delivery check
A generated PPTX is complete only after it has been opened in presentation software
or exported to PDF and inspected. Pass when the intended Kami font family remains
present, with no silent substitution to Calibri, Aptos, or another presentation
default; no text overflows or crops; all content stays inside the safe zone; and page
numbers remain intact. A successful `python-pptx` save is not delivery evidence.
### Slide scale rules (from production decks)
Print and slide share the same palette and serif, but sizing is different.
One rule covers most adjustments: **macro spacing x1.6, micro details x0.5** (letter-spacing, border weight, border-radius).
| Item | Print | Slide | Why |
|---|---|---|---|
| Container | A4 portrait | 1920x1080 or A4 landscape | Fixed ratio, never `100vw x 100vh` |
| Title size | 30-34pt | 48-64pt | Projection needs larger display |
| Slide padding | N/A | 72-80px top, 80px sides | Less than 72px top feels cramped |
| Eyebrow tracking | 0.5-1pt | 3px max | Print tracking looks scattered at slide scale |
| Display tracking | 0 to -0.2pt | -0.5pt | Tighten large titles to prevent letter gaps |
| Header gap | 8-14pt | 36px+ between rule and H1 | Below 36px the rule looks like an underline |
| Line-height titles | 1.1-1.3 | 1.3 minimum | CJK characters collide below 1.3 at slide sizes |
| Code blocks | Full runnable code | Pseudocode + `.hl` keywords | Slide code is for structure, not execution |
| Images | Inline with text | `width:100%; object-fit:contain; max-height:780px` | Avoid fixed height that clips different ratios |
| Footer | Per-template | Single component, CSS-injected text | Prevents drift across 50+ slides |
| Punctuation | Standard | Use ` - ` for short joins, `` for parallel items | CJK commas break visual rhythm at slide scale |
---
## Part 2.5 · Markdown -> Marp (variant deck path)
Marp is the third rendering path, used only when the user explicitly asks for Marp / markdown slides / a deck that lives in a `.md` file. The repo does **not** declare `marp-cli` as a dependency; install it on the user's machine.
### Install
Use the `npx @marp-team/marp-cli@latest ...` form below for zero-install. For repeat use, install via `npm i -g @marp-team/marp-cli` or `brew install marp-cli` (see [marp-cli docs](https://github.com/marp-team/marp-cli)). Kami's build pipeline (`build.py` / `package-skill.sh`) does not call `marp`.
### Files
| Asset | Path |
|---|---|
| CJK theme (CN, JP/KO best-effort) | `assets/templates/marp/slides-marp.css` (theme name: `kami`) |
| EN theme | `assets/templates/marp/slides-marp-en.css` (theme name: `kami-en`) |
| CJK sample deck | `assets/templates/marp/slides-marp.md` |
| EN sample deck | `assets/templates/marp/slides-marp-en.md` |
### Render commands
Run from the repo root so input paths resolve. **Input file must come before `--theme-set`**; `--theme-set` is a yargs array option and will swallow any positional arg that follows it.
**Font path caveat**: Marp inlines the theme CSS into the output HTML verbatim. The `@font-face` `url("../../fonts/...")` paths in the theme therefore resolve relative to the *output file location*, not the theme CSS location. When the output sits inside the repo (e.g. `-o assets/examples/kami.html`), the relative path matches and local Tsanger / Charter loads. When the output sits elsewhere (e.g. `-o /tmp/kami.html`), the relative path misses and the browser falls back to the jsDelivr CDN URL declared alongside each local one. This needs network. This differs from WeasyPrint, where CSS paths resolve relative to the input HTML.
```bash
# HTML preview (no Chromium needed; zero external download)
npx @marp-team/marp-cli@latest \
assets/templates/marp/slides-marp.md \
--theme-set assets/templates/marp \
-o /tmp/kami-cn.html
# PDF (needs Chromium; see "Browser strategy" below)
npx @marp-team/marp-cli@latest \
assets/templates/marp/slides-marp.md \
--theme-set assets/templates/marp \
--pdf --allow-local-files \
-o /tmp/kami-cn.pdf
# PPTX (Chromium-rendered; not a python-pptx editable deck)
npx @marp-team/marp-cli@latest \
assets/templates/marp/slides-marp.md \
--theme-set assets/templates/marp \
--pptx --allow-local-files \
-o /tmp/kami-cn.pptx
```
`--theme-set` points at the directory; Marp picks up every `.css` in there. `--allow-local-files` is required for PDF / PPTX so the renderer may read the local font files referenced by `@font-face` URLs.
### Browser strategy (only for PDF / PPTX)
HTML output is pure Node and needs no browser. PDF / PPTX rendering goes through a headless browser. Three options, lightest first:
| Strategy | Setup | Cost |
|---|---|---|
| **HTML only, view in browser** | Use the HTML command above; open in any browser; export to PDF from the browser's print dialog if needed | 0 MB |
| **Reuse a local Chromium-family browser** | Set `PUPPETEER_EXECUTABLE_PATH` to the absolute path of an installed Chrome, Edge, Brave, Arc, or Chromium binary. Marp also honours `--browser chrome` / `edge` / `firefox` with `--browser-path`. | 0 MB (assumes the browser is already installed) |
| **Let Marp download its own Chromium** | First run of `--pdf` / `--pptx` triggers Puppeteer to fetch a pinned Chromium build (~150 MB to `~/.cache/puppeteer/`) | ~150 MB, one-time |
For light verification of the theme and sample deck, prefer the HTML path.
### Marp gotchas
| Symptom | Cause | Fix |
|---|---|---|
| Two page numbers per slide | Deck pinned a `.page-num` element and also set `paginate: true` | Pick one; the theme injects pagination via `section::after` |
| `position: absolute` does not pin `.co` | A child `
` overrode `position: relative` on the section | Marp themes already set `section { position: relative }`; do not override it per slide |
| PDF / PPTX export hangs on first run | Marp is downloading Chromium | Network-restricted environments need `PUPPETEER_EXECUTABLE_PATH` to a pre-installed Chrome |
| Markdown inside `
` renders as a literal HTML block | Missing blank line between the `
` and the Markdown body | Always leave a blank line above and below Markdown inside an HTML wrapper |
---
## Part 3 · Verify & Debug
### The three-step loop (mandatory after every change)
```bash
# 1. Generate
python3 -c "from weasyprint import HTML; HTML('doc.html').write_pdf('out.pdf')"
# 2. Page count
python3 -c "from pypdf import PdfReader; print(len(PdfReader('out.pdf').pages))"
# 3. Visual inspect (when in doubt)
pdftoppm -png -r 300 out.pdf inspect
```
**Not verified = not done.**
### Did the font actually load?
```bash
python3 scripts/build.py --check-fonts output.pdf # verdict, from the PDF's span table
pdffonts output.pdf # raw font table, if you want to look yourself
```
`--check-fonts` reads which family drew the body ideographs and fails on two
states `pdffonts` will not tell you apart: a sans substitution (the page reads
fine and looks cheap), and CJK text split across two families (per-glyph
fontconfig fallback, which breaks single words down the middle). Prefer it over
reading the font table by eye, and never sign off a CJK deliverable without it.
If the output shows `DejaVuSerif` / `Bitstream Vera` - your specified font didn't load, fell through to system ultimate fallback. Expected: `Charter`, `Georgia`, `TsangerJinKai02`, or a Japanese Mincho face such as `YuMincho`, `Hiragino-Mincho`, `Noto-Serif-CJK-JP`, or `Source-Han-Serif-JP`.
### One-step build + validate
Project script `scripts/build.py` is the productized version of the three-step loop:
```bash
python3 scripts/build.py # all registered examples
python3 scripts/build.py resume-en # one target + page count + fonts
python3 scripts/build.py --check # scan for CSS rule violations
python3 scripts/build.py --check-density # warn on pages with >25% trailing whitespace
python3 scripts/build.py --check-markdown output.pdf # scan for raw Markdown markers
python3 scripts/build.py --check-content content.json filled.html # content IR schema + coverage
python3 scripts/build.py --check-visual output.pdf # page PNGs + perceptual review checklist
```
For long-doc templates, keep TOC entries as links to stable chapter ids. The page
number is generated with CSS `target-counter()` at render time, so content edits
do not require hand-updating page numbers.
### Page overflow (constrained templates)
When a constrained template exceeds its page ceiling, treat content as the primary
repair surface; use Part 4 pitfall "Hard-limit overflow" for the proven priority
order. The result must pass both checks:
```bash
python3 scripts/build.py --check
python3 scripts/build.py --verify
```
### Hi-res visual inspection
```bash
pdftoppm -png -r 160 output.pdf preview # standard
pdftoppm -png -r 300 output.pdf preview # detail bugs
pdftoppm -png -r 400 output.pdf preview # extreme detail (tag double-rect check)
```
### 5-point pre-ship review
A successful render is not enough. Scan these before delivery:
| Dimension | Pass standard |
|---|---|
| Fact accuracy | Numbers, dates, versions, funding, specs, and market facts have sources; uncertainty is written as magnitude or marked as missing |
| Content structure | Headlines read as a summary; each paragraph opens with a claim; no ceremonial filler |
| Material coverage | Branded docs include logo, product image, or UI screenshot coverage; missing materials are clearly marked |
| Typographic detail | Fonts load correctly, line-height stays in spec, emphasis only marks numbers or distinctive phrases, tag backgrounds are solid hex |
| PDF readiness | Page count fits, placeholders are replaced, visual inspection shows no overflow, overlap, or broken page breaks |
If any row fails, fix it before delivery.
### Static product site pre-ship review
Run this when the deliverable is a landing page, product site, or hosted showcase rather than a PDF:
| Dimension | Pass standard |
|---|---|
| Runtime preview | Serve locally and inspect desktop plus 375px mobile. The first viewport shows the product category, real asset, CTA, and a hint of the next section. |
| Generated output | If pages come from `template + i18n + content`, run the generator's check mode. It must fail on missing keys and committed output drift. |
| Public metadata | Canonical, hreflang, `og:locale`, social image, JSON-LD, robots, sitemap, `llms.txt`, and `llms-full.txt` all reflect the shipped locale set. |
| Copy sync | Product positioning, price, version, install path, support link, FAQ, `llms.txt`, and `llms-full.txt` carry the same factual claims in every locale. |
| Asset reality | Screenshots are real product surfaces and every image path resolves from the repo or a public URL. No `/Users`, `file://`, or sibling-repo relative paths. |
| Screenshot fit | Hero, gallery, feature, and social-image slots use stable ratios. UI text, numbers, prompts, and controls are not cropped away for aesthetics. |
| Motion fallback | Gallery rotation, entrance animation, or custom transitions respect `prefers-reduced-motion`; a still page remains readable with motion disabled. |
| Link surface | Primary CTA, download, releases, docs, help, social links, and internal locale links resolve. Any named release or download artifact exists. |
If the site has only one or two locales, hand-maintained static pages are acceptable. For three or more locales, prefer a generator with a drift check over repeated manual edits.
---
## Part 4 · Known pitfalls
Every entry below came from a real failure. Check here first when something looks wrong.
Severity scale: **(P0)** render-breaking, must fix before delivery. **(P1)** breaks the design contract (rhythm, spec). **(P2)** visible to a careful reader, but not blocking. **(P3)** operational: affects workflow, not visual output.
### 1. (P0) Tag / Badge double-rectangle bug (the worst)
**Symptom**: PDFs show two concentric rectangles on tag backgrounds at zoom - an outer softer one and an inner tighter one. Especially visible on mobile PDF viewers.
**Root cause**: WeasyPrint renders `rgba(..., 0.xx)` by compositing the **padding area** and the **glyph pixel area** independently. Glyph anti-aliasing stacks alpha differently, creating the second visible edge.
**Fix**: Tag backgrounds must be solid hex. No rgba.
```css
/* avoid */ .tag { background: rgba(27, 54, 93, 0.18); }
/* use */ .tag { background: var(--tag-bg); }
```
Kami ships two registered tints for this, `--tag-bg` and `--brand-tint`; `references/design.md` «Tags» owns which one to pick. A tint outside those two needs a `tokens.json` entry first, or `scripts/tokens.py` fails the sync guard.
If you genuinely need a texture, `linear-gradient` is safe engineering-wise (the tag rasterizes as one bitmap, no alpha compositing), but at tag size it oversells every time. Solid is the answer.
### 2. (P0) Thin border + radius = double circle
**Symptom**: `border: 0.4pt solid ...` + `border-radius: 2pt` shows two parallel arcs on zoom.
**Root cause**: WeasyPrint strokes border inner and outer paths separately when `< 1pt` + rounded corners - at thin widths they can't overlap.
**Fix (pick one)**:
1. Use background fill instead (preferred, design-consistent)
2. Border ≥ 1pt
3. Drop `border-radius`
### 3. (P1) 2-page hard-limit overflow
For resume, one-pager, and other length-capped docs.
**Common causes**: font fallback, content added, font-size bumped by accident, line-height pushed from 1.4 to 1.6.
**Diagnose**: `pdffonts output.pdf` to verify what actually loaded.
**Fix (priority)**:
1. Cut redundant qualifiers ("deeply researched" -> "researched")
2. Merge related data points in the same section
3. Drop non-essential items whole (not piecemeal)
4. Reduce section spacing (use sparingly - affects global rhythm)
5. Last resort: shrink font by 0.1-0.2pt
**Don't**: cut cover / education / timeline structural blocks; cut emphasis (resume becomes flat).
**5-project high-density layout** (when content legitimately needs 5 projects on page 1): add `class="resume--dense"` to ``. This activates a built-in CSS variant that applies the following adjustments without touching any content:
| Property | Default | Dense |
|---|---|---|
| `body font-size` | 9.2pt | 9pt |
| `.proj-text line-height` | 1.40 | 1.38 (CN) / 1.40 (EN) |
| `.tl-body font-size` | 9pt | 8.5pt (CN only) |
| `.section-title margin-top` | 5mm | 3.5mm |
Apply dense mode only when the 4-project layout already overflows. Do not use it as a default; the visual rhythm is noticeably tighter.
Resume templates use section-title bottom rules and borderless project rows. Do not reintroduce project `border-top` as a density aid; it creates double rules below headings and orphan rules at page breaks.
### 4. (P1) Font fallback causes page count inconsistency
**Symptom**: 2 pages locally, 4 pages in CI / on server.
**Root cause**: font file neither alongside HTML nor system-installed.
**Fix**:
```bash
# Preferred: multi-source download script (retries, size validation).
# Lands fonts in ${XDG_DATA_HOME:-~/.local/share}/fonts/kami (fontconfig-scanned,
# outside the skill dir), then runs fc-cache. A repository checkout first copies
# missing or truncated fonts from root assets/fonts into the skill's ignored assets/fonts;
# downloads are needed only when usable fonts remain missing.
bash scripts/ensure-fonts.sh
# Or put .ttf alongside the HTML
cp TsangerJinKai02-W04.ttf workspace/
# macOS fallback font
brew install --cask font-source-han-serif-sc
# Linux system install
apt install fonts-noto-cjk
mkdir -p ~/.fonts && cp *.ttf ~/.fonts/ && fc-cache -f
```
### 4.1 (P1) Latin-first font stack splits CJK inside inline SVG
**Symptom**: labels inside an embedded SVG render in two typefaces at once,
often mid-word (`提交` in one face, `请` in another). The stack names a CJK
family, and a rendered PDF still shows two.
**Root cause**: for SVG text, a leading Latin serif (`Charter, Georgia, ...`)
ends the CSS stack walk. Characters it has no glyph for go to fontconfig
per glyph rather than continuing down the declared list, so the CJK families
written after it never get their turn and each ideograph lands wherever
fontconfig prefers (`Hiragino-Mincho` for some, `Songti-SC` for others).
**Fix**: in SVG `text` rules, put the CJK families first and let Latin faces
trail. `@font-face` fonts do resolve inside inline SVG, so `TsangerJinKai02`
leading the stack draws both scripts from one family:
```css
/* wrong: CJK after Latin, splits per glyph */
text { font-family: Charter, Georgia, "TsangerJinKai02", "Songti SC", serif; }
/* right: one family draws the whole label */
text { font-family: "TsangerJinKai02", "Source Han Serif SC", "Songti SC", Charter, Georgia, serif; }
```
`--check-fonts` catches this state; a page render at normal size usually does not.
### 5. (P2) CJK and Latin crowding (Chinese mode only)
**Symptom**: "125.4k GitHub Stars" - k and G feel glued.
**Wrong fixes**: hand-added ` ` / `margin-left: 2mm` (misaligns adjacent elements).
**Right fix**: separate spans and let flex `gap` carry the space, never a hand-added ` ` or margin:
```html
125.4kGitHub Stars
```
```css
.metric { display: flex; gap: 6pt; } /* direction: see pitfall #20 */
```
The `gap` is the fix here. Whether the pair sits inline or stacks is a separate call, and pitfall #20 owns it.
### 6. (P2) Full-width vs half-width spaces (Chinese mode)
- **Between Chinese characters**: U+3000 full-width space + `·` + space
- **Between Latin words**: half-width space + `·` + space
- **Mixed**: prefer flex gap, don't hand-type spaces
### 7. (P2) Thousands / percent / arrows - be consistent
**Symptom**: one document mixes thousands separators, spaced percent signs, or
multiple arrow glyphs. `writing.md` "Number formatting" is the source of truth.
Self-check:
```bash
grep -oE '→|⟶|⇒' doc.html | sort | uniq -c
grep -oE '[0-9]{4,}' doc.html | sort -u
```
### 8. (P2) Too much / too little emphasis
**Symptom**: four or five ink-blue runs in one line create visual fatigue, while a
long prose section with none has no scan handle. `writing.md` "Emphasis rhythm" is
the source of truth for density and eligible content.
### 9. (P0) `height: 100vh` doesn't work
**Symptom**: full-bleed cover using `height: 100vh` renders empty.
**Root cause**: viewport units are undefined in WeasyPrint's `@page` context.
**Fix**:
```css
.cover {
min-height: 257mm; /* A4 height 297 - 40mm margins */
display: flex;
flex-direction: column;
justify-content: center;
}
```
### 10. (P1) `break-inside` fails inside flex
**Symptom**: `.card { break-inside: avoid }` still splits across pages.
**Root cause**: WeasyPrint's flex/grid `break-inside` support on direct children is incomplete.
**Fix**: wrap the flex item in an extra block:
```html
...
```
```css
.row { display: flex; }
.card-wrapper { break-inside: avoid; }
```
### 11. (P3) Hide page number on the first page
```css
@page:first {
@top-right { content: ""; }
}
```
### 12. (P2) Printed white margin around the page
**Symptom**: printing produces a white border even though `background` is set.
**Root cause**: default `@page background` only covers the content area, not the margin.
**Fix**:
```css
@page {
size: A4; margin: 20mm;
background: #f5f4ed; /* extends past margins */
}
```
### 13. (P2) Blurry images
**Symptom**: images in PDF look soft.
**Root cause**: WeasyPrint renders at source pixel density. A4 @ 300 dpi = 2480 × 3508 pixels.
**Fix**: source images at 2x or 3x.
### 14. (P0) SVG marker `orient="auto"` ignored
**Symptom**: SVG arrows using `` or `orient="auto-start-reverse"` all point right (the marker's default drawing direction), regardless of the path's tangent angle.
**Root cause**: WeasyPrint's SVG renderer does not support the `orient="auto"` attribute on markers. The marker is always drawn at 0°.
**Fix**: skip `` entirely. Draw each arrowhead as a manual chevron `` at the endpoint, with the direction hardcoded.
```xml
```
Chevron templates (tip at endpoint, 8px arm length):
| Direction | chevron path |
|---|---|
| down | `M (x-8) (y-8) L x y L (x+8) (y-8)` |
| left | `M (x+8) (y-8) L x y L (x+8) (y+8)` |
| up | `M (x-8) (y+8) L x y L (x+8) (y+8)` |
| right | `M (x-8) (y-8) L x y L (x-8) (y+8)` |
### 15. (P1) Slide letter-spacing must be halved
**Symptom**: Slide text looks "scattered" or over-spaced when print letter-spacing values (e.g. `letter-spacing: 8px`) are used directly.
**Root cause**: Print letter-spacing values are tuned for small sizes (8-12pt). At slide sizes (48-64px), the same absolute value gets multiplied out of control.
**Fix**: Slide letter-spacing = print value / 2. Mono fonts are exempt (fixed-width by nature, no extra tracking needed).
```css
/* Print eyebrow */
.eyebrow { letter-spacing: 6px; }
/* Slide eyebrow */
.slide .eyebrow { letter-spacing: 3px; } /* halved */
```
### 16. (P1) Figure SVG `max-height` starves width
**Symptom**: An inline `