diff --git a/AGENTS.md b/AGENTS.md index 2e1f614..a88d37f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Progress truth: `TASKS.md`. Requirements: `PRD.md` and `docs/prd/components-*.md | --- | --- | | Go | 1.27.1 | | MyGo | `github.com/egoist/mygo v0.2.10`, pinned in go.mod | -| Font | Ark Pixel Font 12px proportional zh_Hans (OFL-1.1), packed into `pixel/font12.bin` by `tools/fontpack` | +| Font | Ark Pixel Font 12px proportional zh_Hans v2026.09.25 (OFL-1.1, `pixel/OFL.txt`). Rebuild: `go run ./tools/fontpack -in ark-pixel-12px-proportional-zh_hans.bdf` (BDF sha256 `535f7024…8c86`) | | Web | `cmd/web` built with `GOOS=js GOARCH=wasm`; the browser runs the gallery under `ui.Tester` and blits frames to a canvas | | Hosting | Cloudflare Workers static assets from `web/dist`; code on Gitea `git.zacharyzhang.com` | diff --git a/pixel/OFL.txt b/pixel/OFL.txt new file mode 100644 index 0000000..264873a --- /dev/null +++ b/pixel/OFL.txt @@ -0,0 +1,96 @@ +Ark Pixel Font +https://github.com/TakWolf/ark-pixel-font + +Copyright (c) 2021, TakWolf (https://takwolf.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/pixel/font.go b/pixel/font.go new file mode 100644 index 0000000..e3f30b9 --- /dev/null +++ b/pixel/font.go @@ -0,0 +1,187 @@ +package pixel + +import ( + _ "embed" + "encoding/binary" + "strings" + "unicode" +) + +//go:embed font12.bin +var fontData []byte + +// Text line height in dots at ×1. +const LineHeight = 16 + +type font struct { + ascent, descent, n int + index, data []byte +} + +type glyph struct { + adv, w, h, x, y, stride int + rows []byte +} + +var font12 = loadFont(fontData) + +// tofu stands in for runes the font lacks. +var tofu = glyph{adv: 8, w: 6, h: 9, x: 1, stride: 1, rows: []byte{0xFC, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0xFC}} + +func loadFont(b []byte) *font { + if len(b) < 10 || string(b[:4]) != "8BF1" { + panic("pixel: font12.bin has a bad header") + } + n := int(binary.LittleEndian.Uint32(b[6:10])) + if len(b) < 10+8*n { + panic("pixel: font12.bin index truncated") + } + return &font{ascent: int(b[4]), descent: int(b[5]), n: n, index: b[10 : 10+8*n], data: b} +} + +func (f *font) find(r rune) (glyph, bool) { + lo, hi := 0, f.n + for lo < hi { + m := int(uint(lo+hi) >> 1) + if rune(binary.LittleEndian.Uint32(f.index[8*m:])) < r { + lo = m + 1 + } else { + hi = m + } + } + if lo == f.n || rune(binary.LittleEndian.Uint32(f.index[8*lo:])) != r { + return glyph{}, false + } + off := int(binary.LittleEndian.Uint32(f.index[8*lo+4:])) + d := f.data + if off+5 > len(d) { + panic("pixel: font12.bin glyph truncated") + } + g := glyph{adv: int(d[off]), w: int(d[off+1]), h: int(d[off+2]), x: int(int8(d[off+3])), y: int(int8(d[off+4]))} + g.stride = (g.w + 7) / 8 + end := off + 5 + g.h*g.stride + if end > len(d) { + panic("pixel: font12.bin glyph truncated") + } + g.rows = d[off+5 : end] + return g, true +} + +func glyphOf(r rune) glyph { + if g, ok := font12.find(r); ok { + return g + } + return tofu +} + +func (g glyph) on(x, y int) bool { return g.rows[y*g.stride+x/8]&(0x80>>(x%8)) != 0 } + +// top is the bitmap's first row below the line top. +func (g glyph) top() int { return font12.ascent - g.y - g.h } + +// Measure returns one line's width in dots at ×1. +func Measure(s string) int { + w := 0 + for _, r := range s { + w += glyphOf(r).adv + } + return w +} + +// Lines wraps s to width dots; zero width keeps lines. +func Lines(s string, width int) []string { + var out []string + for _, p := range strings.Split(s, "\n") { + out = append(out, wrap([]rune(p), width)...) + } + return out +} + +func wrap(rs []rune, width int) []string { + if width <= 0 || Measure(string(rs)) <= width { + return []string{string(rs)} + } + var lines []string + for start := 0; start < len(rs); { + w, brk, i := 0, -1, start + for ; i < len(rs); i++ { + if i > start && breakable(rs, i) { + brk = i + } + w += glyphOf(rs[i]).adv + if w > width && i > start { + break + } + } + if i == len(rs) { + lines = append(lines, string(rs[start:])) + break + } + if brk <= start { + brk = hardBreak(rs, start, i) + } + lines = append(lines, strings.TrimRight(string(rs[start:brk]), " ")) + start = skip(rs, brk) + } + return lines +} + +// breakable reports whether a line may break before rs[i]. +func breakable(rs []rune, i int) bool { + if n := skip(rs, i); n < len(rs) && closer(rs[n]) || opener(rs[back(rs, 0, i)]) { + return false + } + return rs[i] == ' ' || rs[i-1] == ' ' || wide(rs[i]) || wide(rs[i-1]) +} + +// hardBreak splits an overlong run, keeping punctuation attached. +func hardBreak(rs []rune, start, i int) int { + q, hangs := i, false + for q > start && (rs[q-1] == ' ' || opener(rs[q-1])) { + hangs = hangs || opener(rs[q-1]) + q-- + } + switch { + case hangs && q > start: + return q + case hangs: + for i < len(rs) && (rs[i] == ' ' || opener(rs[i])) { + i++ + } + i = min(i+1, len(rs)) + } + for n := skip(rs, i); n < len(rs) && closer(rs[n]); n = skip(rs, i) { + i = n + 1 + } + return i +} + +func skip(rs []rune, i int) int { + for i < len(rs) && rs[i] == ' ' { + i++ + } + return i +} + +// back returns the last non-space index before i. +func back(rs []rune, start, i int) int { + p := i - 1 + for p > start && rs[p] == ' ' { + p-- + } + return p +} + +func closer(r rune) bool { + return unicode.In(r, unicode.Pe, unicode.Pf) || strings.ContainsRune(stops, r) +} + +func opener(r rune) bool { return unicode.In(r, unicode.Ps, unicode.Pi) } + +// Sentence ends that must not start a line. +const stops = ",.;:!?,。、;:?!…‥·・" + +func wide(r rune) bool { + return unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul) || + r >= 0x3000 && r <= 0x303F || r >= 0xFF00 && r <= 0xFFEF +} diff --git a/pixel/font12.bin b/pixel/font12.bin new file mode 100644 index 0000000..f3b2c91 Binary files /dev/null and b/pixel/font12.bin differ diff --git a/pixel/font_test.go b/pixel/font_test.go new file mode 100644 index 0000000..37223ad --- /dev/null +++ b/pixel/font_test.go @@ -0,0 +1,218 @@ +package pixel + +import ( + "encoding/binary" + "encoding/hex" + "math/rand/v2" + "slices" + "strings" + "testing" +) + +func TestFontHeader(t *testing.T) { + if font12.n != 24870 || font12.ascent != 13 || font12.descent != 3 { + t.Fatalf("font: %d glyphs, ascent %d, descent %d", font12.n, font12.ascent, font12.descent) + } + if LineHeight != font12.ascent+font12.descent { + t.Fatalf("LineHeight %d, want %d", LineHeight, font12.ascent+font12.descent) + } + prev := rune(-1) + for i := range font12.n { + r := rune(binary.LittleEndian.Uint32(font12.index[8*i:])) + if r <= prev { + t.Fatalf("index entry %d: %U after %U", i, r, prev) + } + prev = r + } +} + +func TestBadFontPanics(t *testing.T) { + for name, b := range map[string][]byte{ + "magic": []byte("XXXX\x0d\x03\x00\x00\x00\x00"), + "truncated": []byte("8BF1\x0d\x03\x05\x00\x00\x00"), + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: loadFont did not panic", name) + } + }() + loadFont(b) + }() + } +} + +// Rows copied from ark-pixel-12px-proportional-zh_hans.bdf v2026.09.25. +func TestGlyphsMatchBDF(t *testing.T) { + cases := []struct { + r rune + adv, w, h, x, y, top int + hexRows string + }{ + {'A', 8, 7, 9, 0, 0, 4, "10 10 28 28 44 7C 44 82 82"}, + {'g', 6, 5, 8, 0, -2, 7, "78 88 88 88 88 78 08 70"}, + {'中', 12, 11, 11, 0, -1, 3, "0400 0400 FFE0 8420 8420 8420 FFE0 0400 0400 0400 0400"}, + {',', 12, 2, 4, 2, -1, 10, "C0 C0 40 80"}, + {' ', 6, 0, 0, 0, 0, 13, ""}, + } + for _, c := range cases { + g, ok := font12.find(c.r) + if !ok { + t.Fatalf("%q missing", c.r) + } + if g.adv != c.adv || g.w != c.w || g.h != c.h || g.x != c.x || g.y != c.y { + t.Fatalf("%q metrics adv %d box %d×%d at %d,%d", c.r, g.adv, g.w, g.h, g.x, g.y) + } + var want []byte + for _, row := range strings.Fields(c.hexRows) { + b, _ := hex.DecodeString(row) + want = append(want, b...) + } + if !slices.Equal(g.rows, want) { + t.Fatalf("%q rows % X, want % X", c.r, g.rows, want) + } + if got := g.top(); got != c.top { + t.Fatalf("%q top %d, want %d", c.r, got, c.top) + } + } + a := glyphOf('A') + if !a.on(3, 0) || a.on(0, 0) || !a.on(0, 8) || !a.on(6, 8) { + t.Fatal("'A' bits read wrong") + } +} + +func TestMissingRuneDrawsTofu(t *testing.T) { + const r = '\U0001F600' + if _, ok := font12.find(r); ok { + t.Fatalf("%U is in the font; pick a missing rune", r) + } + if g := glyphOf(r); g.adv != tofu.adv || !slices.Equal(g.rows, tofu.rows) { + t.Fatalf("%U = adv %d, want tofu", r, g.adv) + } +} + +func TestMeasure(t *testing.T) { + if got := Measure("Ag中"); got != 8+6+12 { + t.Fatalf("Measure = %d, want 26", got) + } + if Measure("") != 0 { + t.Fatal("Measure of empty text is not 0") + } +} + +func TestLines(t *testing.T) { + cases := []struct { + s string + width int + want []string + }{ + {"HELLO WORLD", max(Measure("HELLO"), Measure("WORLD")) + 2, []string{"HELLO", "WORLD"}}, + {"像素风格组件库", 36, []string{"像素风", "格组件", "库"}}, + {"你好,世界", 24, []string{"你", "好,", "世界"}}, + {"「像素」", 24, []string{"「像", "素」"}}, + {"A\nB", 100, []string{"A", "B"}}, + {"a long line", 0, []string{"a long line"}}, + {"x y", Measure("x"), []string{"x", "y"}}, + {"你 ,好", 24, []string{"你 ,", "好"}}, + {"「 像素", 18, []string{"「 像", "素"}}, + {"[你好]世界", 36, []string{"[你", "好]世", "界"}}, + {"「 像素", 18, []string{"「 像", "素"}}, + {"「(像素)」", 18, []string{"「(像", "素)」"}}, + {"「(像素)」", 24, []string{"「(像", "素)」"}}, + {"AB「(C", Measure("AB") + 4, []string{"AB", "「(C"}}, + } + for _, c := range cases { + if got := Lines(c.s, c.width); !slices.Equal(got, c.want) { + t.Errorf("Lines(%q, %d) = %q, want %q", c.s, c.width, got, c.want) + } + } +} + +func TestLinesFitAndKeepText(t *testing.T) { + texts := []string{ + "The quick brown fox jumps over the lazy dog near the riverbank.", + "像素风格的组件库,在限制中创造丰富。每个点都经过认真摆放!", + "Mixed 中英文 text with ABCDEFGHIJKLMNOPQRSTUVWXYZ and 標點,結束。", + "Supercalifragilisticexpialidocious", + "「像素」风格 (测试) 文本 ,结束 。再见", + "[测试]{内容}〔括号〕《书名》“引号”‘单引’", + "「(像素)」 「 空格」 『《嵌套》』", + } + for _, s := range texts { + for width := 6; width <= 120; width += 7 { + lines := Lines(s, width) + for _, l := range lines { + core := strings.TrimLeftFunc(strings.TrimRightFunc(l, hanging(closer)), hanging(opener)) + if Measure(core) > width && len([]rune(core)) > 1 { + t.Fatalf("Lines(%q, %d): %q is %d wide", s, width, l, Measure(l)) + } + if r := []rune(l); closer(r[0]) { + t.Fatalf("Lines(%q, %d): %q starts with a closer", s, width, l) + } + if r := []rune(l); opener(r[len(r)-1]) { + t.Fatalf("Lines(%q, %d): %q ends with an opener", s, width, l) + } + } + if got := strings.ReplaceAll(strings.Join(lines, ""), " ", ""); got != strings.ReplaceAll(s, " ", "") { + t.Fatalf("Lines(%q, %d) lost text: %q", s, width, got) + } + } + } +} + +func TestLinesRandom(t *testing.T) { + alphabet := []rune("ab 像素,。「」()[]《》“”!?.WM") + rng := rand.New(rand.NewPCG(8, 16)) + for n := range 3000 { + rs := make([]rune, 1+rng.IntN(24)) + for i := range rs { + rs[i] = alphabet[rng.IntN(len(alphabet))] + } + s, width := string(rs), 6+rng.IntN(60) + lines := Lines(s, width) + for j, l := range lines { + r := []rune(strings.TrimRight(l, " ")) + if len(r) == 0 { + continue + } + core := strings.TrimLeftFunc(strings.TrimRightFunc(l, hanging(closer)), hanging(opener)) + if j > 0 && closer(r[0]) || j < len(lines)-1 && opener(r[len(r)-1]) || Measure(core) > width && len([]rune(core)) > 1 { + t.Fatalf("case %d: Lines(%q, %d) = %q", n, s, width, lines) + } + } + if got := strings.ReplaceAll(strings.Join(lines, ""), " ", ""); got != strings.ReplaceAll(s, " ", "") { + t.Fatalf("case %d: Lines(%q, %d) lost text: %q", n, s, width, lines) + } + } +} + +func hanging(punct func(rune) bool) func(rune) bool { + return func(r rune) bool { return r == ' ' || punct(r) } +} + +func TestMicroFont(t *testing.T) { + set := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-.:/%! " + seen := map[[MicroHeight]uint8]rune{} + for _, r := range set { + g := microGlyph(r) + if prev, ok := seen[g]; ok && r != ' ' { + t.Errorf("micro %q and %q are the same glyph", prev, r) + } + seen[g] = r + } + if microGlyph('a') != microGlyph('A') { + t.Fatal("micro font does not upper-case") + } + if got := microGlyph('1'); got != [MicroHeight]uint8{2, 6, 2, 2, 7} { + t.Fatalf("micro '1' = %v", got) + } + if MicroWidth("HP 99") != 19 || MicroWidth("") != 0 { + t.Fatalf("MicroWidth = %d, %d", MicroWidth("HP 99"), MicroWidth("")) + } + defer func() { + if recover() == nil { + t.Fatal("micro '#' did not panic") + } + }() + MicroWidth("#") +} diff --git a/pixel/micro.go b/pixel/micro.go new file mode 100644 index 0000000..74dab3e --- /dev/null +++ b/pixel/micro.go @@ -0,0 +1,104 @@ +package pixel + +import ( + "fmt" + "strings" + "unicode" +) + +const ( + MicroAdvance = 4 + MicroHeight = 5 +) + +// Hand-placed 3×5 glyphs; rows split by |. +var micro = microFont(map[rune]string{ + ' ': "...|...|...|...|...", + '0': "###|#.#|#.#|#.#|###", + '1': ".#.|##.|.#.|.#.|###", + '2': "###|..#|###|#..|###", + '3': "###|..#|.##|..#|###", + '4': "#.#|#.#|###|..#|..#", + '5': "###|#..|###|..#|###", + '6': "###|#..|###|#.#|###", + '7': "###|..#|.#.|.#.|.#.", + '8': "###|#.#|###|#.#|###", + '9': "###|#.#|###|..#|###", + 'A': ".#.|#.#|###|#.#|#.#", + 'B': "##.|#.#|##.|#.#|##.", + 'C': ".##|#..|#..|#..|.##", + 'D': "##.|#.#|#.#|#.#|##.", + 'E': "###|#..|##.|#..|###", + 'F': "###|#..|##.|#..|#..", + 'G': ".##|#..|#.#|#.#|.##", + 'H': "#.#|#.#|###|#.#|#.#", + 'I': "###|.#.|.#.|.#.|###", + 'J': "..#|..#|..#|#.#|.#.", + 'K': "#.#|#.#|##.|#.#|#.#", + 'L': "#..|#..|#..|#..|###", + 'M': "#.#|###|###|#.#|#.#", + 'N': "##.|#.#|#.#|#.#|#.#", + 'O': ".#.|#.#|#.#|#.#|.#.", + 'P': "##.|#.#|##.|#..|#..", + 'Q': ".#.|#.#|#.#|##.|.##", + 'R': "##.|#.#|##.|#.#|#.#", + 'S': ".##|#..|.#.|..#|##.", + 'T': "###|.#.|.#.|.#.|.#.", + 'U': "#.#|#.#|#.#|#.#|###", + 'V': "#.#|#.#|#.#|#.#|.#.", + 'W': "#.#|#.#|###|###|#.#", + 'X': "#.#|#.#|.#.|#.#|#.#", + 'Y': "#.#|#.#|.#.|.#.|.#.", + 'Z': "###|..#|.#.|#..|###", + '+': "...|.#.|###|.#.|...", + '-': "...|...|###|...|...", + '.': "...|...|...|...|.#.", + ':': "...|.#.|...|.#.|...", + '/': "..#|..#|.#.|#..|#..", + '%': "#.#|..#|.#.|#..|#.#", + '!': ".#.|.#.|.#.|...|.#.", +}) + +func microFont(art map[rune]string) map[rune][MicroHeight]uint8 { + out := map[rune][MicroHeight]uint8{} + for r, a := range art { + rows := strings.Split(a, "|") + if len(rows) != MicroHeight { + panic(fmt.Sprintf("pixel: micro %q needs %d rows", r, MicroHeight)) + } + var g [MicroHeight]uint8 + for y, row := range rows { + if len(row) != 3 || strings.Trim(row, ".#") != "" { + panic(fmt.Sprintf("pixel: micro %q row %q", r, row)) + } + for x := range 3 { + if row[x] == '#' { + g[y] |= 4 >> x + } + } + } + out[r] = g + } + return out +} + +func microGlyph(r rune) [MicroHeight]uint8 { + g, ok := micro[unicode.ToUpper(r)] + if !ok { + panic(fmt.Sprintf("pixel: micro font lacks %q", r)) + } + return g +} + +// MicroWidth returns the width of s in dots. +func MicroWidth(s string) int { + n := 0 + for _, r := range s { + microGlyph(r) + n++ + } + if n == 0 { + return 0 + } + return n*MicroAdvance - 1 +} diff --git a/tools/fontpack/main.go b/tools/fontpack/main.go new file mode 100644 index 0000000..a261e5c --- /dev/null +++ b/tools/fontpack/main.go @@ -0,0 +1,154 @@ +// Command fontpack packs a BDF bitmap font for package pixel. +package main + +import ( + "bufio" + "cmp" + "encoding/binary" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "log" + "os" + "slices" + "strconv" + "strings" +) + +type glyph struct { + r rune + adv int + w, h, x, y int + rows []byte +} + +func main() { + in := flag.String("in", "", "BDF file") + out := flag.String("out", "pixel/font12.bin", "packed font") + flag.Parse() + f, err := os.Open(*in) + if err != nil { + log.Fatal(err) + } + defer f.Close() + asc, desc, gs, err := parse(f) + if err != nil { + log.Fatalf("fontpack: %s: %v", *in, err) + } + data, err := pack(asc, desc, gs) + if err != nil { + log.Fatalf("fontpack: %v", err) + } + if err := os.WriteFile(*out, data, 0o644); err != nil { + log.Fatal(err) + } + log.Printf("fontpack: %d glyphs, %d bytes, %s", len(gs), len(data), *out) +} + +func parse(r io.Reader) (asc, desc int, gs []glyph, err error) { + asc, desc = -1, -1 + sc := bufio.NewScanner(r) + var g *glyph + inBitmap := false + line := 0 + for sc.Scan() { + line++ + f := strings.Fields(sc.Text()) + if len(f) == 0 { + continue + } + bad := func(e error) (int, int, []glyph, error) { return 0, 0, nil, fmt.Errorf("line %d: %w", line, e) } + switch { + case inBitmap && f[0] == "ENDCHAR": + if len(g.rows) != g.h*((g.w+7)/8) { + return bad(fmt.Errorf("glyph %U has %d bitmap bytes, want %d", g.r, len(g.rows), g.h*((g.w+7)/8))) + } + if g.r >= 0 { + gs = append(gs, *g) + } + g, inBitmap = nil, false + case inBitmap: + b, err := hex.DecodeString(f[0]) + if err != nil || len(b) != (g.w+7)/8 { + return bad(fmt.Errorf("glyph %U row %q", g.r, f[0])) + } + g.rows = append(g.rows, b...) + case f[0] == "FONT_ASCENT": + asc, err = number(f) + case f[0] == "FONT_DESCENT": + desc, err = number(f) + case f[0] == "STARTCHAR": + g = &glyph{r: -1} + case g == nil: + case f[0] == "ENCODING": + var e int + e, err = number(f) + g.r = rune(e) + case f[0] == "DWIDTH": + g.adv, err = number(f) + case f[0] == "BBX": + if len(f) != 5 { + return bad(errors.New("BBX needs four numbers")) + } + v := make([]int, 4) + for i := range v { + if v[i], err = strconv.Atoi(f[i+1]); err != nil { + return bad(err) + } + } + g.w, g.h, g.x, g.y = v[0], v[1], v[2], v[3] + if g.w < 0 || g.h < 0 { + return bad(fmt.Errorf("glyph %U has a negative box", g.r)) + } + case f[0] == "BITMAP": + inBitmap = true + } + if err != nil { + return bad(err) + } + } + if err := sc.Err(); err != nil { + return 0, 0, nil, err + } + if g != nil { + return 0, 0, nil, fmt.Errorf("glyph %U has no ENDCHAR", g.r) + } + if asc < 0 || desc < 0 { + return 0, 0, nil, errors.New("FONT_ASCENT or FONT_DESCENT missing") + } + return asc, desc, gs, nil +} + +func number(f []string) (int, error) { + if len(f) < 2 { + return 0, fmt.Errorf("%s needs a number", f[0]) + } + return strconv.Atoi(f[1]) +} + +// Layout: magic, ascent, descent, count, index, glyphs. +func pack(asc, desc int, gs []glyph) ([]byte, error) { + slices.SortFunc(gs, func(a, b glyph) int { return cmp.Compare(a.r, b.r) }) + if asc > 255 || desc > 255 { + return nil, fmt.Errorf("ascent %d or descent %d over 255", asc, desc) + } + out := []byte{'8', 'B', 'F', '1', byte(asc), byte(desc)} + out = binary.LittleEndian.AppendUint32(out, uint32(len(gs))) + index := len(out) + out = append(out, make([]byte, 8*len(gs))...) + for i, g := range gs { + if i > 0 && gs[i-1].r == g.r { + return nil, fmt.Errorf("glyph %U twice", g.r) + } + if g.adv < 0 || g.adv > 255 || g.w < 0 || g.w > 255 || g.h < 0 || g.h > 255 || g.x < -128 || g.x > 127 || g.y < -128 || g.y > 127 { + return nil, fmt.Errorf("glyph %U metrics out of range", g.r) + } + binary.LittleEndian.PutUint32(out[index+8*i:], uint32(g.r)) + binary.LittleEndian.PutUint32(out[index+8*i+4:], uint32(len(out))) + out = append(out, byte(g.adv), byte(g.w), byte(g.h), byte(int8(g.x)), byte(int8(g.y))) + out = append(out, g.rows...) + } + return out, nil +} diff --git a/tools/fontpack/main_test.go b/tools/fontpack/main_test.go new file mode 100644 index 0000000..4420aca --- /dev/null +++ b/tools/fontpack/main_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/binary" + "strings" + "testing" +) + +const sample = `STARTFONT 2.1 +FONT_ASCENT 13 +FONT_DESCENT 3 +CHARS 4 +STARTCHAR B +ENCODING 66 +DWIDTH 7 0 +BBX 5 2 1 0 +BITMAP +F8 +88 +ENDCHAR +STARTCHAR unencoded +ENCODING -1 +DWIDTH 3 0 +BBX 1 1 0 0 +BITMAP +80 +ENDCHAR +STARTCHAR A +ENCODING 65 +DWIDTH 8 0 +BBX 9 1 0 -2 +BITMAP +FF80 +ENDCHAR +STARTCHAR space +ENCODING 32 +DWIDTH 6 0 +BBX 0 0 0 0 +BITMAP +ENDCHAR +ENDFONT +` + +func TestParseAndPack(t *testing.T) { + asc, desc, gs, err := parse(strings.NewReader(sample)) + if err != nil { + t.Fatal(err) + } + if asc != 13 || desc != 3 || len(gs) != 3 { + t.Fatalf("ascent %d descent %d glyphs %d", asc, desc, len(gs)) + } + b, err := pack(asc, desc, gs) + if err != nil { + t.Fatal(err) + } + if string(b[:4]) != "8BF1" || b[4] != 13 || b[5] != 3 || binary.LittleEndian.Uint32(b[6:]) != 3 { + t.Fatalf("header % X", b[:10]) + } + runes := []rune{' ', 'A', 'B'} + records := [][]byte{ + {6, 0, 0, 0, 0}, + {8, 9, 1, 0, 0xFE, 0xFF, 0x80}, + {7, 5, 2, 1, 0, 0xF8, 0x88}, + } + for i, r := range runes { + e := b[10+8*i:] + if got := rune(binary.LittleEndian.Uint32(e)); got != r { + t.Fatalf("index %d rune %U, want %U", i, got, r) + } + off := int(binary.LittleEndian.Uint32(e[4:])) + if got := b[off : off+len(records[i])]; string(got) != string(records[i]) { + t.Fatalf("%U record % X, want % X", r, got, records[i]) + } + } +} + +func TestParseErrors(t *testing.T) { + cases := map[string]string{ + "no ascent": strings.Replace(sample, "FONT_ASCENT 13\n", "", 1), + "short rows": strings.Replace(sample, "F8\n88\n", "F8\n", 1), + "bad hex": strings.Replace(sample, "F8\n", "ZZ\n", 1), + "wide row": strings.Replace(sample, "F8\n", "F800\n", 1), + "bad bbx": strings.Replace(sample, "BBX 5 2 1 0", "BBX 5 2", 1), + "no ENDCHAR": strings.TrimSuffix(sample, "ENDCHAR\nENDFONT\n"), + "negative": strings.Replace(sample, "BBX 0 0 0 0", "BBX -1 -1 0 0", 1), + } + for name, src := range cases { + if _, _, _, err := parse(strings.NewReader(src)); err == nil { + t.Errorf("%s: no error", name) + } + } +} + +func TestPackRejectsDuplicates(t *testing.T) { + gs := []glyph{{r: 'A', adv: 1}, {r: 'A', adv: 2}} + if _, err := pack(13, 3, gs); err == nil { + t.Fatal("duplicate rune packed") + } + if _, err := pack(13, 3, []glyph{{r: 'A', adv: 300}}); err == nil { + t.Fatal("advance 300 packed") + } +}