Ikon: cari per domain/URL gambar + semua format dikonversi ke PNG

- Dialog ikon: nama aplikasi -> Iconify; domain (mikrotik.com) -> ikon resmi situs
  (manifest 512, apple-touch-icon, favicon) + layanan favicon; URL gambar -> langsung
- Contoh kasus: 'winbox' cuma punya 1 ikon tidak nyambung di Iconify, tapi
  'mikrotik.com' memberi ikon resminya (5 pilihan, semuanya PNG)
- Decoder ICO: entri BMP 24/32-bit (favicon.ico lama) ikut didukung, bukan cuma PNG
- FromURL/Fetch selalu mengembalikan PNG; hasil raster/konversi seragam 512px
- Tes: deteksi URL gambar/domain, daftar ikon situs, decode ICO BMP, konversi format
This commit is contained in:
oxpoe committed 2026-10-13 08:00:12 +07:00
1 parent cad41caaee
commit 59145910b5
9 files changed
+441 -33

No files matched your search

+79 -14
View File
@@ -36,18 +36,17 @@ func Fetch(pageURL string) ([]byte, string, error) {
var lastErr error
for _, candidate := range iconCandidates(base) {
data, err := get(candidate)
data, err := get(candidate.url)
if err != nil {
lastErr = err
continue
}
if ext := sniffExt(data); ext != "" {
return data, ext, nil
converted, err := ToPNG(data)
if err != nil {
lastErr = fmt.Errorf("%s: %w", candidate.url, err)
continue
}
if png := largestPNGFromICO(data); png != nil {
return png, ".png", nil
}
lastErr = errors.New("unsupported icon format at " + candidate)
return converted, ".png", nil
}
if lastErr == nil {
lastErr = errors.New("no favicon found")
@@ -105,24 +104,86 @@ func manifestIcons(page *url.URL) []iconCandidate {
if err != nil {
continue
}
score := 0
score, label := 0, "manifest"
if entry.Sizes != "" {
label = "manifest " + entry.Sizes
}
if size := attr(sizeAttr, `sizes="`+entry.Sizes+`"`); size != "" {
fmt.Sscanf(size, "%d", &score)
}
candidates = append(candidates, iconCandidate{base.ResolveReference(ref).String(), score})
candidates = append(candidates, iconCandidate{url: base.ResolveReference(ref).String(), label: label, score: score})
}
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
return candidates
}
// SiteIcons mengembalikan semua ikon yang ditawarkan sebuah situs (manifest,
// apple-touch-icon, favicon) plus versi dari layanan favicon.
// Dipakai untuk memasang ikon aplikasi dari domain resminya.
func SiteIcons(pageURL string) ([]Result, error) {
base, err := normalizePageURL(pageURL)
if err != nil {
return nil, err
}
var out []Result
seen := map[string]bool{}
add := func(c iconCandidate) {
if c.url == "" || seen[c.url] {
return
}
seen[c.url] = true
out = append(out, Result{Name: c.label, URL: c.url})
}
for _, c := range iconCandidates(base) {
add(c)
}
if base.Hostname() != "" {
add(faviconService(base.Hostname()))
}
if len(out) == 0 {
return nil, errors.New("situs ini tidak menyediakan ikon")
}
return out, nil
}
// faviconService memakai layanan favicon Google sebagai pilihan tambahan.
func faviconService(domain string) iconCandidate {
return iconCandidate{
url: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", url.QueryEscape(domain)),
label: "google " + domain,
score: 5,
}
}
// normalizePageURL menambah skema https kalau user menulis domain saja.
func normalizePageURL(raw string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, errors.New("alamat kosong")
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
base, err := url.Parse(raw)
if err != nil {
return nil, err
}
if base.Host == "" {
return nil, errors.New("domain tidak valid: " + raw)
}
return base, nil
}
// iconCandidate adalah kandidat URL ikon dengan skor prioritas.
type iconCandidate struct {
url string
label string // keterangan untuk ditampilkan di UI (mis. "manifest 512x512")
score int
}
// iconCandidates mengumpulkan URL ikon dari HTML, ikon besar didahulukan, favicon.ico terakhir.
func iconCandidates(base *url.URL) []string {
func iconCandidates(base *url.URL) []iconCandidate {
var found []iconCandidate
if page, err := get(base.String()); err == nil {
@@ -147,24 +208,28 @@ func iconCandidates(base *url.URL) []string {
fmt.Sscanf(size, "%d", &score)
score += 10
}
found = append(found, iconCandidate{base.ResolveReference(ref).String(), score})
label := rel
if size := attr(sizeAttr, tag); size != "" {
label += " " + size
}
found = append(found, iconCandidate{url: base.ResolveReference(ref).String(), label: label, score: score})
}
}
sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score })
found = append(manifestIcons(base), found...)
out := make([]string, 0, len(found)+1)
out := make([]iconCandidate, 0, len(found)+1)
seen := map[string]bool{}
for _, c := range found {
if !seen[c.url] {
seen[c.url] = true
out = append(out, c.url)
out = append(out, c)
}
}
fallback := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String()
if !seen[fallback] {
out = append(out, fallback)
out = append(out, iconCandidate{url: fallback, label: "favicon.ico"})
}
return out
}
+15 -1
View File
@@ -1,6 +1,10 @@
package icon
import (
"bytes"
"image"
"image/color"
"image/png"
"io"
"net/http"
"net/http/httptest"
@@ -23,7 +27,17 @@ func icoWithPNG(png []byte) []byte {
return ico
}
var pngBytes = append([]byte("\x89PNG\r\n\x1a\n"), []byte("payload")...)
// pngBytes adalah PNG asli (kecil) untuk tes: ICO yang berisi PNG harus benar-benar
// bisa di-decode, bukan sekadar punya magic bytes.
var pngBytes = func() []byte {
img := image.NewNRGBA(image.Rect(0, 0, 8, 8))
img.Set(4, 4, color.NRGBA{R: 0x20, G: 0x80, B: 0xE0, A: 0xFF})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
panic(err)
}
return buf.Bytes()
}()
func TestFetchUsesLinkedIcon(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+109
View File
@@ -0,0 +1,109 @@
package icon
import (
"bytes"
"encoding/binary"
"errors"
"image"
"image/color"
"image/png"
)
// decodeICO mengambil gambar terbesar di dalam file .ico.
//
// ICO bisa berisi PNG (modern) atau DIB/BMP (format lama, masih sering dipakai
// favicon). Keduanya didukung: BMP 32-bit pakai alpha, 24-bit pakai AND mask.
// Entri dengan palet (<= 8-bit) tidak didukung.
func decodeICO(data []byte) (image.Image, error) {
if pngData := largestPNGFromICO(data); pngData != nil {
return png.Decode(bytes.NewReader(pngData))
}
if len(data) < 22 || data[0] != 0 || data[1] != 0 || data[2] != 1 || data[3] != 0 {
return nil, errors.New("bukan file ICO")
}
count := int(data[4]) | int(data[5])<<8
var best image.Image
bestSize := 0
for i := 0; i < count; i++ {
off := 6 + i*16
if off+16 > len(data) {
break
}
width := int(data[off])
if width == 0 {
width = 256
}
size := int(data[off+8]) | int(data[off+9])<<8 | int(data[off+10])<<16 | int(data[off+11])<<24
start := int(data[off+12]) | int(data[off+13])<<8 | int(data[off+14])<<16 | int(data[off+15])<<24
if size <= 0 || start <= 0 || start+size > len(data) {
continue
}
if width*width <= bestSize {
continue
}
img, err := decodeDIB(data[start : start+size])
if err != nil {
continue
}
best, bestSize = img, width*width
}
if best == nil {
return nil, errors.New("tidak ada gambar yang bisa dibaca di dalam .ico")
}
return best, nil
}
// decodeDIB membaca gambar DIB/BMP di dalam entri ICO.
func decodeDIB(dib []byte) (image.Image, error) {
if len(dib) < 40 {
return nil, errors.New("entri ICO terlalu pendek")
}
headerSize := int(binary.LittleEndian.Uint32(dib[0:4]))
width := int(int32(binary.LittleEndian.Uint32(dib[4:8])))
height := int(int32(binary.LittleEndian.Uint32(dib[8:12]))) / 2 // tinggi DIB = XOR + AND mask
bitCount := int(binary.LittleEndian.Uint16(dib[14:16]))
compression := int(binary.LittleEndian.Uint32(dib[16:20]))
if compression != 0 {
return nil, errors.New("entri ICO terkompresi tidak didukung")
}
if width <= 0 || height <= 0 || width > 512 || height > 512 {
return nil, errors.New("ukuran entri ICO tidak wajar")
}
if bitCount != 24 && bitCount != 32 {
return nil, errors.New("hanya ICO 24/32-bit yang didukung")
}
stride := (width*bitCount/8 + 3) & ^3
start := headerSize
if start+stride*height > len(dib) {
return nil, errors.New("data piksel ICO terpotong")
}
img := image.NewNRGBA(image.Rect(0, 0, width, height))
allTransparent := bitCount == 32
for y := 0; y < height; y++ {
row := dib[start+(height-1-y)*stride:] // DIB disimpan dari bawah ke atas
for x := 0; x < width; x++ {
px := row[x*bitCount/8:]
var alpha uint8 = 0xFF
if bitCount == 32 {
alpha = px[3]
if alpha != 0 {
allTransparent = false
}
}
img.SetNRGBA(x, y, color.NRGBA{R: px[2], G: px[1], B: px[0], A: alpha})
}
}
// sebagian ICO 32-bit tidak mengisi kanal alpha sama sekali
if allTransparent {
for i := 3; i < len(img.Pix); i += 4 {
img.Pix[i] = 0xFF
}
}
return img, nil
}
+3 -3
View File
@@ -86,9 +86,9 @@ func ToPNG(data []byte) ([]byte, error) {
return encodePNG(img)
}
// ico bisa berisi PNG langsung
if png := largestPNGFromICO(data); png != nil {
return png, nil
// ico: bisa berisi PNG langsung atau gambar BMP di dalamnya
if img, err := decodeICO(data); err == nil {
return encodePNG(img)
}
return nil, errors.New("format ikon tidak didukung")
}
+61 -5
View File
@@ -161,7 +161,9 @@ func queryVariants(query string) []string {
return out
}
// FromURL mengunduh ikon dari URL dan menebak ekstensinya dari isi file.
// FromURL mengunduh ikon dari URL. Hasilnya selalu PNG: format apa pun
// (svg, webp, gif, ico, jpeg) dikonversi dulu supaya desktop environment
// tidak perlu mendukung banyak format.
func FromURL(rawURL string) ([]byte, string, error) {
data, err := get(rawURL)
if err != nil {
@@ -172,11 +174,65 @@ func FromURL(rawURL string) ([]byte, string, error) {
if err != nil {
return nil, "", err
}
ext := sniffExt(data)
if ext == "" {
return nil, "", fmt.Errorf("unsupported icon format at %s", rawURL)
converted, err := ToPNG(data)
if err != nil {
return nil, "", fmt.Errorf("%s: %w", rawURL, err)
}
return data, ext, nil
return converted, ".png", nil
}
// imageExts adalah ekstensi gambar yang bisa dipakai langsung sebagai ikon.
var imageExts = []string{".png", ".svg", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".ico", ".xpm"}
// IsImageURL benar kalau teks yang diketik user berupa URL gambar.
func IsImageURL(text string) bool {
text = strings.TrimSpace(text)
if !strings.Contains(text, "://") {
return false
}
lower := strings.ToLower(text)
for _, ext := range imageExts {
if strings.HasSuffix(strings.SplitN(lower, "?", 2)[0], ext) {
return true
}
}
return false
}
// Domain mengembalikan domain kalau user mengetik alamat situs (mis. "mikrotik.com"),
// bukan nama aplikasi biasa.
func Domain(text string) string {
text = strings.TrimSpace(text)
if text == "" || strings.ContainsAny(text, " \t") || IsImageURL(text) {
return ""
}
// nama file gambar (mis. "lazyrun.png") bukan domain
lower := strings.ToLower(strings.SplitN(text, "?", 2)[0])
for _, ext := range imageExts {
if strings.HasSuffix(lower, ext) {
return ""
}
}
host := text
if strings.Contains(text, "://") {
if u, err := url.Parse(text); err == nil {
host = u.Hostname()
}
}
parts := strings.Split(strings.Trim(host, "."), ".")
if len(parts) < 2 {
return ""
}
tld := parts[len(parts)-1]
if len(tld) < 2 || len(tld) > 24 {
return ""
}
for _, r := range strings.Join(parts, "") {
if !(r == '-' || r == '.' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
return ""
}
}
return host
}
// slug menyamakan nama untuk perbandingan (huruf kecil, tanpa pemisah).
+80 -2
View File
@@ -1,6 +1,7 @@
package icon
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
@@ -119,17 +120,94 @@ func TestFindPicksMatchingName(t *testing.T) {
}
}
func TestFromURLSniffsFormat(t *testing.T) {
func TestFromURLAlwaysReturnsPNG(t *testing.T) {
srv := fakeIconify(t)
data, ext, err := FromURL(srv.URL + "/logos/whatsapp-icon.svg")
if err != nil {
t.Fatal(err)
}
if ext != ".svg" || len(data) == 0 {
// SVG dari internet dikonversi ke PNG supaya seragam
if ext != ".png" || len(data) == 0 {
t.Errorf("FromURL = ext %q, %d byte", ext, len(data))
}
if !bytes.HasPrefix(data, pngMagic) {
t.Errorf("hasil FromURL bukan PNG: %q", data[:min(8, len(data))])
}
if _, _, err := FromURL(srv.URL + "/tidak-ada"); err == nil {
t.Error("URL 404 harus error")
}
}
func TestIsImageURLAndDomain(t *testing.T) {
images := []string{"https://contoh.com/ikon.png", "http://a.b/logo.svg", "https://x.id/i.JPEG?v=2"}
for _, u := range images {
if !IsImageURL(u) {
t.Errorf("IsImageURL(%q) = false", u)
}
}
if IsImageURL("https://contoh.com") || IsImageURL("winbox") {
t.Error("URL halaman/nama biasa bukan gambar")
}
domains := map[string]string{
"mikrotik.com": "mikrotik.com",
" web.whatsapp.com ": "web.whatsapp.com",
"https://mikrotik.com/": "mikrotik.com",
"winbox": "",
"WhatsApp Web": "",
"my-app v1.2": "",
"lazyrun.png": "",
"file:///home/x/icon.png": "",
}
for in, want := range domains {
if got := Domain(in); got != want {
t.Errorf("Domain(%q) = %q, mau %q", in, got, want)
}
}
}
func TestSiteIconsListsSiteAndService(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
_, _ = w.Write([]byte(`<html><head>` +
`<link rel="manifest" href="/manifest.json">` +
`<link rel="apple-touch-icon" sizes="180x180" href="/touch.png">` +
`<link rel="shortcut icon" href="/favicon.ico">` +
`</head></html>`))
case "/manifest.json":
_, _ = w.Write([]byte(`{"icons":[{"src":"/big-192.png","sizes":"192x192"},{"src":"/big-512.png","sizes":"512x512"}]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
list, err := SiteIcons(srv.URL)
if err != nil {
t.Fatal(err)
}
if len(list) < 3 {
t.Fatalf("ikon situs = %d, mau minimal 3: %+v", len(list), list)
}
// ikon manifest terbesar harus di depan, lalu ikon halaman, lalu layanan favicon
if list[0].URL != srv.URL+"/big-512.png" {
t.Errorf("kandidat pertama = %+s", list[0])
}
found := false
for _, r := range list {
if strings.HasPrefix(r.URL, "https://www.google.com/s2/favicons") {
found = true
}
}
if !found {
t.Error("layanan favicon tidak disertakan sebagai pilihan")
}
}
func TestSiteIconsNeedsValidDomain(t *testing.T) {
if _, err := SiteIcons(""); err == nil {
t.Error("alamat kosong harus error")
}
}
+65
View File
@@ -2,6 +2,7 @@ package icon
import (
"bytes"
"encoding/binary"
"image/png"
"testing"
)
@@ -80,3 +81,67 @@ func TestRasterizeColorsCurrentColorIcons(t *testing.T) {
t.Errorf("warna pengganti bukan brand indigo: r=%d g=%d b=%d", r>>8, g>>8, b>>8)
}
}
// ico32bpp membuat ICO berisi gambar BMP 32-bit (format favicon lama).
func ico32bpp(width int, pixel func(x, y int) (byte, byte, byte, byte)) []byte {
stride := (width*4 + 3) & ^3
dib := make([]byte, 40+stride*width)
binary.LittleEndian.PutUint32(dib[0:], 40)
binary.LittleEndian.PutUint32(dib[4:], uint32(width))
binary.LittleEndian.PutUint32(dib[8:], uint32(width*2)) // XOR + AND
binary.LittleEndian.PutUint16(dib[12:], 1)
binary.LittleEndian.PutUint16(dib[14:], 32)
for y := 0; y < width; y++ {
row := dib[40+(width-1-y)*stride:]
for x := 0; x < width; x++ {
b, g, r, a := pixel(x, y)
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = b, g, r, a
}
}
ico := make([]byte, 22+len(dib))
ico[2], ico[3] = 1, 0
ico[4], ico[5] = 1, 0
put32 := func(off, v int) {
binary.LittleEndian.PutUint32(ico[off:], uint32(v))
}
ico[6] = byte(width) // lebar (0 = 256)
ico[7] = byte(width)
put32(14, len(dib))
put32(18, 22)
copy(ico[22:], dib)
return ico
}
func TestToPNGConvertsBMPIcon(t *testing.T) {
ico := ico32bpp(16, func(x, y int) (byte, byte, byte, byte) {
return 0x20, 0x80, 0xE0, 0xFF // BGR + alpha
})
out, err := ToPNG(ico)
if err != nil {
t.Fatalf("ico BMP harus bisa dikonversi: %v", err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
t.Fatal(err)
}
r, g, b, a := img.At(8, 8).RGBA()
if a == 0 {
t.Fatal("piksel ICO BMP transparan, seharusnya opaque")
}
// warna BGR 0x20,0x80,0xE0 = RGB (0xE0,0x80,0x20)
if r>>8 != 0xE0 || g>>8 != 0x80 || b>>8 != 0x20 {
t.Errorf("warna salah: r=%#x g=%#x b=%#x", r>>8, g>>8, b>>8)
}
}
func TestDecodeICORejectsGarbage(t *testing.T) {
if _, err := decodeICO([]byte("bukan ico")); err == nil {
t.Error("data bukan ICO harus error")
}
if _, err := decodeICO(icoWithPNG([]byte("bukan png"))); err == nil {
t.Error("entry PNG rusak harus error")
}
}
+18 -1
View File
@@ -56,6 +56,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
entry.SetPlaceHolder("whatsapp")
status := muted(T("search_hint"))
entry.SetPlaceHolder("whatsapp / mikrotik.com")
grid := container.NewGridWithColumns(6)
results := container.NewVScroll(container.NewPadded(grid))
slots := make(chan struct{}, 3)
@@ -69,7 +70,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
status.SetText(T("searching"))
grid.RemoveAll()
go func() {
list, err := icon.SearchBest(entry.Text, 24)
list, err := lookupIcons(entry.Text)
fyne.Do(func() {
if err != nil {
status.SetText(T("search_failed"))
@@ -102,6 +103,22 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
search()
}
// lookupIcons memilih sumber sesuai yang diketik user:
// URL gambar → dipakai langsung, domain situs → ikon resmi situs itu,
// selain itu → pencarian nama di Iconify.
func lookupIcons(query string) ([]icon.Result, error) {
query = strings.TrimSpace(query)
if icon.IsImageURL(query) {
return []icon.Result{{Name: "URL ikon", URL: query}}, nil
}
if domain := icon.Domain(query); domain != "" {
if list, err := icon.SiteIcons(domain); err == nil && len(list) > 0 {
return list, nil
}
}
return icon.SearchBest(query, 24)
}
// iconChoice adalah satu ikon hasil pencarian. Gambarnya diunduh di latar belakang
// dan digambar sebagai PNG: SVG dari internet kadang tidak bisa digambar sehingga
// thumbnail tidak muncul atau proses menggambar berhenti.