2026-01-01 21:05:54 +00:00

438 lines
10 KiB
Go

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/draw"
"image/png"
"io"
"math"
"math/rand"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
)
type EmojiSpritesConfig struct {
NonDiversityPerRow int
DiversityPerRow int
PickerPerRow int
PickerCount int
}
var EMOJI_SPRITES = EmojiSpritesConfig{
NonDiversityPerRow: 42,
DiversityPerRow: 10,
PickerPerRow: 11,
PickerCount: 50,
}
const (
EMOJI_SIZE = 32
TWEMOJI_CDN = "https://fluxerstatic.com/emoji"
)
var SPRITE_SCALES = []int{1, 2}
type EmojiObject struct {
Surrogates string `json:"surrogates"`
Skins []struct {
Surrogates string `json:"surrogates"`
} `json:"skins,omitempty"`
}
type EmojiEntry struct {
Surrogates string
}
type httpResp struct {
Status int
Body string
}
func main() {
rand.Seed(time.Now().UnixNano())
cwd, _ := os.Getwd()
appDir := filepath.Join(cwd, "..")
outputDir := filepath.Join(appDir, "src", "assets", "emoji-sprites")
if err := os.MkdirAll(outputDir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "Failed to ensure output dir:", err)
os.Exit(1)
}
emojiData, err := loadEmojiData(filepath.Join(appDir, "src", "data", "emojis.json"))
if err != nil {
fmt.Fprintln(os.Stderr, "Error loading emoji data:", err)
os.Exit(1)
}
client := &http.Client{
Timeout: 30 * time.Second,
}
svgCache := newSVGCache()
if err := generateMainSpriteSheet(client, svgCache, emojiData, outputDir); err != nil {
fmt.Fprintln(os.Stderr, "Error generating main sprite sheet:", err)
os.Exit(1)
}
if err := generateDiversitySpriteSheets(client, svgCache, emojiData, outputDir); err != nil {
fmt.Fprintln(os.Stderr, "Error generating diversity sprite sheets:", err)
os.Exit(1)
}
if err := generatePickerSpriteSheet(client, svgCache, outputDir); err != nil {
fmt.Fprintln(os.Stderr, "Error generating picker sprite sheet:", err)
os.Exit(1)
}
fmt.Println("Emoji sprites generated successfully.")
}
func loadEmojiData(path string) (map[string][]EmojiObject, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var data map[string][]EmojiObject
if err := json.Unmarshal(b, &data); err != nil {
return nil, err
}
return data, nil
}
// --- SVG fetching + caching ---
type svgCache struct {
m map[string]*string
}
func newSVGCache() *svgCache {
return &svgCache{m: make(map[string]*string)}
}
func (c *svgCache) get(codepoint string) (*string, bool) {
v, ok := c.m[codepoint]
return v, ok
}
func (c *svgCache) set(codepoint string, v *string) {
c.m[codepoint] = v
}
func downloadSVG(client *http.Client, url string) (httpResp, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return httpResp{}, err
}
req.Header.Set("User-Agent", "fluxer-emoji-sprites/1.0")
resp, err := client.Do(req)
if err != nil {
return httpResp{}, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return httpResp{}, err
}
return httpResp{
Status: resp.StatusCode,
Body: string(bodyBytes),
}, nil
}
func fetchTwemojiSVG(client *http.Client, cache *svgCache, codepoint string) *string {
if v, ok := cache.get(codepoint); ok {
return v
}
url := fmt.Sprintf("%s/%s.svg", TWEMOJI_CDN, codepoint)
r, err := downloadSVG(client, url)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch Twemoji %s: %v\n", codepoint, err)
cache.set(codepoint, nil)
return nil
}
if r.Status != 200 {
fmt.Fprintf(os.Stderr, "Twemoji %s returned %d\n", codepoint, r.Status)
cache.set(codepoint, nil)
return nil
}
body := r.Body
cache.set(codepoint, &body)
return &body
}
// --- Emoji -> codepoint ---
func emojiToCodepoint(s string) string {
parts := make([]string, 0, len(s))
for _, r := range s {
if r == 0xFE0F {
continue
}
parts = append(parts, strings.ToLower(strconv.FormatInt(int64(r), 16)))
}
return strings.Join(parts, "-")
}
// --- Rendering ---
var svgOpenTagRe = regexp.MustCompile(`(?i)<svg([^>]*)>`)
func fixSVGSize(svg string, size int) string {
return svgOpenTagRe.ReplaceAllString(svg, fmt.Sprintf(`<svg$1 width="%d" height="%d">`, size, size))
}
func renderSVGToImage(svgContent string, size int) (*image.RGBA, error) {
fixed := fixSVGSize(svgContent, size)
icon, err := oksvg.ReadIconStream(strings.NewReader(fixed))
if err != nil {
return nil, err
}
icon.SetTarget(0, 0, float64(size), float64(size))
dst := image.NewRGBA(image.Rect(0, 0, size, size))
scanner := rasterx.NewScannerGV(size, size, dst, dst.Bounds())
r := rasterx.NewDasher(size, size, scanner)
icon.Draw(r, 1.0)
return dst, nil
}
func createPlaceholder(size int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, size, size))
h := rand.Float64() * 360.0
r, g, b := hslToRGB(h, 0.70, 0.60)
cx := float64(size) / 2.0
cy := float64(size) / 2.0
radius := float64(size) * 0.4
r2 := radius * radius
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
dx := (float64(x) + 0.5) - cx
dy := (float64(y) + 0.5) - cy
if dx*dx+dy*dy <= r2 {
i := img.PixOffset(x, y)
img.Pix[i+0] = r
img.Pix[i+1] = g
img.Pix[i+2] = b
img.Pix[i+3] = 0xFF
}
}
}
return img
}
func hslToRGB(h, s, l float64) (uint8, uint8, uint8) {
h = math.Mod(h, 360.0) / 360.0
var r, g, b float64
if s == 0 {
r, g, b = l, l, l
} else {
var q float64
if l < 0.5 {
q = l * (1 + s)
} else {
q = l + s - l*s
}
p := 2*l - q
r = hueToRGB(p, q, h+1.0/3.0)
g = hueToRGB(p, q, h)
b = hueToRGB(p, q, h-1.0/3.0)
}
return uint8(clamp01(r) * 255), uint8(clamp01(g) * 255), uint8(clamp01(b) * 255)
}
func hueToRGB(p, q, t float64) float64 {
if t < 0 {
t += 1
}
if t > 1 {
t -= 1
}
if t < 1.0/6.0 {
return p + (q-p)*6*t
}
if t < 1.0/2.0 {
return q
}
if t < 2.0/3.0 {
return p + (q-p)*(2.0/3.0-t)*6
}
return p
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func loadEmojiImage(client *http.Client, cache *svgCache, surrogate string, size int) *image.RGBA {
codepoint := emojiToCodepoint(surrogate)
if svg := fetchTwemojiSVG(client, cache, codepoint); svg != nil {
if img, err := renderSVGToImage(*svg, size); err == nil {
return img
}
}
if strings.Contains(codepoint, "-200d-") {
basePart := strings.Split(codepoint, "-200d-")[0]
if svg := fetchTwemojiSVG(client, cache, basePart); svg != nil {
if img, err := renderSVGToImage(*svg, size); err == nil {
return img
}
}
}
fmt.Fprintf(os.Stderr, "Missing SVG for %s (%s), using placeholder\n", codepoint, surrogate)
return createPlaceholder(size)
}
func renderSpriteSheet(client *http.Client, cache *svgCache, emojiEntries []EmojiEntry, perRow int, fileNameBase string, outputDir string) error {
if perRow <= 0 {
return fmt.Errorf("perRow must be > 0")
}
rows := int(math.Ceil(float64(len(emojiEntries)) / float64(perRow)))
for _, scale := range SPRITE_SCALES {
size := EMOJI_SIZE * scale
dstW := perRow * size
dstH := rows * size
sheet := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for i, item := range emojiEntries {
emojiImg := loadEmojiImage(client, cache, item.Surrogates, size)
row := i / perRow
col := i % perRow
x := col * size
y := row * size
r := image.Rect(x, y, x+size, y+size)
draw.Draw(sheet, r, emojiImg, image.Point{}, draw.Over)
}
suffix := ""
if scale != 1 {
suffix = fmt.Sprintf("@%dx", scale)
}
outPath := filepath.Join(outputDir, fmt.Sprintf("%s%s.png", fileNameBase, suffix))
if err := writePNG(outPath, sheet); err != nil {
return err
}
fmt.Printf("Wrote %s\n", outPath)
}
return nil
}
func writePNG(path string, img image.Image) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}
// --- Generators ---
func generateMainSpriteSheet(client *http.Client, cache *svgCache, emojiData map[string][]EmojiObject, outputDir string) error {
base := make([]EmojiEntry, 0, 4096)
for _, objs := range emojiData {
for _, obj := range objs {
base = append(base, EmojiEntry{Surrogates: obj.Surrogates})
}
}
return renderSpriteSheet(client, cache, base, EMOJI_SPRITES.NonDiversityPerRow, "spritesheet-emoji", outputDir)
}
func generateDiversitySpriteSheets(client *http.Client, cache *svgCache, emojiData map[string][]EmojiObject, outputDir string) error {
skinTones := []string{"🏻", "🏼", "🏽", "🏾", "🏿"}
for skinIndex, skinTone := range skinTones {
skinCodepoint := emojiToCodepoint(skinTone)
skinEntries := make([]EmojiEntry, 0, 2048)
for _, objs := range emojiData {
for _, obj := range objs {
if len(obj.Skins) > skinIndex && obj.Skins[skinIndex].Surrogates != "" {
skinEntries = append(skinEntries, EmojiEntry{Surrogates: obj.Skins[skinIndex].Surrogates})
}
}
}
if len(skinEntries) == 0 {
continue
}
if err := renderSpriteSheet(client, cache, skinEntries, EMOJI_SPRITES.DiversityPerRow, "spritesheet-"+skinCodepoint, outputDir); err != nil {
return err
}
}
return nil
}
func generatePickerSpriteSheet(client *http.Client, cache *svgCache, outputDir string) error {
basicEmojis := []string{
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "😊", "😇",
"🙂", "😉", "😌", "😍", "🥰", "😘", "😗", "😙", "😚", "😋",
"😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓", "😎", "🥳", "😏",
}
entries := make([]EmojiEntry, 0, len(basicEmojis))
for _, e := range basicEmojis {
entries = append(entries, EmojiEntry{Surrogates: e})
}
return renderSpriteSheet(client, cache, entries, EMOJI_SPRITES.PickerPerRow, "spritesheet-picker", outputDir)
}