diff --git a/bitmap-font-maker/README.md b/bitmap-font-maker/README.md new file mode 100644 index 0000000..4ef1a8b --- /dev/null +++ b/bitmap-font-maker/README.md @@ -0,0 +1,75 @@ +# bitmap-font-maker (`fontc`) + +Turns `.font` text files — pixel glyph grids an agent can read and edit +directly — into **font atlases (PNG + JSON metrics)** and renders text +strings to PNG. Proportional widths, unicode glyph names (ÅÄÖ works). +Go, zero dependencies, single static binary. + +## Build + +```bash +# Arch/Garuda: sudo pacman -S go +cd bitmap-font-maker +go build -o build/fontc . # or the VS Code task "build bitmap-font-maker" +go test ./... +``` + +## The `.font` format + +``` +# comment +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: + .#. + #.# + ### + #.# + #.# + +glyph :: the char between "glyph " and ":" is literal + . + # + . + # + . +``` + +- `#` = pixel on, `.` = off. +- **All glyphs share one height; widths may differ** (proportional fonts — + `M` can be 5 px wide while `.` is 1 px). +- Max glyph size 64x64. + +## Commands + +```bash +fontc build tiny5.font -o out/tiny5 # -> tiny5.png (atlas) + tiny5.json (metrics) +fontc render tiny5.font 'HEJ!\nRAD 2' --scale 4 --color '#FFD700' -o title.png +fontc info tiny5.font # validate + list glyphs +fontc preview tiny5.font 'HELLO' # draw in the terminal +``` + +## Atlas + metrics + +The atlas draws glyphs **white on transparent** so game engines can tint +them. The JSON carries everything a loader needs: + +```json +{ + "name": "tiny5", "atlas": "tiny5.png", + "height": 5, "lineHeight": 6, "baseline": 5, + "spacing": 1, "spaceWidth": 3, + "glyphs": { "A": {"x": 0, "y": 0, "w": 3, "h": 5, "advance": 4}, ... } +} +``` + +Drawing text in a game: blit `glyphs[c]` from the atlas, advance the +cursor by `advance`; spaces advance `spaceWidth + spacing`; new lines +step `lineHeight`. + +[`examples/tiny5.font`](examples/tiny5.font) is a complete 3x5 font: +A-Z, ÅÄÖ, 0-9 and punctuation. diff --git a/bitmap-font-maker/examples/out/hej.png b/bitmap-font-maker/examples/out/hej.png new file mode 100644 index 0000000..e158337 Binary files /dev/null and b/bitmap-font-maker/examples/out/hej.png differ diff --git a/bitmap-font-maker/examples/out/tiny5.json b/bitmap-font-maker/examples/out/tiny5.json new file mode 100644 index 0000000..40763b8 --- /dev/null +++ b/bitmap-font-maker/examples/out/tiny5.json @@ -0,0 +1,340 @@ +{ + "name": "tiny5", + "atlas": "tiny5.png", + "height": 5, + "lineHeight": 6, + "baseline": 5, + "spacing": 1, + "spaceWidth": 3, + "glyphs": { + "!": { + "x": 36, + "y": 30, + "w": 1, + "h": 5, + "advance": 2 + }, + "'": { + "x": 24, + "y": 36, + "w": 1, + "h": 5, + "advance": 2 + }, + "+": { + "x": 18, + "y": 36, + "w": 3, + "h": 5, + "advance": 4 + }, + ",": { + "x": 30, + "y": 30, + "w": 2, + "h": 5, + "advance": 3 + }, + "-": { + "x": 12, + "y": 36, + "w": 3, + "h": 5, + "advance": 4 + }, + ".": { + "x": 24, + "y": 30, + "w": 1, + "h": 5, + "advance": 2 + }, + "0": { + "x": 6, + "y": 24, + "w": 3, + "h": 5, + "advance": 4 + }, + "1": { + "x": 12, + "y": 24, + "w": 3, + "h": 5, + "advance": 4 + }, + "2": { + "x": 18, + "y": 24, + "w": 3, + "h": 5, + "advance": 4 + }, + "3": { + "x": 24, + "y": 24, + "w": 3, + "h": 5, + "advance": 4 + }, + "4": { + "x": 30, + "y": 24, + "w": 3, + "h": 5, + "advance": 4 + }, + "5": { + "x": 36, + "y": 24, + "w": 3, + "h": 5, + "advance": 4 + }, + "6": { + "x": 0, + "y": 30, + "w": 3, + "h": 5, + "advance": 4 + }, + "7": { + "x": 6, + "y": 30, + "w": 3, + "h": 5, + "advance": 4 + }, + "8": { + "x": 12, + "y": 30, + "w": 3, + "h": 5, + "advance": 4 + }, + "9": { + "x": 18, + "y": 30, + "w": 3, + "h": 5, + "advance": 4 + }, + ":": { + "x": 6, + "y": 36, + "w": 1, + "h": 5, + "advance": 2 + }, + "?": { + "x": 0, + "y": 36, + "w": 3, + "h": 5, + "advance": 4 + }, + "A": { + "x": 0, + "y": 0, + "w": 3, + "h": 5, + "advance": 4 + }, + "B": { + "x": 6, + "y": 0, + "w": 3, + "h": 5, + "advance": 4 + }, + "C": { + "x": 12, + "y": 0, + "w": 3, + "h": 5, + "advance": 4 + }, + "D": { + "x": 18, + "y": 0, + "w": 3, + "h": 5, + "advance": 4 + }, + "E": { + "x": 24, + "y": 0, + "w": 3, + "h": 5, + "advance": 4 + }, + "F": { + "x": 30, + "y": 0, + "w": 3, + "h": 5, + "advance": 4 + }, + "G": { + "x": 36, + "y": 0, + "w": 4, + "h": 5, + "advance": 5 + }, + "H": { + "x": 0, + "y": 6, + "w": 3, + "h": 5, + "advance": 4 + }, + "I": { + "x": 6, + "y": 6, + "w": 3, + "h": 5, + "advance": 4 + }, + "J": { + "x": 12, + "y": 6, + "w": 3, + "h": 5, + "advance": 4 + }, + "K": { + "x": 18, + "y": 6, + "w": 3, + "h": 5, + "advance": 4 + }, + "L": { + "x": 24, + "y": 6, + "w": 3, + "h": 5, + "advance": 4 + }, + "M": { + "x": 30, + "y": 6, + "w": 5, + "h": 5, + "advance": 6 + }, + "N": { + "x": 36, + "y": 6, + "w": 4, + "h": 5, + "advance": 5 + }, + "O": { + "x": 0, + "y": 12, + "w": 3, + "h": 5, + "advance": 4 + }, + "P": { + "x": 6, + "y": 12, + "w": 3, + "h": 5, + "advance": 4 + }, + "Q": { + "x": 12, + "y": 12, + "w": 4, + "h": 5, + "advance": 5 + }, + "R": { + "x": 18, + "y": 12, + "w": 3, + "h": 5, + "advance": 4 + }, + "S": { + "x": 24, + "y": 12, + "w": 3, + "h": 5, + "advance": 4 + }, + "T": { + "x": 30, + "y": 12, + "w": 3, + "h": 5, + "advance": 4 + }, + "U": { + "x": 36, + "y": 12, + "w": 3, + "h": 5, + "advance": 4 + }, + "V": { + "x": 0, + "y": 18, + "w": 3, + "h": 5, + "advance": 4 + }, + "W": { + "x": 6, + "y": 18, + "w": 5, + "h": 5, + "advance": 6 + }, + "X": { + "x": 12, + "y": 18, + "w": 3, + "h": 5, + "advance": 4 + }, + "Y": { + "x": 18, + "y": 18, + "w": 3, + "h": 5, + "advance": 4 + }, + "Z": { + "x": 24, + "y": 18, + "w": 3, + "h": 5, + "advance": 4 + }, + "Ä": { + "x": 36, + "y": 18, + "w": 3, + "h": 5, + "advance": 4 + }, + "Å": { + "x": 30, + "y": 18, + "w": 3, + "h": 5, + "advance": 4 + }, + "Ö": { + "x": 0, + "y": 24, + "w": 3, + "h": 5, + "advance": 4 + } + } +} diff --git a/bitmap-font-maker/examples/out/tiny5.png b/bitmap-font-maker/examples/out/tiny5.png new file mode 100644 index 0000000..321d906 Binary files /dev/null and b/bitmap-font-maker/examples/out/tiny5.png differ diff --git a/bitmap-font-maker/examples/tiny5.font b/bitmap-font-maker/examples/tiny5.font new file mode 100644 index 0000000..f96de2f --- /dev/null +++ b/bitmap-font-maker/examples/tiny5.font @@ -0,0 +1,334 @@ +# tiny5 - a 3x5 pixel font (uppercase + digits + Swedish ÅÄÖ). +# Widths vary: M/W are 5 px, punctuation as narrow as 1 px. +font: tiny5 +spacing: 1 +space-width: 3 + +glyph A: +.#. +#.# +### +#.# +#.# + +glyph B: +##. +#.# +##. +#.# +##. + +glyph C: +.## +#.. +#.. +#.. +.## + +glyph D: +##. +#.# +#.# +#.# +##. + +glyph E: +### +#.. +##. +#.. +### + +glyph F: +### +#.. +##. +#.. +#.. + +glyph G: +.### +#... +#.## +#..# +.##. + +glyph H: +#.# +#.# +### +#.# +#.# + +glyph I: +### +.#. +.#. +.#. +### + +glyph J: +..# +..# +..# +#.# +.#. + +glyph K: +#.# +#.# +##. +#.# +#.# + +glyph L: +#.. +#.. +#.. +#.. +### + +glyph M: +#...# +##.## +#.#.# +#...# +#...# + +glyph N: +#..# +##.# +#.## +#..# +#..# + +glyph O: +.#. +#.# +#.# +#.# +.#. + +glyph P: +##. +#.# +##. +#.. +#.. + +glyph Q: +.##. +#..# +#..# +#.#. +.#.# + +glyph R: +##. +#.# +##. +#.# +#.# + +glyph S: +.## +#.. +.#. +..# +##. + +glyph T: +### +.#. +.#. +.#. +.#. + +glyph U: +#.# +#.# +#.# +#.# +### + +glyph V: +#.# +#.# +#.# +#.# +.#. + +glyph W: +#...# +#...# +#.#.# +##.## +#...# + +glyph X: +#.# +#.# +.#. +#.# +#.# + +glyph Y: +#.# +#.# +.#. +.#. +.#. + +glyph Z: +### +..# +.#. +#.. +### + +glyph Å: +.#. +.#. +#.# +### +#.# + +glyph Ä: +#.# +.#. +#.# +### +#.# + +glyph Ö: +#.# +.#. +#.# +#.# +.#. + +glyph 0: +### +#.# +#.# +#.# +### + +glyph 1: +.#. +##. +.#. +.#. +### + +glyph 2: +##. +..# +.#. +#.. +### + +glyph 3: +### +..# +.## +..# +### + +glyph 4: +#.# +#.# +### +..# +..# + +glyph 5: +### +#.. +##. +..# +##. + +glyph 6: +.## +#.. +### +#.# +### + +glyph 7: +### +..# +.#. +.#. +.#. + +glyph 8: +### +#.# +### +#.# +### + +glyph 9: +### +#.# +### +..# +##. + +glyph .: +. +. +. +. +# + +glyph ,: +.. +.. +.. +.# +#. + +glyph !: +# +# +# +. +# + +glyph ?: +### +..# +.#. +... +.#. + +glyph :: +. +# +. +# +. + +glyph -: +... +... +### +... +... + +glyph +: +... +.#. +### +.#. +... + +glyph ': +# +# +. +. +. diff --git a/bitmap-font-maker/font/font.go b/bitmap-font-maker/font/font.go new file mode 100644 index 0000000..da46daf --- /dev/null +++ b/bitmap-font-maker/font/font.go @@ -0,0 +1,238 @@ +// Package font parses .font text files and renders bitmap fonts to +// atlases (PNG + JSON metrics) and text images. +package font + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// MaxGlyphSize bounds glyph width/height in pixels. +const MaxGlyphSize = 64 + +// Glyph is one character's bitmap: rows of booleans (true = pixel on). +// All glyphs in a font share the same height; widths vary +// (proportional fonts). +type Glyph struct { + Char rune + W, H int + Rows [][]bool +} + +// Font is a parsed .font file. +type Font struct { + Name string + Height int // glyph height, uniform across the font + LineHeight int // suggested distance between text baselines + Baseline int // rows from glyph top to the baseline + Spacing int // horizontal px between glyphs + SpaceWidth int // advance of ' ' + Glyphs map[rune]*Glyph + Order []rune // file order, for stable atlas layout +} + +// ParseFile reads a .font file; the font name defaults to the file name. +func ParseFile(path string) (*Font, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + ft, err := Parse(f) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if ft.Name == "" { + ft.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } + return ft, nil +} + +// Parse reads the .font text format: +// +// # comment +// font: tiny5 optional name +// spacing: 1 px between glyphs (default 1) +// space-width: 3 advance of ' ' (default: width of '0' or 3) +// line-height: 7 default: glyph height + 1 +// baseline: 5 default: glyph height +// +// glyph A: +// .#. +// #.# +// ### +// #.# +// #.# +// +// Glyph grids use '#' for on and '.' for off. Every glyph must have the +// same height; widths may differ. The char between "glyph " and the +// trailing ':' is taken literally (one character, e.g. "glyph ::"). +func Parse(r io.Reader) (*Font, error) { + ft := &Font{ + Spacing: 1, + SpaceWidth: -1, // resolved in validate + LineHeight: -1, + Baseline: -1, + Glyphs: map[rune]*Glyph{}, + } + var cur *Glyph + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + lineNo := 0 + flush := func() error { + if cur == nil { + return nil + } + if len(cur.Rows) == 0 { + return fmt.Errorf("glyph %q has no grid rows", string(cur.Char)) + } + cur.H = len(cur.Rows) + cur.W = len(cur.Rows[0]) + for i, row := range cur.Rows { + if len(row) != cur.W { + return fmt.Errorf("glyph %q row %d is %d px wide, expected %d", string(cur.Char), i+1, len(row), cur.W) + } + } + if cur.W > MaxGlyphSize || cur.H > MaxGlyphSize { + return fmt.Errorf("glyph %q is %dx%d; the maximum is %dx%d", string(cur.Char), cur.W, cur.H, MaxGlyphSize, MaxGlyphSize) + } + if _, dup := ft.Glyphs[cur.Char]; dup { + return fmt.Errorf("glyph %q defined twice", string(cur.Char)) + } + ft.Glyphs[cur.Char] = cur + ft.Order = append(ft.Order, cur.Char) + cur = nil + return nil + } + for sc.Scan() { + lineNo++ + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + // '#' starts a comment — except inside a glyph where a line of + // only '#'/'.' is a grid row. + if strings.HasPrefix(line, "#") && !(cur != nil && isGridLine(line)) { + continue + } + if strings.HasPrefix(strings.ToLower(line), "glyph ") && strings.HasSuffix(line, ":") { + if err := flush(); err != nil { + return nil, fmt.Errorf("line %d: %w", lineNo, err) + } + name := strings.TrimSuffix(line[len("glyph "):], ":") + runes := []rune(name) + if len(runes) != 1 { + return nil, fmt.Errorf("line %d: glyph name %q must be exactly one character", lineNo, name) + } + cur = &Glyph{Char: runes[0]} + continue + } + if cur != nil && isGridLine(line) { + row := make([]bool, 0, len(line)) + for _, r := range line { + row = append(row, r == '#') + } + cur.Rows = append(cur.Rows, row) + continue + } + // header key: value + if i := strings.Index(line, ":"); i > 0 && cur == nil { + key := strings.ToLower(strings.TrimSpace(line[:i])) + val := strings.TrimSpace(line[i+1:]) + var err error + switch key { + case "font": + ft.Name = val + case "spacing": + ft.Spacing, err = strconv.Atoi(val) + case "space-width": + ft.SpaceWidth, err = strconv.Atoi(val) + case "line-height": + ft.LineHeight, err = strconv.Atoi(val) + case "baseline": + ft.Baseline, err = strconv.Atoi(val) + default: + return nil, fmt.Errorf("line %d: unknown setting %q", lineNo, key) + } + if err != nil { + return nil, fmt.Errorf("line %d: %s: %v", lineNo, key, err) + } + continue + } + return nil, fmt.Errorf("line %d: unexpected %q (want 'key: value', 'glyph X:' or a #/. grid row)", lineNo, line) + } + if err := sc.Err(); err != nil { + return nil, err + } + if err := flush(); err != nil { + return nil, err + } + return ft, ft.validate() +} + +// isGridLine reports whether the line consists solely of '#' and '.'. +func isGridLine(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r != '#' && r != '.' { + return false + } + } + return true +} + +func (ft *Font) validate() error { + if len(ft.Order) == 0 { + return fmt.Errorf("font has no glyphs") + } + ft.Height = ft.Glyphs[ft.Order[0]].H + for _, r := range ft.Order { + if g := ft.Glyphs[r]; g.H != ft.Height { + return fmt.Errorf("glyph %q is %d px tall but %q is %d — all glyphs must share one height", + string(r), g.H, string(ft.Order[0]), ft.Height) + } + } + if ft.LineHeight < 0 { + ft.LineHeight = ft.Height + 1 + } + if ft.Baseline < 0 { + ft.Baseline = ft.Height + } + if ft.SpaceWidth < 0 { + if g, ok := ft.Glyphs['0']; ok { + ft.SpaceWidth = g.W + } else { + ft.SpaceWidth = 3 + } + } + if ft.Spacing < 0 { + return fmt.Errorf("spacing must be >= 0") + } + return nil +} + +// MaxGlyphWidth returns the widest glyph's width. +func (ft *Font) MaxGlyphWidth() int { + w := 0 + for _, r := range ft.Order { + if g := ft.Glyphs[r]; g.W > w { + w = g.W + } + } + return w +} + +// SortedChars returns the glyph chars sorted by codepoint (JSON stability). +func (ft *Font) SortedChars() []rune { + out := append([]rune(nil), ft.Order...) + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} diff --git a/bitmap-font-maker/font/font_test.go b/bitmap-font-maker/font/font_test.go new file mode 100644 index 0000000..9913ad6 --- /dev/null +++ b/bitmap-font-maker/font/font_test.go @@ -0,0 +1,144 @@ +package font + +import ( + "bytes" + "encoding/json" + "image/color" + "strings" + "testing" +) + +const tiny = ` +font: t +spacing: 1 +space-width: 2 + +glyph A: +.#. +#.# +### + +glyph I: +# +# +# +` + +func parseOK(t *testing.T, src string) *Font { + t.Helper() + ft, err := Parse(strings.NewReader(src)) + if err != nil { + t.Fatalf("parse: %v", err) + } + return ft +} + +func TestParseBasics(t *testing.T) { + ft := parseOK(t, tiny) + if ft.Name != "t" || ft.Height != 3 || len(ft.Order) != 2 { + t.Errorf("name=%q height=%d glyphs=%d", ft.Name, ft.Height, len(ft.Order)) + } + a := ft.Glyphs['A'] + if a.W != 3 || a.H != 3 { + t.Errorf("A is %dx%d, want 3x3", a.W, a.H) + } + if !a.Rows[1][0] || a.Rows[0][0] { + t.Error("A pixel pattern wrong") + } + i := ft.Glyphs['I'] + if i.W != 1 { + t.Errorf("I width = %d, want 1 (proportional)", i.W) + } + if ft.LineHeight != 4 || ft.Baseline != 3 { + t.Errorf("defaults: lineHeight=%d baseline=%d", ft.LineHeight, ft.Baseline) + } +} + +func TestParseErrors(t *testing.T) { + cases := map[string]string{ + "no glyphs": "font: x\n", + "ragged glyph": "glyph A:\n##\n#\n", + "height differs": "glyph A:\n#\n#\n\nglyph B:\n#\n", + "dup glyph": "glyph A:\n#\n\nglyph A:\n#\n", + "bad name": "glyph AB:\n#\n", + "unknown key": "wat: 3\nglyph A:\n#\n", + } + for name, src := range cases { + if _, err := Parse(strings.NewReader(src)); err == nil { + t.Errorf("%s: expected error", name) + } + } +} + +func TestGlyphNameColon(t *testing.T) { + ft := parseOK(t, "glyph ::\n#\n#\n") + if _, ok := ft.Glyphs[':']; !ok { + t.Error("glyph ':' not parsed") + } +} + +func TestAtlasAndMetrics(t *testing.T) { + ft := parseOK(t, tiny) + img, m := ft.BuildAtlas("t.png") + if m.Glyphs["A"].Advance != 4 { + t.Errorf("A advance = %d, want 4 (w3 + spacing1)", m.Glyphs["A"].Advance) + } + g := m.Glyphs["A"] + // center-top pixel of A within its atlas cell: (x+1, y+0) + if img.NRGBAAt(g.X+1, g.Y).A == 0 { + t.Error("A apex pixel missing in atlas") + } + if img.NRGBAAt(g.X, g.Y).A != 0 { + t.Error("A corner should be empty") + } + var buf bytes.Buffer + if err := WriteMetrics(&buf, m); err != nil { + t.Fatal(err) + } + var back Metrics + if err := json.Unmarshal(buf.Bytes(), &back); err != nil { + t.Fatalf("metrics json invalid: %v", err) + } + if back.Glyphs["I"].W != 1 { + t.Errorf("metrics roundtrip: I.w = %d", back.Glyphs["I"].W) + } +} + +func TestRenderText(t *testing.T) { + ft := parseOK(t, tiny) + white := color.NRGBA{255, 255, 255, 255} + img, warns := ft.RenderText("AI", 1, white) + if len(warns) != 0 { + t.Errorf("warnings: %v", warns) + } + // width: A(3) + spacing(1) + I(1) = 5 + if b := img.Bounds(); b.Dx() != 5 || b.Dy() != 3 { + t.Errorf("size = %dx%d, want 5x3", b.Dx(), b.Dy()) + } + // I column at x=4 + if img.NRGBAAt(4, 0).A == 0 { + t.Error("I pixel missing") + } + + _, warns = ft.RenderText("AXA", 1, white) + if len(warns) != 1 { + t.Errorf("missing-glyph warning expected, got %v", warns) + } + + img, _ = ft.RenderText(`A\nA`, 1, white) + if b := img.Bounds(); b.Dy() != ft.LineHeight+ft.Height { + t.Errorf("two-line height = %d, want %d", b.Dy(), ft.LineHeight+ft.Height) + } + + img2, _ := ft.RenderText("A", 3, white) + if b := img2.Bounds(); b.Dx() != 9 || b.Dy() != 9 { + t.Errorf("scaled size = %dx%d, want 9x9", b.Dx(), b.Dy()) + } +} + +func TestSpaceWidthDefaultFromZero(t *testing.T) { + ft := parseOK(t, "glyph 0:\n####\n####\n") + if ft.SpaceWidth != 4 { + t.Errorf("space-width = %d, want 4 (width of '0')", ft.SpaceWidth) + } +} diff --git a/bitmap-font-maker/font/render.go b/bitmap-font-maker/font/render.go new file mode 100644 index 0000000..4b97d3a --- /dev/null +++ b/bitmap-font-maker/font/render.go @@ -0,0 +1,165 @@ +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) +} diff --git a/bitmap-font-maker/go.mod b/bitmap-font-maker/go.mod new file mode 100644 index 0000000..ed8eced --- /dev/null +++ b/bitmap-font-maker/go.mod @@ -0,0 +1,3 @@ +module gitea.brasse-pc.eu/brasse/agent-tools/bitmap-font-maker + +go 1.24 diff --git a/bitmap-font-maker/main.go b/bitmap-font-maker/main.go new file mode 100644 index 0000000..52c7a99 --- /dev/null +++ b/bitmap-font-maker/main.go @@ -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 [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) +}