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:
258
bitmap-font-maker/main.go
Normal file
258
bitmap-font-maker/main.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user