Add pixel font, wrapping and micro font
This commit is contained in:
8 files changed
+862
-1
No files matched your search
@@ -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` |
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Binary file not shown.
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user