seedhammer-v1-companion/font/vector/convert.go
mineracks 9261cf368a Lift composer substrate from upstream v1.3.0
Twelve packages lifted from seedhammer/seedhammer @ v1.3.0
(commit 2f071c1d8f23eb7fd39b15fc0acb8874113f801e):

  address/     — bitcoin address parsing
  backup/      — v1 plate dimensions + UR-coded multi-plate backup
  bc/          — Blockchain Commons: ur, fountain, bytewords, urtypes,
                 xoshiro256 (5 subpkgs)
  bip32/       — HD-key derivation
  bip39/       — mnemonic seed phrases + 2048-word wordlist
  engrave/     — text/QR → MoveTo/LineTo command stream conversion
  seedqr/      — SeedQR / CompactSeedQR encoders
  image/       — paletted, rgb565, alpha4, ninepatch image formats
  nonstandard/ — bitcoin descriptor + script parsing
  font/        — bitmap + vector font runtime
  font/{comfortaa,poppins,constant,bitmap,vector}/  — actual fonts
  driver/mjolnir/  — MarkingWay USB-serial engraver driver

Plus an earlier-aside backup_test.go restored (its deps are now lifted).

Import paths globally rewritten seedhammer.com → mineracks namespace
via single sed pass; verified no orphan refs remain. go.mod adopts
upstream's full dep set plus the replace-directive for the patched
kortschak/qr fork.

  go build ./...     clean (all 27 packages)
  go test ./...      clean (12 packages with tests, all passing)

NOT lifted in this commit:
  - driver/{wshat,drm,libcamera}  (hardware-specific GPIO/LCD/camera —
    will be platform-v1/-shaped abstractions instead)
  - gui/                (depends on the above; lifts in Phase 2)
  - cmd/{controller,...} (Pi binary entrypoints — not needed for the
    companion repo)
  - zbar/               (QR scanner — needs libcamera)

Next:
  - Write the SH1E reference encoder/decoder in engrave/wire/sh1e/
  - Lift Gangleri42's cmd/webnfc/ shell + retune to v1 plates
  - First buildable composer WASM with a working preview

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:36:40 +10:00

491 lines
10 KiB
Go

