initial commit
This commit is contained in:
290
fluxer_app/scripts/cmd/generate-color-system/main.go
Normal file
290
fluxer_app/scripts/cmd/generate-color-system/main.go
Normal file
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* 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 (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type ColorFamily struct {
|
||||
Hue int `yaml:"hue" json:"hue"`
|
||||
Saturation int `yaml:"saturation" json:"saturation"`
|
||||
UseSaturationFactor bool `yaml:"useSaturationFactor" json:"useSaturationFactor"`
|
||||
}
|
||||
|
||||
type ScaleStop struct {
|
||||
Name string `yaml:"name"`
|
||||
Position *float64 `yaml:"position"`
|
||||
}
|
||||
|
||||
type Scale struct {
|
||||
Family string `yaml:"family"`
|
||||
Range [2]float64 `yaml:"range"`
|
||||
Curve string `yaml:"curve"`
|
||||
Stops []ScaleStop `yaml:"stops"`
|
||||
}
|
||||
|
||||
type TokenDef struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Scale string `yaml:"scale,omitempty"`
|
||||
Value string `yaml:"value,omitempty"`
|
||||
Family string `yaml:"family,omitempty"`
|
||||
Hue *int `yaml:"hue,omitempty"`
|
||||
Saturation *int `yaml:"saturation,omitempty"`
|
||||
Lightness *float64 `yaml:"lightness,omitempty"`
|
||||
Alpha *float64 `yaml:"alpha,omitempty"`
|
||||
UseSaturationFactor *bool `yaml:"useSaturationFactor,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Families map[string]ColorFamily `yaml:"families"`
|
||||
Scales map[string]Scale `yaml:"scales"`
|
||||
Tokens struct {
|
||||
Root []TokenDef `yaml:"root"`
|
||||
Light []TokenDef `yaml:"light"`
|
||||
Coal []TokenDef `yaml:"coal"`
|
||||
} `yaml:"tokens"`
|
||||
}
|
||||
|
||||
type OutputToken struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Family string `json:"family,omitempty"`
|
||||
Hue *int `json:"hue,omitempty"`
|
||||
Saturation *int `json:"saturation,omitempty"`
|
||||
Lightness *float64 `json:"lightness,omitempty"`
|
||||
Alpha *float64 `json:"alpha,omitempty"`
|
||||
UseSaturationFactor *bool `json:"useSaturationFactor,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
func clamp01(value float64) float64 {
|
||||
return math.Min(1, math.Max(0, value))
|
||||
}
|
||||
|
||||
func applyCurve(curve string, t float64) float64 {
|
||||
switch curve {
|
||||
case "easeIn":
|
||||
return t * t
|
||||
case "easeOut":
|
||||
return 1 - (1-t)*(1-t)
|
||||
case "easeInOut":
|
||||
if t < 0.5 {
|
||||
return 2 * t * t
|
||||
}
|
||||
return 1 - 2*(1-t)*(1-t)
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
func buildScaleTokens(scale Scale) []OutputToken {
|
||||
lastIndex := float64(max(len(scale.Stops)-1, 1))
|
||||
tokens := make([]OutputToken, 0, len(scale.Stops))
|
||||
|
||||
for i, stop := range scale.Stops {
|
||||
pos := 0.0
|
||||
if stop.Position != nil {
|
||||
pos = clamp01(*stop.Position)
|
||||
} else {
|
||||
pos = float64(i) / lastIndex
|
||||
}
|
||||
|
||||
eased := applyCurve(scale.Curve, pos)
|
||||
lightness := scale.Range[0] + (scale.Range[1]-scale.Range[0])*eased
|
||||
lightness = math.Round(lightness*1000) / 1000
|
||||
|
||||
tokens = append(tokens, OutputToken{
|
||||
Type: "tone",
|
||||
Name: stop.Name,
|
||||
Family: scale.Family,
|
||||
Lightness: &lightness,
|
||||
})
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func expandTokens(defs []TokenDef, scales map[string]Scale) []OutputToken {
|
||||
var tokens []OutputToken
|
||||
|
||||
for _, def := range defs {
|
||||
if def.Scale != "" {
|
||||
scale, ok := scales[def.Scale]
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "Warning: unknown scale %q\n", def.Scale)
|
||||
continue
|
||||
}
|
||||
tokens = append(tokens, buildScaleTokens(scale)...)
|
||||
continue
|
||||
}
|
||||
|
||||
if def.Value != "" {
|
||||
tokens = append(tokens, OutputToken{
|
||||
Type: "literal",
|
||||
Name: def.Name,
|
||||
Value: strings.TrimSpace(def.Value),
|
||||
})
|
||||
} else {
|
||||
tokens = append(tokens, OutputToken{
|
||||
Type: "tone",
|
||||
Name: def.Name,
|
||||
Family: def.Family,
|
||||
Hue: def.Hue,
|
||||
Saturation: def.Saturation,
|
||||
Lightness: def.Lightness,
|
||||
Alpha: def.Alpha,
|
||||
UseSaturationFactor: def.UseSaturationFactor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
func formatNumber(value float64) string {
|
||||
if value == float64(int(value)) {
|
||||
return fmt.Sprintf("%d", int(value))
|
||||
}
|
||||
s := fmt.Sprintf("%.2f", value)
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimRight(s, ".")
|
||||
return s
|
||||
}
|
||||
|
||||
func formatTone(token OutputToken, families map[string]ColorFamily) string {
|
||||
var family *ColorFamily
|
||||
if token.Family != "" {
|
||||
if f, ok := families[token.Family]; ok {
|
||||
family = &f
|
||||
}
|
||||
}
|
||||
|
||||
var hue, saturation int
|
||||
var lightness float64
|
||||
var useFactor bool
|
||||
|
||||
if token.Hue != nil {
|
||||
hue = *token.Hue
|
||||
} else if family != nil {
|
||||
hue = family.Hue
|
||||
}
|
||||
|
||||
if token.Saturation != nil {
|
||||
saturation = *token.Saturation
|
||||
} else if family != nil {
|
||||
saturation = family.Saturation
|
||||
}
|
||||
|
||||
if token.Lightness != nil {
|
||||
lightness = *token.Lightness
|
||||
}
|
||||
|
||||
if token.UseSaturationFactor != nil {
|
||||
useFactor = *token.UseSaturationFactor
|
||||
} else if family != nil {
|
||||
useFactor = family.UseSaturationFactor
|
||||
}
|
||||
|
||||
var satStr string
|
||||
if useFactor {
|
||||
satStr = fmt.Sprintf("calc(%s%% * var(--saturation-factor))", formatNumber(float64(saturation)))
|
||||
} else {
|
||||
satStr = fmt.Sprintf("%s%%", formatNumber(float64(saturation)))
|
||||
}
|
||||
|
||||
if token.Alpha == nil {
|
||||
return fmt.Sprintf("hsl(%s, %s, %s%%)", formatNumber(float64(hue)), satStr, formatNumber(lightness))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("hsla(%s, %s, %s%%, %s)", formatNumber(float64(hue)), satStr, formatNumber(lightness), formatNumber(*token.Alpha))
|
||||
}
|
||||
|
||||
func formatValue(token OutputToken, families map[string]ColorFamily) string {
|
||||
if token.Type == "tone" {
|
||||
return formatTone(token, families)
|
||||
}
|
||||
return strings.TrimSpace(token.Value)
|
||||
}
|
||||
|
||||
func renderBlock(selector string, tokens []OutputToken, families map[string]ColorFamily) string {
|
||||
var lines []string
|
||||
for _, token := range tokens {
|
||||
lines = append(lines, fmt.Sprintf("\t%s: %s;", token.Name, formatValue(token, families)))
|
||||
}
|
||||
return fmt.Sprintf("%s {\n%s\n}", selector, strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func generateCSS(cfg *Config, rootTokens, lightTokens, coalTokens []OutputToken) string {
|
||||
header := `/*
|
||||
* This file is auto-generated by scripts/cmd/generate-color-system.
|
||||
* Do not edit directly — update color-system.yaml instead.
|
||||
*/`
|
||||
|
||||
blocks := []string{
|
||||
renderBlock(":root", rootTokens, cfg.Families),
|
||||
renderBlock(".theme-light", lightTokens, cfg.Families),
|
||||
renderBlock(".theme-coal", coalTokens, cfg.Families),
|
||||
}
|
||||
|
||||
return header + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
|
||||
}
|
||||
|
||||
func main() {
|
||||
cwd, _ := os.Getwd()
|
||||
configPath := filepath.Join(cwd, "color-system.yaml")
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rootTokens := expandTokens(cfg.Tokens.Root, cfg.Scales)
|
||||
lightTokens := expandTokens(cfg.Tokens.Light, cfg.Scales)
|
||||
coalTokens := expandTokens(cfg.Tokens.Coal, cfg.Scales)
|
||||
|
||||
parentDir := filepath.Join(cwd, "..")
|
||||
cssPath := filepath.Join(parentDir, "src", "styles", "generated", "color-system.css")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cssPath), 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating CSS directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
css := generateCSS(&cfg, rootTokens, lightTokens, coalTokens)
|
||||
if err := os.WriteFile(cssPath, []byte(css), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing CSS file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
relCSS, _ := filepath.Rel(parentDir, cssPath)
|
||||
fmt.Printf("Wrote %s\n", relCSS)
|
||||
}
|
||||
437
fluxer_app/scripts/cmd/generate-emoji-sprites/main.go
Normal file
437
fluxer_app/scripts/cmd/generate-emoji-sprites/main.go
Normal file
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
2129
fluxer_app/scripts/cmd/locales-pending/main.go
Normal file
2129
fluxer_app/scripts/cmd/locales-pending/main.go
Normal file
File diff suppressed because it is too large
Load Diff
279
fluxer_app/scripts/cmd/locales-pending/reset/main.go
Normal file
279
fluxer_app/scripts/cmd/locales-pending/reset/main.go
Normal file
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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 (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type POFile struct {
|
||||
HeaderLines []string
|
||||
Entries []POEntry
|
||||
}
|
||||
|
||||
type POEntry struct {
|
||||
Comments []string
|
||||
References []string
|
||||
MsgID string
|
||||
MsgStr string
|
||||
}
|
||||
|
||||
func main() {
|
||||
localesDir := flag.String("locales-dir", "../../../../src/locales", "Path to the locales directory")
|
||||
singleLocale := flag.String("locale", "", "Reset only this locale (empty = all)")
|
||||
dryRun := flag.Bool("dry-run", false, "Show what would be reset without making changes")
|
||||
flag.Parse()
|
||||
|
||||
absLocalesDir, err := absPath(*localesDir)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to resolve locales directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
locales, err := discoverLocales(absLocalesDir)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to discover locales: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var targetLocales []string
|
||||
for _, locale := range locales {
|
||||
if locale == "en-US" {
|
||||
continue
|
||||
}
|
||||
if *singleLocale != "" && locale != *singleLocale {
|
||||
continue
|
||||
}
|
||||
targetLocales = append(targetLocales, locale)
|
||||
}
|
||||
|
||||
if len(targetLocales) == 0 {
|
||||
fmt.Println("No target locales found")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Resetting translations for %d locales...\n", len(targetLocales))
|
||||
if *dryRun {
|
||||
fmt.Println("(DRY RUN - no changes will be made)")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
totalReset := 0
|
||||
for _, locale := range targetLocales {
|
||||
poPath := filepath.Join(absLocalesDir, locale, "messages.po")
|
||||
poFile, err := parsePOFile(poPath)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ %s: failed to parse: %v\n", locale, err)
|
||||
continue
|
||||
}
|
||||
|
||||
resetCount := 0
|
||||
for i := range poFile.Entries {
|
||||
if poFile.Entries[i].MsgStr != "" {
|
||||
resetCount++
|
||||
poFile.Entries[i].MsgStr = ""
|
||||
}
|
||||
}
|
||||
|
||||
if resetCount == 0 {
|
||||
fmt.Printf(" - %s: already empty (0 strings)\n", locale)
|
||||
continue
|
||||
}
|
||||
|
||||
if !*dryRun {
|
||||
if err := writePOFile(poPath, poFile); err != nil {
|
||||
fmt.Printf(" ✗ %s: failed to write: %v\n", locale, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" ✓ %s: reset %d strings\n", locale, resetCount)
|
||||
totalReset += resetCount
|
||||
}
|
||||
|
||||
fmt.Printf("\nTotal: reset %d translations across %d locales\n", totalReset, len(targetLocales))
|
||||
if *dryRun {
|
||||
fmt.Println("(DRY RUN - run without --dry-run to apply changes)")
|
||||
}
|
||||
}
|
||||
|
||||
func absPath(rel string) (string, error) {
|
||||
if filepath.IsAbs(rel) {
|
||||
return rel, nil
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(wd, rel), nil
|
||||
}
|
||||
|
||||
func discoverLocales(localesDir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(localesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var locales []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
locales = append(locales, entry.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(locales)
|
||||
return locales, nil
|
||||
}
|
||||
|
||||
func parsePOFile(path string) (POFile, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return POFile{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var (
|
||||
current []string
|
||||
scanner = bufio.NewScanner(file)
|
||||
trimmed string
|
||||
headerSet bool
|
||||
result POFile
|
||||
)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed = strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
if len(current) > 0 {
|
||||
entry := parseBlock(current)
|
||||
if !headerSet && entry.MsgID == "" {
|
||||
result.HeaderLines = append([]string{}, current...)
|
||||
headerSet = true
|
||||
} else {
|
||||
result.Entries = append(result.Entries, entry)
|
||||
}
|
||||
current = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
current = append(current, line)
|
||||
}
|
||||
if len(current) > 0 {
|
||||
entry := parseBlock(current)
|
||||
if !headerSet && entry.MsgID == "" {
|
||||
result.HeaderLines = append([]string{}, current...)
|
||||
} else {
|
||||
result.Entries = append(result.Entries, entry)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return POFile{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseBlock(lines []string) POEntry {
|
||||
entry := POEntry{}
|
||||
var (
|
||||
inMsgID bool
|
||||
inMsgStr bool
|
||||
)
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "#."):
|
||||
entry.Comments = append(entry.Comments, strings.TrimSpace(line[2:]))
|
||||
case strings.HasPrefix(line, "#:"):
|
||||
entry.References = append(entry.References, strings.TrimSpace(line[2:]))
|
||||
case strings.HasPrefix(line, "msgid"):
|
||||
entry.MsgID = parseQuoted(strings.TrimSpace(line[len("msgid"):]))
|
||||
inMsgID = true
|
||||
inMsgStr = false
|
||||
case strings.HasPrefix(line, "msgstr"):
|
||||
entry.MsgStr = parseQuoted(strings.TrimSpace(line[len("msgstr"):]))
|
||||
inMsgStr = true
|
||||
inMsgID = false
|
||||
case strings.HasPrefix(line, "\""):
|
||||
if inMsgID {
|
||||
entry.MsgID += parseQuoted(line)
|
||||
} else if inMsgStr {
|
||||
entry.MsgStr += parseQuoted(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func parseQuoted(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if unquoted, err := strconv.Unquote(value); err == nil {
|
||||
return unquoted
|
||||
}
|
||||
if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
|
||||
return value[1 : len(value)-1]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func writePOFile(path string, po POFile) error {
|
||||
var lines []string
|
||||
if len(po.HeaderLines) > 0 {
|
||||
lines = append(lines, po.HeaderLines...)
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
for idx, entry := range po.Entries {
|
||||
lines = append(lines, renderEntry(entry))
|
||||
if idx < len(po.Entries)-1 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
|
||||
}
|
||||
|
||||
func renderEntry(entry POEntry) string {
|
||||
var sb strings.Builder
|
||||
for _, comment := range entry.Comments {
|
||||
sb.WriteString("#. ")
|
||||
sb.WriteString(comment)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
for _, ref := range entry.References {
|
||||
sb.WriteString("#: ")
|
||||
sb.WriteString(ref)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("msgid ")
|
||||
sb.WriteString(strconv.Quote(entry.MsgID))
|
||||
sb.WriteString("\nmsgstr ")
|
||||
sb.WriteString(strconv.Quote(entry.MsgStr))
|
||||
return sb.String()
|
||||
}
|
||||
66
fluxer_app/scripts/cmd/set-build-channel/main.go
Normal file
66
fluxer_app/scripts/cmd/set-build-channel/main.go
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
var allowedChannels = []string{"stable", "canary"}
|
||||
|
||||
func parseChannel() string {
|
||||
raw := os.Getenv("BUILD_CHANNEL")
|
||||
if raw != "" && slices.Contains(allowedChannels, raw) {
|
||||
return raw
|
||||
}
|
||||
return "stable"
|
||||
}
|
||||
|
||||
func main() {
|
||||
channel := parseChannel()
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
targetPath := filepath.Join(cwd, "..", "src-electron", "common", "build-channel.ts")
|
||||
|
||||
fileContent := fmt.Sprintf(`/*
|
||||
* This file is generated by scripts/cmd/set-build-channel.
|
||||
* DO NOT EDIT MANUALLY.
|
||||
*/
|
||||
|
||||
export type BuildChannel = 'stable' | 'canary';
|
||||
|
||||
const DEFAULT_BUILD_CHANNEL = '%s' as BuildChannel;
|
||||
|
||||
const envChannel = process.env.BUILD_CHANNEL?.toLowerCase();
|
||||
export const BUILD_CHANNEL = (envChannel === 'canary' ? 'canary' : DEFAULT_BUILD_CHANNEL) as BuildChannel;
|
||||
export const IS_CANARY = BUILD_CHANNEL === 'canary';
|
||||
export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;
|
||||
`, channel)
|
||||
|
||||
if err := os.WriteFile(targetPath, []byte(fileContent), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Wrote %s with channel '%s'\n", targetPath, channel)
|
||||
}
|
||||
Reference in New Issue
Block a user