// 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 }