//go:build ignore
package main
import (
"bytes"
"encoding/binary"
"encoding/xml"
"errors"
"flag"
"fmt"
"go/format"
"image"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"unicode"
"github.com/mineracks/seedhammer-v1-companion/font/vector"
)
var packageName = flag.String("package", "main", "package name")
type Face struct {
Metrics vector.Metrics
// Index maps a character to its segment range.
Index [unicode.MaxASCII]vector.Glyph
// Segments encoded as opcode, args, opcode, args...
Segments []byte
}
func main() {
flag.Parse()
if flag.NArg() != 2 {
fmt.Fprintf(os.Stderr, "usage: convert infile outfile\n")
os.Exit(1)
}
infile := flag.Arg(0)
in, err := os.ReadFile(infile)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
conv, err := convert(in)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to parse %q: %v\n", infile, err)
os.Exit(1)
}
if err := generate(filepath.Base(infile), conv); err != nil {
fmt.Fprintf(os.Stderr, "failed to generate %q: %v\n", infile, err)
os.Exit(1)
}
}
func generate(fname string, conv *Face) error {
var output bytes.Buffer
ext := filepath.Ext(fname)
fname = fname[:len(fname)-len(ext)]
name := flag.Arg(1)
fmt.Fprintf(&output, "// Code generated by font/vector/convert.go; DO NOT EDIT.\npackage %s\n", *packageName)
fmt.Fprintf(&output, "import (\n")
fmt.Fprintf(&output, " _ \"embed\"\n")
fmt.Fprintf(&output, " \"unsafe\"\n")
fmt.Fprintf(&output, " \"github.com/mineracks/seedhammer-v1-companion/font/vector\"\n")
fmt.Fprintf(&output, ")\n\n")
fmt.Fprintf(&output, "var Font = vector.NewFace(unsafe.Slice(unsafe.StringData(%sData), len(%[1]sData)))\n\n", name)
fmt.Fprintf(&output, "//go:embed %s.bin\n", name)
fmt.Fprintf(&output, "var %sData string\n", name)
formatted, err := format.Source(output.Bytes())
if err != nil {
return err
}
var data []byte
bo := binary.LittleEndian
data = append(data, uint8(conv.Metrics.Ascent), uint8(conv.Metrics.Height))
for _, g := range conv.Index {
data = append(data, uint8(g.Advance))
start, end := int(g.Start)+vector.OffSegments, int(g.End)+vector.OffSegments
s16, e16 := uint16(start), uint16(end)
if int(s16) != start || int(e16) != end {
return errors.New("segment offset overflows uint16")
}
data = bo.AppendUint16(data, s16)
data = bo.AppendUint16(data, e16)
}
if len(data) != vector.OffSegments {
panic("miscalculated segment offset")
}
data = append(data, conv.Segments...)
if err := os.WriteFile(name+".go", formatted, 0o600); err != nil {
return err
}
return os.WriteFile(name+".bin", data, 0o600)
}
type MetaData struct {
Advance, Height, Baseline int
}
func convert(svg []byte) (*Face, error) {
d := xml.NewDecoder(bytes.NewReader(svg))
for {
root, err := d.Token()
if err != nil {
return nil, err
}
t, ok := root.(xml.StartElement)
if !ok {
continue
}
if !ok || t.Name.Local != "svg" {
return nil, errors.New("missing <svg> root element")
}
face := new(Face)
meta, err := parseMeta(svg)
if err != nil {
return nil, err
}
ascent := int8(meta.Baseline)
if int(ascent) != meta.Baseline {
return nil, errors.New("baseline overflows int8")
}
height := int8(meta.Height)
if int(height) != meta.Height {
return nil, errors.New("height overlflows int8")
}
face.Metrics.Ascent = ascent
face.Metrics.Height = height
adv := meta.Advance
face.Index[' '] = vector.Glyph{
Advance: int8(adv),
}
err = parseChars(face, d, adv, int(ascent))
return face, err
}
}
func parseMeta(data []byte) (*MetaData, error) {
type Line struct {
ID string `xml:"id,attr"`
X1 float64 `xml:"x1,attr"`
Y1 float64 `xml:"y1,attr"`
X2 float64 `xml:"x2,attr"`
Y2 float64 `xml:"y2,attr"`
}
type SVG struct {
XMLName xml.Name `xml:"svg"`
Lines []Line `xml:"line"`
}
var svg SVG
if err := xml.Unmarshal(data, &svg); err != nil {
return nil, err
}
var meta MetaData
for _, line := range svg.Lines {
switch line.ID {
case "advance":
meta.Advance = mustInt(line.X2 - line.X1)
case "height":
meta.Height = mustInt(line.Y2 - line.Y1)
case "baseline":
meta.Baseline = mustInt(line.Y1)
}
}
return &meta, nil
}
func findAttr(e xml.StartElement, name string) (string, bool) {
for _, a := range e.Attr {
if a.Name.Local == name {
return a.Value, true
}
}
return "", false
}
func mustInt(v float64) int {
i := int(v)
if float64(i) != v {
panic("non-integer floating point number")
}
return i
}
func parseChars(face *Face, d *xml.Decoder, adv, ascent int) error {
offx := 0
for {
t, err := d.Token()
if err != nil {
if err != io.EOF {
return err
}
break
}
e, ok := t.(xml.StartElement)
if !ok {
continue
}
switch e.Name.Local {
case "style":
if err := d.Skip(); err != nil {
return err
}
continue
}
id, _ := findAttr(e, "id")
switch id {
case "advance", "height", "baseline", "size":
// Skip anonymous and meta-data elements.
if err := d.Skip(); err != nil {
return err
}
continue
}
r, ok := mapChar(id)
if !ok {
return fmt.Errorf("unknown character id: %q", id)
}
idxStart := len(face.Segments)
if err := parseSegments(face, d, e, offx, -ascent); err != nil {
return err
}
idxEnd := len(face.Segments)
face.Index[r] = vector.Glyph{
Advance: int8(adv),
Start: uint16(idxStart),
End: uint16(idxEnd),
}
offx -= adv
}
return nil
}
func parseSegments(face *Face, d *xml.Decoder, e xml.StartElement, offx, offy int) error {
encode := func(op vector.SegmentOp, args ...image.Point) {
face.Segments = append(face.Segments, byte(op))
for _, a := range args {
x, y := int8(a.X), int8(a.Y)
if int(x) != a.X || int(y) != a.Y {
panic(fmt.Errorf("coordinates out of range: %v", a))
}
face.Segments = append(face.Segments, byte(x), byte(y))
}
}
switch n := e.Name.Local; n {
case "g":
for {
t, err := d.Token()
if err != nil {
return err
}
switch t := t.(type) {
case xml.StartElement:
if err := parseSegments(face, d, t, offx, offy); err != nil {
return err
}
case xml.EndElement:
return nil
}
}
case "line":
var line struct {
X1 float64 `xml:"x1,attr"`
Y1 float64 `xml:"y1,attr"`
X2 float64 `xml:"x2,attr"`
Y2 float64 `xml:"y2,attr"`
}
if err := d.DecodeElement(&line, &e); err != nil {
return err
}
encode(vector.SegmentOpMoveTo, image.Pt(mustInt(line.X1)+offx, mustInt(line.Y1)+offy))
encode(vector.SegmentOpLineTo, image.Pt(mustInt(line.X2)+offx, mustInt(line.Y2)+offy))
return nil
case "polyline":
points, ok := findAttr(e, "points")
if !ok {
return errors.New("missing points attribute for <polyline>")
}
points = strings.TrimSpace(points)
coords := strings.Split(points, " ")
for i, c := range coords {
var x, y float64
if _, err := fmt.Sscanf(c, "%f,%f", &x, &y); err != nil {
return fmt.Errorf("invalid coordinates %q in <polyline>:", c)
}
op := vector.SegmentOpLineTo
if i == 0 {
op = vector.SegmentOpMoveTo
}
encode(op, image.Pt(mustInt(x)+offx, mustInt(y)+offy))
}
return d.Skip()
case "path":
cmds, ok := findAttr(e, "d")
if !ok {
return errors.New("missing d attribute for <path>")
}
cmds = strings.TrimSpace(cmds)
pen := image.Pt(offx, offy)
initPoint := pen
ctrl2 := pen
for {
cmds = strings.TrimLeft(cmds, " ,\t\n")
if len(cmds) == 0 {
break
}
orig := cmds
op := rune(cmds[0])
cmds = cmds[1:]
switch op {
case 'M', 'm', 'V', 'v', 'L', 'l', 'H', 'h', 'C', 'c', 'S', 's':
case 'Z', 'z':
if pen != initPoint {
encode(vector.SegmentOpLineTo, initPoint)
pen = initPoint
}
ctrl2 = initPoint
continue
default:
return fmt.Errorf("unknown <path> command %s in %q", string(op), orig)
}
var coords []int
for {
cmds = strings.TrimLeft(cmds, " ,\t\n")
if len(cmds) == 0 {
break
}
n, x, ok := parseFloat(cmds)
if !ok {
break
}
cmds = cmds[n:]
coords = append(coords, mustInt(x))
}
rel := unicode.IsLower(op)
newPen := pen
switch unicode.ToLower(op) {
case 'h':
for _, x := range coords {
p := image.Pt(x, pen.Y)
if rel {
p.X += pen.X
} else {
p.X += offx
}
encode(vector.SegmentOpLineTo, p)
newPen = p
}
pen = newPen
ctrl2 = newPen
continue
case 'v':
for _, y := range coords {
p := image.Pt(pen.X, y)
if rel {
p.Y += pen.Y
} else {
p.Y += offy
}
encode(vector.SegmentOpLineTo, p)
newPen = p
}
pen = newPen
ctrl2 = newPen
continue
}
if len(coords)%2 != 0 {
return fmt.Errorf("odd number of coordinates in <path> data: %q", orig)
}
var off image.Point
if rel {
// Relative command.
off = pen
} else {
off = image.Pt(offx, offy)
}
var points []image.Point
for i := 0; i < len(coords); i += 2 {
p := image.Pt(coords[i], coords[i+1])
p = p.Add(off)
points = append(points, p)
}
newCtrl2 := ctrl2
switch op := unicode.ToLower(op); op {
case 'm', 'l':
sop := vector.SegmentOpMoveTo
if op == 'l' {
sop = vector.SegmentOpLineTo
}
for _, p := range points {
encode(sop, p)
newPen = p
}
if op == 'm' {
initPoint = newPen
}
case 'c', 's':
return errors.New("cubic splines not supported")
}
pen = newPen
ctrl2 = newCtrl2
}
return d.Skip()
default:
return fmt.Errorf("unsupported element: <%s>", n)
}
}
func parseFloat(s string) (int, float64, bool) {
n := 0
if len(s) > 0 && s[0] == '-' {
n++
}
for ; n < len(s); n++ {
if !(unicode.IsDigit(rune(s[n])) || s[n] == '.') {
break
}
}
f, err := strconv.ParseFloat(s[:n], 64)
return n, f, err == nil
}
func mapChar(id string) (rune, bool) {
var r rune
switch {
case len(id) == 1:
r = rune(id[0])
default:
switch id {
case "zero":
r = '0'
case "one":
r = '1'
case "two":
r = '2'
case "three":
r = '3'
case "four":
r = '4'
case "five":
r = '5'
case "six":
r = '6'
case "seven":
r = '7'
case "eight":
r = '8'
case "nine":
r = '9'
case "colon":
r = ':'
case "comma":
r = ','
case "slash":
r = '/'
case "apostrophe":
r = '\''
case "dash":
r = '-'
case "period":
r = '.'
case "leftparen":
r = '('
case "rightparen":
r = ')'
case "leftbracket":
r = '['
case "rightbracket":
r = ']'
case "leftcurlybrace":
r = '{'
case "rightcurlybrace":
r = '}'
case "hash":
r = '#'
case "star":
r = '*'
case "at":
r = '@'
default:
return 0, false
}
}
return r, true
}