Files

62 lines
6.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Standalone tool that produces bitmap fonts (`.gif` + `.fnt`) for the "Projecte 2026" game engine.
- [`font_gen.py`](font_gen.py) — pure rasterising core + CLI. The two public functions are [`build_font_bitmap`](font_gen.py) (TTF → in-memory `FontBitmapResult`, no disk I/O) and [`save_font_files`](font_gen.py) (writes `.gif` + `.fnt`). The CLI wrappers `render_font` (TTF) and `render_gif_font` (GIF) layer logging on top.
- [`font_gen_gui.py`](font_gen_gui.py) — Tkinter GUI that imports the core, runs `build_font_bitmap` on every input change (debounced 200 ms) for live preview, and calls `save_font_files` on GENERAR.
The two files are independent: copy them anywhere and run. There is no install step, no test suite, no linter config.
## Running
```sh
pip install Pillow # only mandatory dependency
python3 font_gen.py # GUI (no args)
python3 font_gen.py --ttf foo.ttf --size 8 --output foo # CLI from TTF
python3 font_gen.py --gif foo.gif --output foo --columns 16 \
--box-width 10 --box-height 7 --cell-spacing 1 # CLI from existing GIF
```
`magick` (ImageMagick) is invoked optionally inside `save_font_files` to clamp the GIF palette to 2 colours; if missing it is silently skipped.
Default output directory is **next to the script** (`os.path.dirname(__file__)`). The CLI accepts `--dir` to override; the GUI always writes alongside the script.
## Output contract (don't break these)
These properties are consumed by the engine and must hold for any change to the rasteriser:
- **Indexed GIF, palette index 0 = background, index 1 = glyph.** Compatible with `SurfaceSprite::render(1, color)` in the engine. The post-process `magick … -colors 2 GIF87:` enforces a 2-colour palette when available.
- **Character set order is fixed.** [`ALL_CHARS`](font_gen.py) = ASCII 32126 followed by the ES/CA/VA extension list, in that exact sequence. `.fnt` rows and GIF cells are addressed by this order; reordering breaks every existing `.fnt` consumer.
- **`.fnt` format**: header keys (`box_width`, `box_height`, `columns`, optional `cell_spacing`, optional `row_spacing` only when it differs from `cell_spacing`), blank line, then `<codepoint_decimal> <visual_width> # <name>` per supported char. See [`_write_fnt`](font_gen.py).
- **Visual width is measured from pixels**, not from the font's typographic advance — last column containing an opaque pixel + 1. `getlength()` is only used to size the canvas.
## TTF rendering: the non-obvious bits
- **`.notdef` detection**: some TTFs return a substitute glyph (with positive advance and a valid bbox) for missing codepoints, which would falsely pass the `getlength(ch) >= 1` check. The tool renders `U+FFFE` as a reference and rejects any char whose pixels match it. See `_is_notdef` inside `build_font_bitmap`.
- **Fallback table** (`CHAR_FALLBACKS`) maps accented ES/CA/VA chars to ASCII equivalents (`À→A`, `ñ→n`, …). When a TTF lacks the accented glyph, the fallback is drawn into the cell **but the original codepoint is recorded in the `.fnt`**, so localised text still resolves at runtime.
- **Vertical placement**: `draw.text((-bbox[0], y_offset), …)` where `y_offset = -min(getbbox.top)` over all chars to draw, so the topmost pixel of any glyph (including the diacritic on À/É/Ç) lands at y=0 in the scratch. Don't revert this to `cap_tops` average — that variant clips accents above the cap line. The `.notdef` detection uses a separate tentative `notdef_scratch_h` / `notdef_y_offset` (consistency between reference and per-char render is all that matters there); the real `scratch_h` and `y_offset` are recomputed from `getbbox` spans after classification.
- **`box_width` auto-mode** uses `max(getlength)` across rendered chars; override with `--box-width` for pixel-exact bitmap strikes.
- **`box_height` auto-mode is pixel-tight**, *not* `ascent + descent`. Glyphs are rendered to a scratch canvas sized to the bbox span, then `box_height = max_opaque_y - min_opaque_y + 1` across all rendered glyphs (and `y_crop = min_opaque_y` is applied during the second pass). This trims unused leading/descent space — for many bitmap fonts the typographic line height reserves rows that no glyph actually paints. Override with `--box-height` to disable.
## GIF mode quirks (CLI-only)
- `cell_spacing` is both the inter-column gap **and** the top/left border. Origin of cell `(col, row)` is `(col*(bw+cs)+cs, row*(bh+rs)+cs)` — see `render_gif_font`.
- Empty cells (all index 0) are marked unsupported and dropped from the `.fnt`, except whitespace chars which get a default width of `box_width // 2`.
- The source GIF is `shutil.copy2`'d unchanged to the output dir; only the `.fnt` is regenerated.
- This mode is intentionally **not exposed in the GUI** — it's a maintenance path for re-measuring an existing bitmap font, not a primary workflow.
## GUI specifics
- **Live preview**: every change to TTF / size / advanced options schedules `_update_preview` via `self.after(200, …)` (debounced). Render runs on the main thread — for typical pixel-font sizes it is sub-second.
- **`ImageTk.PhotoImage` lifetime**: stored on `self._preview_img_tk` to prevent Tk from garbage-collecting the image and showing a blank label. If you refactor preview rendering, keep this reference alive.
- **Preview palette is for display only**: index 0 is recoloured white and index 1 black so the user can read the glyphs on screen. The image saved to disk uses the original palette (index 1 = white) — `_render_preview` operates on a copy.
- **Preview scale**: nearest-neighbor upscale by `min(8, PREVIEW_MAX_W // w, PREVIEW_MAX_H // h)`. Pixel-perfect, never blurred.
## Entry point dispatch
In `font_gen.py` `__main__`: `len(sys.argv) == 1` launches the GUI directly (no argparse). Anything else goes through `main()`, which honours `--gui` to launch the GUI explicitly. The GUI is imported lazily inside `_launch_gui()` so CLI-only invocations don't require tkinter.