Add font-icon-extractor tool

This commit is contained in:
Tobias Gesellchen
2026-03-28 15:49:06 +01:00
parent e0a84d5904
commit d9d9a67f0e
6 changed files with 414 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
test_output/
+305
View File
@@ -0,0 +1,305 @@
// Package main provides a utility to extract icons from Bose-branded TrueType fonts.
package main
import (
"encoding/json"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log"
"os"
"path/filepath"
"sort"
"github.com/srwiley/rasterx"
"golang.org/x/image/font"
"golang.org/x/image/font/sfnt"
"golang.org/x/image/math/fixed"
)
type IconMapping struct {
Hex string `json:"hex"`
GlyphName string `json:"glyph_name"`
File string `json:"file"`
SVGFile string `json:"svg_file,omitempty"`
}
func main() {
fontPath := flag.String("font", "/path/to/bose.ttf", "Path to the TTF font file")
outputDir := flag.String("output", "extracted_icons", "Output directory for icons")
imgSize := flag.Int("size", 256, "Size of the PNG icons")
flag.Parse()
if err := os.MkdirAll(*outputDir, 0755); err != nil {
log.Fatalf("Failed to create output directory: %v", err)
}
data, err := os.ReadFile(*fontPath)
if err != nil {
log.Fatalf("Failed to read font file: %v", err)
}
f, err := sfnt.Parse(data)
if err != nil {
log.Fatalf("Failed to parse font: %v", err)
}
var (
buffer sfnt.Buffer
glyphIndex sfnt.GlyphIndex
glyphName string
segments sfnt.Segments
pngFile *os.File
)
unitsPerEm := f.UnitsPerEm()
ppem := fixed.Int26_6(unitsPerEm) << 6
m, err := f.Metrics(&buffer, ppem, font.HintingNone)
if err != nil {
log.Fatalf("Failed to get metrics: %v", err)
}
mapping := make(map[rune]IconMapping)
// Iterate through common ranges
ranges := []struct{ start, end rune }{
{0x20, 0x7E}, // Basic Latin
{0xA0, 0xFF}, // Latin-1 Supplement
{0xE000, 0xF8FF}, // Private Use Area
}
for _, rg := range ranges {
for r := rg.start; r <= rg.end; r++ {
glyphIndex, err = f.GlyphIndex(&buffer, r)
if err != nil || glyphIndex == 0 {
continue
}
glyphName, err = f.GlyphName(&buffer, glyphIndex)
if err != nil {
glyphName = fmt.Sprintf("uni%04X", r)
}
segments, err = f.LoadGlyph(&buffer, glyphIndex, ppem, nil)
if err != nil {
fmt.Printf("Failed to load glyph 0x%04X: %v\n", r, err)
continue
}
if len(segments) == 0 {
continue
}
charHex := fmt.Sprintf("%04X", r)
pngFilename := fmt.Sprintf("icon_%s.png", charHex)
svgFilename := fmt.Sprintf("icon_%s.svg", charHex)
// 1. Extract SVG
svgPath := segmentsToSVGPath(segments)
totalHeight := float64(m.Ascent+m.Descent) / 64.0
svgContent := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 %g %d %g">
<g transform="scale(1, -1)">
<path d="%s" />
</g>
</svg>`, -float64(m.Ascent)/64.0, int(unitsPerEm), totalHeight, svgPath)
if err = os.WriteFile(filepath.Join(*outputDir, svgFilename), []byte(svgContent), 0644); err != nil {
fmt.Printf("Failed to write SVG 0x%s: %v\n", charHex, err)
}
// 2. Render PNG
img := renderGlyphToPNG(segments, int(unitsPerEm), int(m.Ascent), int(m.Descent), *imgSize)
pngFile, err = os.Create(filepath.Join(*outputDir, pngFilename))
if err == nil {
if err = png.Encode(pngFile, img); err != nil {
fmt.Printf("Failed to encode PNG 0x%s: %v\n", charHex, err)
}
pngFile.Close()
} else {
fmt.Printf("Failed to create PNG file 0x%s: %v\n", charHex, err)
}
mapping[r] = IconMapping{
Hex: fmt.Sprintf("0x%s", charHex),
GlyphName: glyphName,
File: pngFilename,
SVGFile: svgFilename,
}
}
}
// Save mapping.json
mappingList := make(map[string]IconMapping)
var keys []int
for r, m := range mapping {
mappingList[fmt.Sprintf("%d", r)] = m
keys = append(keys, int(r))
}
sort.Ints(keys)
jsonData, err := json.MarshalIndent(mappingList, "", " ")
if err != nil {
log.Fatalf("Failed to marshal mapping: %v", err)
}
_ = os.WriteFile(filepath.Join(*outputDir, "mapping.json"), jsonData, 0644)
// Save mapping.md
mdFile, _ := os.Create(filepath.Join(*outputDir, "mapping.md"))
fmt.Fprintln(mdFile, "# Bose Icons Mapping")
fmt.Fprintln(mdFile, "")
fmt.Fprintln(mdFile, "| Char Code | Glyph Name | PNG | SVG |")
fmt.Fprintln(mdFile, "| --- | --- | --- | --- |")
for _, k := range keys {
m := mapping[rune(k)]
fmt.Fprintf(mdFile, "| %s | %s | ![%s](%s) | [SVG](%s) |\n", m.Hex, m.GlyphName, m.GlyphName, m.File, m.SVGFile)
}
mdFile.Close()
fmt.Printf("Extracted %d icons to %s\n", len(mapping), *outputDir)
}
func segmentsToSVGPath(segments sfnt.Segments) string {
var path string
for _, seg := range segments {
switch seg.Op {
case sfnt.SegmentOpMoveTo:
path += fmt.Sprintf("M%g %g ", float64(seg.Args[0].X)/64.0, -float64(seg.Args[0].Y)/64.0)
case sfnt.SegmentOpLineTo:
path += fmt.Sprintf("L%g %g ", float64(seg.Args[0].X)/64.0, -float64(seg.Args[0].Y)/64.0)
case sfnt.SegmentOpQuadTo:
path += fmt.Sprintf("Q%g %g %g %g ", float64(seg.Args[0].X)/64.0, -float64(seg.Args[0].Y)/64.0, float64(seg.Args[1].X)/64.0, -float64(seg.Args[1].Y)/64.0)
case sfnt.SegmentOpCubeTo:
path += fmt.Sprintf("C%g %g %g %g %g %g ", float64(seg.Args[0].X)/64.0, -float64(seg.Args[0].Y)/64.0, float64(seg.Args[1].X)/64.0, -float64(seg.Args[1].Y)/64.0, float64(seg.Args[2].X)/64.0, -float64(seg.Args[2].Y)/64.0)
}
}
return path
}
func renderGlyphToPNG(segments sfnt.Segments, _, _, _, imgSize int) image.Image {
rgba := image.NewRGBA(image.Rect(0, 0, imgSize, imgSize))
draw.Draw(rgba, rgba.Bounds(), image.Transparent, image.Point{}, draw.Src)
// Calculate glyph bounds
var xmin, ymin, xmax, ymax float64
initialized := false
for _, seg := range segments {
for _, arg := range seg.Args {
x, y := float64(arg.X)/64.0, float64(arg.Y)/64.0
if !initialized {
xmin, xmax = x, x
ymin, ymax = y, y
initialized = true
} else {
if x < xmin {
xmin = x
}
if x > xmax {
xmax = x
}
if y < ymin {
ymin = y
}
if y > ymax {
ymax = y
}
}
}
}
w := xmax - xmin
h := ymax - ymin
// If no width/height, return empty image
if w <= 0 || h <= 0 {
return rgba
}
// Calculate scale to fit in imgSize with padding
padding := 20.0
available := float64(imgSize) - 2*padding
scale := available / w
if h*scale > available {
scale = available / h
}
// Center the glyph
// X: center of image (imgSize/2) - (center of glyph (xmin+xmax)/2) * scale
offsetX := float64(imgSize)/2.0 - (xmin+xmax)/2.0*scale
// Y: center of image (imgSize/2) - (center of glyph (ymin+ymax)/2) * scale
offsetY := float64(imgSize)/2.0 - (ymin+ymax)/2.0*scale
scanner := rasterx.NewScannerGV(imgSize, imgSize, rgba, rgba.Bounds())
filler := rasterx.NewFiller(imgSize, imgSize, scanner)
filler.SetColor(color.Black)
for _, seg := range segments {
switch seg.Op {
case sfnt.SegmentOpMoveTo:
filler.Start(fixedP(
offsetX+float64(seg.Args[0].X)/64.0*scale,
offsetY+float64(seg.Args[0].Y)/64.0*scale,
))
case sfnt.SegmentOpLineTo:
filler.Line(fixedP(
offsetX+float64(seg.Args[0].X)/64.0*scale,
offsetY+float64(seg.Args[0].Y)/64.0*scale,
))
case sfnt.SegmentOpQuadTo:
filler.QuadBezier(
fixedP(
offsetX+float64(seg.Args[0].X)/64.0*scale,
offsetY+float64(seg.Args[0].Y)/64.0*scale,
),
fixedP(
offsetX+float64(seg.Args[1].X)/64.0*scale,
offsetY+float64(seg.Args[1].Y)/64.0*scale,
),
)
case sfnt.SegmentOpCubeTo:
filler.CubeBezier(
fixedP(
offsetX+float64(seg.Args[0].X)/64.0*scale,
offsetY+float64(seg.Args[0].Y)/64.0*scale,
),
fixedP(
offsetX+float64(seg.Args[1].X)/64.0*scale,
offsetY+float64(seg.Args[1].Y)/64.0*scale,
),
fixedP(
offsetX+float64(seg.Args[2].X)/64.0*scale,
offsetY+float64(seg.Args[2].Y)/64.0*scale,
),
)
}
}
filler.Stop(true)
filler.Draw()
return rgba
}
func fixedP(x, y float64) fixed.Point26_6 {
return fixed.Point26_6{X: fixed.Int26_6(x * 64), Y: fixed.Int26_6(y * 64)}
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"fmt"
"image/png"
"os"
"path/filepath"
"testing"
"golang.org/x/image/font"
"golang.org/x/image/font/sfnt"
"golang.org/x/image/math/fixed"
)
func TestExtractE115(t *testing.T) {
fontPath := "testdata/bose_subset.ttf"
outputDir := "test_output"
refDir := "testdata/references"
imgSize := 256
targetRune := rune(0xE115)
if err := os.MkdirAll(outputDir, 0755); err != nil {
t.Fatalf("Failed to create output directory: %v", err)
}
data, err := os.ReadFile(fontPath)
if err != nil {
t.Fatalf("Failed to read font file: %v", err)
}
f, err := sfnt.Parse(data)
if err != nil {
t.Fatalf("Failed to parse font: %v", err)
}
var buffer sfnt.Buffer
unitsPerEm := f.UnitsPerEm()
ppem := fixed.Int26_6(unitsPerEm) << 6
m, err := f.Metrics(&buffer, ppem, font.HintingNone)
if err != nil {
t.Fatalf("Failed to get metrics: %v", err)
}
glyphIndex, err := f.GlyphIndex(&buffer, targetRune)
if err != nil || glyphIndex == 0 {
t.Fatalf("Failed to find glyph for 0x%X", targetRune)
}
segments, err := f.LoadGlyph(&buffer, glyphIndex, ppem, nil)
if err != nil {
t.Fatalf("Failed to load glyph 0x%X: %v", targetRune, err)
}
charHex := fmt.Sprintf("%04X", targetRune)
pngFilename := fmt.Sprintf("icon_%s.png", charHex)
svgFilename := fmt.Sprintf("icon_%s.svg", charHex)
// 1. Extract SVG
svgPath := segmentsToSVGPath(segments)
totalHeight := float64(m.Ascent+m.Descent) / 64.0
svgContent := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 %g %d %g">
<g transform="scale(1, -1)">
<path d="%s" />
</g>
</svg>`, -float64(m.Ascent)/64.0, int(unitsPerEm), totalHeight, svgPath)
svgFilePath := filepath.Join(outputDir, svgFilename)
if err = os.WriteFile(svgFilePath, []byte(svgContent), 0644); err != nil {
t.Errorf("Failed to write SVG 0x%s: %v", charHex, err)
}
// 2. Render PNG
img := renderGlyphToPNG(segments, int(unitsPerEm), int(m.Ascent), int(m.Descent), imgSize)
pngFilePath := filepath.Join(outputDir, pngFilename)
pngFile, err := os.Create(pngFilePath)
if err == nil {
if err = png.Encode(pngFile, img); err != nil {
t.Errorf("Failed to encode PNG 0x%s: %v", charHex, err)
}
pngFile.Close()
} else {
t.Errorf("Failed to create PNG file 0x%s: %v", charHex, err)
}
// 3. Compare with references
for _, filename := range []string{svgFilename, pngFilename} {
generated, err := os.ReadFile(filepath.Join(outputDir, filename))
if err != nil {
t.Errorf("Failed to read generated file %s: %v", filename, err)
continue
}
reference, err := os.ReadFile(filepath.Join(refDir, filename))
if err != nil {
t.Errorf("Failed to read reference file %s: %v", filename, err)
continue
}
if string(generated) != string(reference) {
t.Errorf("Mismatch in %s: generated does not match reference", filename)
}
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
<g transform="scale(1, -1)">
<path d="M119 -31 L828 678 L871 635 L162 -74 L119 -31 M206 194 L206 405 Q206 426 220 440 Q235 455 256 455 L405 455 L602 654 L668 654 L668 638 L607 572 L430 394 L267 394 L267 204 L269 204 L222 157 Q206 171 206 194 M435 112 L478 155 L607 26 L607 285 L668 345 L668 -56 L602 -56 L435 112 " />
</g>
</svg>

After

Width:  |  Height:  |  Size: 404 B