bitmap-font-maker: fontc - .font glyph grids -> atlas PNG + metrics JSON + text rendering

- .font format: glyph sections with #/. grids, proportional widths,
  literal unicode glyph names, spacing/space-width/line-height/baseline
- build (atlas white-on-transparent + JSON metrics), render (text -> PNG
  with \n, scale, color), info, preview (terminal half-blocks)
- example tiny5 font: A-Z, ÅÄÖ, 0-9, punctuation (47 glyphs, 3x5)
- go tests: parsing, errors, atlas metrics, text rendering

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
This commit is contained in:
2026-07-14 07:03:04 +02:00
parent a5b8a249ed
commit 6b10c4a57c
10 changed files with 1557 additions and 0 deletions

258
bitmap-font-maker/main.go Normal file
View File

@@ -0,0 +1,258 @@
// fontc turns .font text files (pixel glyph grids) into font atlases
// (PNG + JSON metrics) and renders text strings to images.
package main
import (
"flag"
"fmt"
"image/color"
"os"
"path/filepath"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/bitmap-font-maker/font"
)
var version = "dev"
const usage = `fontc - bitmap font maker for agents
Usage:
fontc build <file.font> [flags] build atlas PNG + metrics JSON
fontc render <file.font> <text> [flags]
render a text string to PNG
fontc info <file.font> validate + list glyphs
fontc preview <file.font> <text> draw text in the terminal
fontc version
Build flags:
-o <base> output base name -> <base>.png + <base>.json
(default: font file name without extension)
Render flags:
-o <path> output PNG (default text.png)
--scale <n> integer upscale, default 1
--color <c> text color (#RRGGBB, CSS names not supported here), default #FFFFFF
Use \n in <text> for line breaks.
The .font format:
font: tiny5 optional name
spacing: 1 px between glyphs (default 1)
space-width: 3 advance of ' ' (default: width of '0')
line-height: 7 default: glyph height + 1
baseline: 5 default: glyph height
glyph A:
.#.
#.#
###
#.#
#.#
'#' = pixel on, '.' = off. All glyphs share one height; widths may
differ (proportional). The atlas draws glyphs in white so engines can
tint them; metrics JSON carries x/y/w/h/advance per glyph.
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "build":
cmdBuild(os.Args[2:])
case "render":
cmdRender(os.Args[2:])
case "info":
cmdInfo(os.Args[2:])
case "preview":
cmdPreview(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("fontc", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'fontc help'", os.Args[1])
}
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if !strings.Contains(name, "=") {
f := fs.Lookup(name)
isBool := false
if f != nil {
if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bv.IsBoolFlag() {
isBool = true
}
}
if !isBool && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
} else {
pos = append(pos, a)
}
}
fs.Parse(append(flags, pos...))
}
func cmdBuild(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
out := fs.String("o", "", "output base name")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("build takes exactly one .font file")
}
ft, err := font.ParseFile(fs.Arg(0))
if err != nil {
die("%v", err)
}
base := *out
if base == "" {
base = strings.TrimSuffix(fs.Arg(0), filepath.Ext(fs.Arg(0)))
}
base = strings.TrimSuffix(base, ".png")
img, metrics := ft.BuildAtlas(filepath.Base(base) + ".png")
pngF, err := os.Create(base + ".png")
if err != nil {
die("%v", err)
}
defer pngF.Close()
if err := font.WritePNG(pngF, img); err != nil {
die("%v", err)
}
jsonF, err := os.Create(base + ".json")
if err != nil {
die("%v", err)
}
defer jsonF.Close()
if err := font.WriteMetrics(jsonF, metrics); err != nil {
die("%v", err)
}
b := img.Bounds()
fmt.Printf("%s.png (%dx%d atlas, %d glyphs)\n%s.json\n", base, b.Dx(), b.Dy(), len(ft.Order), base)
}
func parseHexColor(s string) (r, g, b uint8, err error) {
s = strings.TrimPrefix(strings.TrimSpace(s), "#")
if len(s) != 6 {
return 0, 0, 0, fmt.Errorf("color must be #RRGGBB, got %q", s)
}
var v [3]uint8
for i := 0; i < 3; i++ {
var n int
if _, err := fmt.Sscanf(s[i*2:i*2+2], "%02x", &n); err != nil {
return 0, 0, 0, fmt.Errorf("bad hex color %q", s)
}
v[i] = uint8(n)
}
return v[0], v[1], v[2], nil
}
func cmdRender(args []string) {
fs := flag.NewFlagSet("render", flag.ExitOnError)
out := fs.String("o", "text.png", "output PNG")
scale := fs.Int("scale", 1, "integer upscale")
col := fs.String("color", "#FFFFFF", "text color #RRGGBB")
parseInterspersed(fs, args)
if fs.NArg() != 2 {
die("render takes a .font file and a text string")
}
ft, err := font.ParseFile(fs.Arg(0))
if err != nil {
die("%v", err)
}
r, g, b, err := parseHexColor(*col)
if err != nil {
die("%v", err)
}
img, warnings := ft.RenderText(fs.Arg(1), *scale, rgba(r, g, b))
for _, w := range warnings {
fmt.Fprintln(os.Stderr, "fontc:", w)
}
f, err := os.Create(*out)
if err != nil {
die("%v", err)
}
defer f.Close()
if err := font.WritePNG(f, img); err != nil {
die("%v", err)
}
bd := img.Bounds()
fmt.Printf("%s (%dx%d px)\n", *out, bd.Dx(), bd.Dy())
}
func cmdInfo(args []string) {
if len(args) != 1 {
die("info takes exactly one .font file")
}
ft, err := font.ParseFile(args[0])
if err != nil {
die("%v", err)
}
fmt.Printf("font: %s\n", ft.Name)
fmt.Printf("height: %d px\n", ft.Height)
fmt.Printf("line-height: %d px\n", ft.LineHeight)
fmt.Printf("baseline: %d\n", ft.Baseline)
fmt.Printf("spacing: %d px\n", ft.Spacing)
fmt.Printf("space-width: %d px\n", ft.SpaceWidth)
fmt.Printf("glyphs: %d\n", len(ft.Order))
var chars []string
for _, r := range ft.SortedChars() {
chars = append(chars, string(r))
}
fmt.Printf(" %s\n", strings.Join(chars, " "))
fmt.Println("valid: yes")
}
func cmdPreview(args []string) {
if len(args) != 2 {
die("preview takes a .font file and a text string")
}
ft, err := font.ParseFile(args[0])
if err != nil {
die("%v", err)
}
img, warnings := ft.RenderText(args[1], 1, rgba(255, 255, 255))
for _, w := range warnings {
fmt.Fprintln(os.Stderr, "fontc:", w)
}
b := img.Bounds()
for y := 0; y < b.Dy(); y += 2 {
var sb strings.Builder
for x := 0; x < b.Dx(); x++ {
top := img.NRGBAAt(x, y).A >= 128
bot := y+1 < b.Dy() && img.NRGBAAt(x, y+1).A >= 128
switch {
case top && bot:
sb.WriteRune('█')
case top:
sb.WriteRune('▀')
case bot:
sb.WriteRune('▄')
default:
sb.WriteByte(' ')
}
}
fmt.Println(sb.String())
}
}
func rgba(r, g, b uint8) color.NRGBA {
return color.NRGBA{R: r, G: g, B: b, A: 255}
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, "fontc: "+format+"\n", a...)
os.Exit(1)
}