// 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 [flags] build atlas PNG + metrics JSON fontc render [flags] render a text string to PNG fontc info validate + list glyphs fontc preview draw text in the terminal fontc version Build flags: -o output base name -> .png + .json (default: font file name without extension) Render flags: -o output PNG (default text.png) --scale integer upscale, default 1 --color text color (#RRGGBB, CSS names not supported here), default #FFFFFF Use \n in 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) }