- .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
166 lines
4.1 KiB
Go
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)
|
|
}
|