Add pixel font, wrapping and micro font

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 15:43:44 -04:00
1 parent 1af6cd61bf
commit 538031af31
8 files changed
+862 -1

No files matched your search

+1 -1
View File
@@ -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` |
+96
View File
@@ -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.
+187
View File
@@ -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
}
BIN
View File
Binary file not shown.
+218
View File
@@ -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("#")
}
+104
View File
@@ -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
}
+154
View File
@@ -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
}
+102
View File
@@ -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")
}
}