- .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
259 lines
6.4 KiB
Go
259 lines
6.4 KiB
Go
// 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)
|
|
}
|