From d9d9a67f0eeeda67bfe9fb35699a1335da9ce6f2 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 28 Mar 2026 13:45:14 +0100 Subject: [PATCH] Add font-icon-extractor tool --- cmd/font-icon-extractor/.gitignore | 1 + cmd/font-icon-extractor/main.go | 305 ++++++++++++++++++ cmd/font-icon-extractor/main_test.go | 103 ++++++ .../testdata/bose_subset.ttf | Bin 0 -> 940 bytes .../testdata/references/icon_E115.png | Bin 0 -> 2719 bytes .../testdata/references/icon_E115.svg | 5 + 6 files changed, 414 insertions(+) create mode 100644 cmd/font-icon-extractor/.gitignore create mode 100644 cmd/font-icon-extractor/main.go create mode 100644 cmd/font-icon-extractor/main_test.go create mode 100644 cmd/font-icon-extractor/testdata/bose_subset.ttf create mode 100644 cmd/font-icon-extractor/testdata/references/icon_E115.png create mode 100644 cmd/font-icon-extractor/testdata/references/icon_E115.svg diff --git a/cmd/font-icon-extractor/.gitignore b/cmd/font-icon-extractor/.gitignore new file mode 100644 index 0000000..46c8ed0 --- /dev/null +++ b/cmd/font-icon-extractor/.gitignore @@ -0,0 +1 @@ +test_output/ diff --git a/cmd/font-icon-extractor/main.go b/cmd/font-icon-extractor/main.go new file mode 100644 index 0000000..9ac69ff --- /dev/null +++ b/cmd/font-icon-extractor/main.go @@ -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(` + + + +`, -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)} +} diff --git a/cmd/font-icon-extractor/main_test.go b/cmd/font-icon-extractor/main_test.go new file mode 100644 index 0000000..4f5eb4b --- /dev/null +++ b/cmd/font-icon-extractor/main_test.go @@ -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(` + + + +`, -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) + } + } +} diff --git a/cmd/font-icon-extractor/testdata/bose_subset.ttf b/cmd/font-icon-extractor/testdata/bose_subset.ttf new file mode 100644 index 0000000000000000000000000000000000000000..202e7a0633a272b4725cf58dc5179302cb94f12a GIT binary patch literal 940 zcmZuvOK4L;6g@NVrKwG^srK=TS{tYop`WBlnh3T~qk^T>LX{#SwvRL>h5Sfbo9xo9 z3vuIGK@rrIf+8rokfNX)De5{ayKrey7xj5&l0`AjPOa=mg4>cGF&0PqF z&wjdi91!;zT}tMhB0B3CK$_&yv{Npk9ftwqE_r7!?U0-qK6J zFzVtKJTT=Ad5Nl-#xAZREQVR{^O#Z^QmR?tSFX8c=g!Yzs&3US-QzuHxWkmzi62t^ zYXL1)QyH`#dX?>gsWT>b^19`^4S)1|qna8oE-SUUk9djJzyb%z8dzdKN=#SI&vbB1inKaf|?XY@QpIKnvD(6V?jA`VUNpqe&z2bRxRy{{(t>>xO8r50$ n3vlKTzXhID;v_5jbQK-dadqol?lbkS|FZ;NOglPw|6P9pi{_d| literal 0 HcmV?d00001 diff --git a/cmd/font-icon-extractor/testdata/references/icon_E115.png b/cmd/font-icon-extractor/testdata/references/icon_E115.png new file mode 100644 index 0000000000000000000000000000000000000000..e1ae66ee88e62b544145a9b3be49e97b6017a85e GIT binary patch literal 2719 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 + + + + \ No newline at end of file