# Production (Build · Verify · Troubleshoot)
The engineering runbook for kami: from HTML / Python templates to PDF / PPTX deliverables. Four parts: **HTML -> PDF** · **Python -> PPTX** · **Verify & Debug** · **16 known pitfalls**.
---
## 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 it overflows: lower `font-size` first, then tighten margins, then cut content.
**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 exception to design.md invariant #1 ("never pure white"), 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
```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)` | | |
| `::before` / `::after` | | |
### 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 (1:1 with design.md)
```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(0x5e, 0x5d, 0x59)
STONE = RGBColor(0x87, 0x86, 0x7f)
BORDER_WARM = RGBColor(0xe8, 0xe6, 0xdc)
TAG_BG = RGBColor(0xee, 0xf2, 0xf7)
```
### 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**: don't. Parchment is a print aesthetic, not a SaaS demo. At most `fade`
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`
### 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 |
|---|---|
| CN theme | `assets/templates/marp/slides-marp.css` (theme name: `kami`) |
| EN theme | `assets/templates/marp/slides-marp-en.css` (theme name: `kami-en`) |
| CN 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
pdffonts output.pdf
```
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 12 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
```
### Page overflow (constrained templates)
When a constrained template (one-pager, letter, resume, and their variants) runs over its page ceiling, fix it by editing content, not by shrinking type: cut or merge body copy until it fits, since tiny font / line-height / margin changes break the layout (see High-Risk Pitfalls). Then verify:
```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 · 22 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(201, 100, 66, 0.18); }
/* use */ .tag { background: #E4ECF5; }
```
**rgba -> solid conversion** (parchment `#f5f4ed` base + ink-blue `#1B365D`):
| rgba alpha | Solid hex |
|---|---|
| 0.08 | `#EEF2F7` |
| 0.14 | `#E4ECF5` |
| **0.18** | **`#E4ECF5`** ← default |
| 0.22 | `#D0DCE9` |
| 0.30 | `#D6E1EE` |
Formula: `solid_channel = base + (foreground - base) × alpha`. Different base colors (e.g. ivory) need re-computing.
**Want "breathing" texture?** Use `linear-gradient` - the whole tag rasterizes as one bitmap, no alpha compositing:
```css
.tag { background: linear-gradient(to right, #D6E1EE, #E4ECF5 70%, #EEF2F7); }
```
**Aesthetic warning**: gradients work engineering-wise but usually oversell the tag. Priority order: lightest solid (`#EEF2F7`) > standard solid (`#E4ECF5`) > gradient (rarely). If the reader's eye lands on the tag background shape before the text inside - you went too far.
### 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. Inside a repo checkout it is a
# no-op because the committed TTFs already satisfy the templates' relative path.
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
```
### 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 with flex gap:
```html
125.4kGitHub Stars
```
```css
.metric { display: flex; align-items: baseline; gap: 6pt; }
```
### 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
| Use | Avoid |
|---|---|
| `5,000+` | `5000+` |
| `90%` | `90 %` (pre-space) |
| `->` | `->` / `->` |
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
- Four or five ink-blue runs in one line -> visual fatigue, no focal point
- Entire section with none -> flat, no scan handles
**Rule**: ≤ 2 emphases per line, ≥ 1 per section, only **quantifiable numbers or distinctive phrases** get highlighted - never adjectives.
Healthy ratio: one emphasis per 80-150 words.
### 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. (P3) Verification loop (catch-all)
```bash
python3 -c "from weasyprint import HTML; HTML('doc.html').write_pdf('out.pdf')"
python3 -c "from pypdf import PdfReader; print(len(PdfReader('out.pdf').pages))"
pdftoppm -png -r 300 out.pdf inspect # when in doubt
```
**Not verified = not done.**
### 15. (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)` |
### 16. (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 */
```
### 17. (P1) Figure SVG `max-height` starves width
**Symptom**: An inline `