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 0000000..202e7a0 Binary files /dev/null and b/cmd/font-icon-extractor/testdata/bose_subset.ttf differ 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 0000000..e1ae66e Binary files /dev/null and b/cmd/font-icon-extractor/testdata/references/icon_E115.png differ diff --git a/cmd/font-icon-extractor/testdata/references/icon_E115.svg b/cmd/font-icon-extractor/testdata/references/icon_E115.svg new file mode 100644 index 0000000..69c426e --- /dev/null +++ b/cmd/font-icon-extractor/testdata/references/icon_E115.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file