diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..780b388
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,11 @@
+# Webtools
+
+This is a static site for a set of tools available on the web. The root of the site is located in "site", with index.html holding a list of available tools. Within each subdirectory of "site" is a separate tool. Each tool is self-contained with it's own index.html, CSS and JavaScript. Multipes of these files can be added if necessary. Note that each HTML has a standard header, with a typical title convension and importing "pico.min.css".
+
+Some tools can include a WASM file for more complex tasks. The WASM targets are built from Go files, with each cmd located as a sub directory in the "cmds" directory. Each one is built as a standalone WASM target.
+
+To build:
+
+- Run `build.wasm` to build the WASM targets. Additional targets will need to be added to this Make target
+- Run `build` to build both the WASM and prepare the site. The site will be available in a "target" directory.
+- Run `dev` to build the site and run a dev HTML server to test the site on port 8000. The dev server can be killed by sending a SIGINT signal.
diff --git a/Makefile b/Makefile
index d9bdbe1..d3f1b85 100644
--- a/Makefile
+++ b/Makefile
@@ -14,6 +14,7 @@ build.wasm:
GOOS=js GOARCH=wasm go build -o target/wasm/clocks.wasm ./cmds/clocks
GOOS=js GOARCH=wasm go build -o target/wasm/gotemplate.wasm ./cmds/gotemplate
GOOS=js GOARCH=wasm go build -o target/wasm/timestamps.wasm ./cmds/timestamps
+ GOOS=js GOARCH=wasm go build -o target/wasm/android-icons.wasm ./cmds/android-icons
cp $(GOROOT)/lib/wasm/wasm_exec.js target/wasm/.
.Phony: build.site
diff --git a/cmds/android-icons/main.go b/cmds/android-icons/main.go
new file mode 100644
index 0000000..cb3ddc8
--- /dev/null
+++ b/cmds/android-icons/main.go
@@ -0,0 +1,144 @@
+//go:build js
+
+package main
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "image"
+ "image/png"
+ "strings"
+ "syscall/js"
+
+ "golang.org/x/image/draw"
+)
+
+type density struct {
+ dir string
+ size int
+}
+
+var densities = []density{
+ {"mipmap-mdpi", 48},
+ {"mipmap-hdpi", 72},
+ {"mipmap-xhdpi", 96},
+ {"mipmap-xxhdpi", 144},
+ {"mipmap-xxxhdpi", 192},
+}
+
+func main() {
+ js.Global().Set("prepareZip", js.FuncOf(prepareZip))
+ <-make(chan struct{})
+}
+
+func prepareZip(this js.Value, args []js.Value) interface{} {
+ // args[0] is an array of {name: string, data: string (base64 PNG data)}
+ files := args[0]
+ length := files.Length()
+
+ if length == 0 {
+ js.Global().Call("alert", "No files selected.")
+ return nil
+ }
+
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+
+ for i := 0; i < length; i++ {
+ file := files.Index(i)
+ name := file.Get("name").String()
+ dataURL := file.Get("data").String()
+
+ // Strip data URL prefix (data:image/png;base64,)
+ commaIdx := strings.Index(dataURL, ",")
+ if commaIdx < 0 {
+ setStatus(fmt.Sprintf("Invalid data for %s", name))
+ return nil
+ }
+ b64Data := dataURL[commaIdx+1:]
+
+ imgData, err := base64.StdEncoding.DecodeString(b64Data)
+ if err != nil {
+ setStatus(fmt.Sprintf("Failed to decode %s: %v", name, err))
+ return nil
+ }
+
+ src, err := png.Decode(bytes.NewReader(imgData))
+ if err != nil {
+ setStatus(fmt.Sprintf("Failed to decode PNG %s: %v", name, err))
+ return nil
+ }
+
+ // Strip extension from name for the base name
+ baseName := name
+ if strings.HasSuffix(strings.ToLower(baseName), ".png") {
+ baseName = baseName[:len(baseName)-4]
+ }
+
+ for _, d := range densities {
+ resized := resizeImage(src, d.size)
+
+ var pngBuf bytes.Buffer
+ if err := png.Encode(&pngBuf, resized); err != nil {
+ setStatus(fmt.Sprintf("Failed to encode %s/%s.png: %v", d.dir, baseName, err))
+ return nil
+ }
+
+ path := fmt.Sprintf("%s/%s.png", d.dir, baseName)
+ w, err := zw.Create(path)
+ if err != nil {
+ setStatus(fmt.Sprintf("Failed to create zip entry %s: %v", path, err))
+ return nil
+ }
+ if _, err := w.Write(pngBuf.Bytes()); err != nil {
+ setStatus(fmt.Sprintf("Failed to write zip entry %s: %v", path, err))
+ return nil
+ }
+ }
+ }
+
+ if err := zw.Close(); err != nil {
+ setStatus(fmt.Sprintf("Failed to finalize zip: %v", err))
+ return nil
+ }
+
+ // Convert zip bytes to a JS Uint8Array and trigger download
+ zipBytes := buf.Bytes()
+ jsArray := js.Global().Get("Uint8Array").New(len(zipBytes))
+ js.CopyBytesToJS(jsArray, zipBytes)
+
+ // Create Blob and download
+ blobParts := js.Global().Get("Array").New()
+ blobParts.Call("push", jsArray)
+ blobOpts := js.Global().Get("Object").New()
+ blobOpts.Set("type", "application/zip")
+ blob := js.Global().Get("Blob").New(blobParts, blobOpts)
+
+ url := js.Global().Get("URL").Call("createObjectURL", blob)
+ anchor := js.Global().Get("document").Call("createElement", "a")
+ anchor.Set("href", url)
+ anchor.Set("download", "android-icons.zip")
+ js.Global().Get("document").Get("body").Call("appendChild", anchor)
+ anchor.Call("click")
+ anchor.Call("remove")
+ js.Global().Get("URL").Call("revokeObjectURL", url)
+
+ setStatus(fmt.Sprintf("Done! Prepared icons for %d image(s).", length))
+ return nil
+}
+
+func resizeImage(src image.Image, size int) image.Image {
+ dst := image.NewNRGBA(image.Rect(0, 0, size, size))
+ draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
+ return dst
+}
+
+func setStatus(msg string) {
+ querySelector("#status").Set("innerText", msg)
+}
+
+func querySelector(query string) js.Value {
+ return js.Global().Get("document").Call("querySelector", query)
+}
diff --git a/go.mod b/go.mod
index 7f220ff..4e30240 100644
--- a/go.mod
+++ b/go.mod
@@ -1,5 +1,8 @@
module github.com/lmika/webtools
-go 1.24.3
+go 1.25.0
-require github.com/alecthomas/participle/v2 v2.1.4 // indirect
+require (
+ github.com/alecthomas/participle/v2 v2.1.4 // indirect
+ golang.org/x/image v0.37.0 // indirect
+)
diff --git a/go.sum b/go.sum
index bff288a..8e1c4f0 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,4 @@
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
+golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
+golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
diff --git a/site/android-icons/index.html b/site/android-icons/index.html
new file mode 100644
index 0000000..d30965e
--- /dev/null
+++ b/site/android-icons/index.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Android Icon Resizer - Tools
+
+
+
+
+
+
+
Android Icon Resizer
+
Resize PNG images to Android mipmap density buckets. Everything runs in your browser.