Files
claude 6b10c4a57c 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
2026-07-14 07:03:04 +02:00

166 lines
4.1 KiB
Go

package font
import (
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"io"
"strings"
)
// AtlasGlyph is one glyph's placement in the atlas.
type AtlasGlyph struct {
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
Advance int `json:"advance"` // cursor movement after drawing: W + spacing
}
// Metrics is the JSON sidecar written next to the atlas PNG.
type Metrics struct {
Name string `json:"name"`
Atlas string `json:"atlas"`
Height int `json:"height"`
LineHeight int `json:"lineHeight"`
Baseline int `json:"baseline"`
Spacing int `json:"spacing"`
SpaceWidth int `json:"spaceWidth"`
Glyphs map[string]AtlasGlyph `json:"glyphs"`
}
// BuildAtlas packs all glyphs into a grid atlas image (white pixels on
// transparency, so game engines can tint) and returns the metrics.
// atlasName is stored in the metrics so loaders can find the image.
func (ft *Font) BuildAtlas(atlasName string) (*image.NRGBA, *Metrics) {
n := len(ft.Order)
cols := 1
for cols*cols < n {
cols++
}
rows := (n + cols - 1) / cols
cellW := ft.MaxGlyphWidth() + 1 // 1 px padding against bleed
cellH := ft.Height + 1
img := image.NewNRGBA(image.Rect(0, 0, cols*cellW, rows*cellH))
white := color.NRGBA{255, 255, 255, 255}
m := &Metrics{
Name: ft.Name,
Atlas: atlasName,
Height: ft.Height,
LineHeight: ft.LineHeight,
Baseline: ft.Baseline,
Spacing: ft.Spacing,
SpaceWidth: ft.SpaceWidth,
Glyphs: map[string]AtlasGlyph{},
}
for i, r := range ft.Order {
g := ft.Glyphs[r]
x0 := (i % cols) * cellW
y0 := (i / cols) * cellH
drawGlyph(img, g, x0, y0, white)
m.Glyphs[string(r)] = AtlasGlyph{X: x0, Y: y0, W: g.W, H: g.H, Advance: g.W + ft.Spacing}
}
return img, m
}
func drawGlyph(img *image.NRGBA, g *Glyph, x0, y0 int, c color.NRGBA) {
for y, row := range g.Rows {
for x, on := range row {
if on {
img.SetNRGBA(x0+x, y0+y, c)
}
}
}
}
// RenderText draws text into a new image using the font. scale is an
// integer upscale factor; unknown characters are skipped with a warning
// returned. "\n" (literal backslash-n) and real newlines both break lines.
func (ft *Font) RenderText(text string, scale int, fg color.NRGBA) (*image.NRGBA, []string) {
if scale < 1 {
scale = 1
}
text = strings.ReplaceAll(text, `\n`, "\n")
lines := strings.Split(text, "\n")
var warnings []string
width := 0
for _, line := range lines {
if w := ft.lineWidth(line); w > width {
width = w
}
}
if width == 0 {
width = 1
}
height := ft.LineHeight*(len(lines)-1) + ft.Height
small := image.NewNRGBA(image.Rect(0, 0, width, height))
for li, line := range lines {
x := 0
y := li * ft.LineHeight
for _, r := range line {
if r == ' ' {
x += ft.SpaceWidth + ft.Spacing
continue
}
g, ok := ft.Glyphs[r]
if !ok {
warnings = append(warnings, fmt.Sprintf("no glyph for %q — skipped", string(r)))
x += ft.SpaceWidth + ft.Spacing
continue
}
drawGlyph(small, g, x, y, fg)
x += g.W + ft.Spacing
}
}
if scale == 1 {
return small, warnings
}
b := small.Bounds()
big := image.NewNRGBA(image.Rect(0, 0, b.Dx()*scale, b.Dy()*scale))
for y := 0; y < b.Dy(); y++ {
for x := 0; x < b.Dx(); x++ {
c := small.NRGBAAt(x, y)
for dy := 0; dy < scale; dy++ {
for dx := 0; dx < scale; dx++ {
big.SetNRGBA(x*scale+dx, y*scale+dy, c)
}
}
}
}
return big, warnings
}
func (ft *Font) lineWidth(line string) int {
x := 0
for _, r := range line {
if r == ' ' {
x += ft.SpaceWidth + ft.Spacing
continue
}
if g, ok := ft.Glyphs[r]; ok {
x += g.W + ft.Spacing
} else {
x += ft.SpaceWidth + ft.Spacing
}
}
if x > 0 {
x -= ft.Spacing // no trailing spacing
}
return x
}
// WritePNG encodes an image as PNG.
func WritePNG(w io.Writer, img image.Image) error { return png.Encode(w, img) }
// WriteMetrics encodes metrics as indented JSON.
func WriteMetrics(w io.Writer, m *Metrics) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(m)
}