From c7ff8597aacffd1413ee16860441306689778a65 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 12 Mar 2026 22:52:25 +1100 Subject: [PATCH] Some more tools around Android icons --- CLAUDE.md | 11 +++ Makefile | 1 + cmds/android-icons/main.go | 144 +++++++++++++++++++++++++++++ go.mod | 7 +- go.sum | 2 + site/android-icons/index.html | 34 +++++++ site/android-icons/main.js | 136 +++++++++++++++++++++++++++ site/android-icons/style.css | 54 +++++++++++ site/gradient-image/index.html | 50 ++++++++++ site/gradient-image/main.js | 70 ++++++++++++++ site/gradient-image/style.css | 22 +++++ site/image-inner-resize/index.html | 32 +++++++ site/image-inner-resize/main.js | 57 ++++++++++++ site/image-inner-resize/style.css | 23 +++++ site/index.html | 3 + 15 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 cmds/android-icons/main.go create mode 100644 site/android-icons/index.html create mode 100644 site/android-icons/main.js create mode 100644 site/android-icons/style.css create mode 100644 site/gradient-image/index.html create mode 100644 site/gradient-image/main.js create mode 100644 site/gradient-image/style.css create mode 100644 site/image-inner-resize/index.html create mode 100644 site/image-inner-resize/main.js create mode 100644 site/image-inner-resize/style.css 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.

+
+
+
+
+ + +
+
+
+

Circle Crop Preview

+ +
+
+ +
+
+
+ + + diff --git a/site/android-icons/main.js b/site/android-icons/main.js new file mode 100644 index 0000000..056050b --- /dev/null +++ b/site/android-icons/main.js @@ -0,0 +1,136 @@ +import "/wasm/wasm_exec.js"; + +const go = new Go(); +let wasmReady = false; + +WebAssembly.instantiateStreaming(fetch("/wasm/android-icons.wasm"), go.importObject) + .then((result) => { + go.run(result.instance); + wasmReady = true; + }); + +const fileInput = document.getElementById("file-input"); +const previewArea = document.getElementById("preview-area"); +const prepareBtn = document.getElementById("prepare-btn"); +const statusEl = document.getElementById("status"); +const circlePreview = document.getElementById("circle-preview"); +const circleCanvas = document.getElementById("circle-canvas"); + +let loadedFiles = []; + +function renderCirclePreview() { + if (loadedFiles.length === 0) { + circlePreview.style.display = "none"; + return; + } + + const nameLower = (f) => f.name.toLowerCase(); + + let bgFile = loadedFiles.find((f) => /back/.test(nameLower(f))); + let fgFile = loadedFiles.find((f) => !/back/.test(nameLower(f)) && !/mono/.test(nameLower(f))); + + // Fallbacks: if only one image or no "back" image, use the first file + if (!bgFile && !fgFile) { + bgFile = loadedFiles[0]; + } else if (!bgFile) { + bgFile = fgFile; + fgFile = null; + } + + const size = 192; + circleCanvas.width = size; + circleCanvas.height = size; + const ctx = circleCanvas.getContext("2d"); + ctx.clearRect(0, 0, size, size); + + // Clip to circle + ctx.save(); + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + + const drawLayer = (src) => { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + ctx.drawImage(img, 0, 0, size, size); + resolve(); + }; + img.onerror = resolve; + img.src = src; + }); + }; + + drawLayer(bgFile.data).then(() => { + if (fgFile && fgFile !== bgFile) { + return drawLayer(fgFile.data); + } + }).then(() => { + ctx.restore(); + + // Draw border ring + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2 - 1.5, 0, Math.PI * 2); + ctx.closePath(); + ctx.strokeStyle = "rgba(128, 128, 128, 0.5)"; + ctx.lineWidth = 3; + ctx.stroke(); + + circlePreview.style.display = "block"; + }); +} + +fileInput.addEventListener("change", () => { + const files = Array.from(fileInput.files); + loadedFiles = []; + previewArea.innerHTML = ""; + circlePreview.style.display = "none"; + + if (files.length === 0) { + prepareBtn.disabled = true; + return; + } + + files.forEach((file) => { + const reader = new FileReader(); + reader.onload = (e) => { + const dataURL = e.target.result; + loadedFiles.push({ name: file.name, data: dataURL }); + + const item = document.createElement("div"); + item.className = "preview-item"; + + const img = document.createElement("img"); + img.src = dataURL; + + const label = document.createElement("span"); + label.textContent = file.name; + + item.appendChild(img); + item.appendChild(label); + previewArea.appendChild(item); + + if (loadedFiles.length === files.length) { + prepareBtn.disabled = false; + renderCirclePreview(); + } + }; + reader.readAsDataURL(file); + }); +}); + +prepareBtn.addEventListener("click", () => { + if (!wasmReady) { + statusEl.textContent = "WASM module is still loading, please wait..."; + return; + } + if (loadedFiles.length === 0) { + return; + } + statusEl.textContent = "Preparing..."; + // Pass file data to Go WASM + setTimeout(() => { + prepareZip(loadedFiles); + }, 50); +}); diff --git a/site/android-icons/style.css b/site/android-icons/style.css new file mode 100644 index 0000000..1aea483 --- /dev/null +++ b/site/android-icons/style.css @@ -0,0 +1,54 @@ +#preview-area { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 1rem 0; +} + +.preview-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.preview-item img { + width: 96px; + height: 96px; + object-fit: contain; + border: 1px solid var(--pico-muted-border-color); + border-radius: 4px; + background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px; +} + +.preview-item span { + font-size: 0.8rem; + color: var(--pico-muted-color); + word-break: break-all; + max-width: 96px; + text-align: center; +} + +.actions { + margin: 1rem 0; +} + +#circle-preview { + display: none; + margin: 1rem 0; +} + +#circle-preview h3 { + margin-bottom: 0.5rem; +} + +#circle-canvas { + background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px; + border-radius: 50%; + box-shadow: 0 0 0 3px var(--pico-muted-border-color); +} + +#status { + margin-top: 0.5rem; + font-style: italic; +} diff --git a/site/gradient-image/index.html b/site/gradient-image/index.html new file mode 100644 index 0000000..2c1179c --- /dev/null +++ b/site/gradient-image/index.html @@ -0,0 +1,50 @@ + + + + + + Gradient Image - Tools + + + + +
+
+

Gradient Image

+

Generate gradient images as downloadable PNGs

+
+
+
+
+ + + + + + + + + + + + + + +
+
+ + +
+
+ + + diff --git a/site/gradient-image/main.js b/site/gradient-image/main.js new file mode 100644 index 0000000..4271b7e --- /dev/null +++ b/site/gradient-image/main.js @@ -0,0 +1,70 @@ +const PREVIEW_SIZE = 256; + +const fromColorEl = document.getElementById("from-color"); +const toColorEl = document.getElementById("to-color"); +const gradientTypeEl = document.getElementById("gradient-type"); +const rotationEl = document.getElementById("rotation"); +const rotationValueEl = document.getElementById("rotation-value"); +const imageSizeEl = document.getElementById("image-size"); +const canvas = document.getElementById("preview-canvas"); +const downloadBtn = document.getElementById("download-btn"); + +function drawGradient(ctx, size) { + const fromColor = fromColorEl.value; + const toColor = toColorEl.value; + const type = gradientTypeEl.value; + const rotation = parseInt(rotationEl.value); + + const cx = size / 2; + const cy = size / 2; + + let gradient; + if (type === "radial") { + const radius = size / 2; + gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); + } else { + const angle = (rotation * Math.PI) / 180; + const len = size / 2; + const dx = Math.cos(angle) * len; + const dy = Math.sin(angle) * len; + gradient = ctx.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy); + } + + gradient.addColorStop(0, fromColor); + gradient.addColorStop(1, toColor); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); +} + +function renderPreview() { + canvas.width = PREVIEW_SIZE; + canvas.height = PREVIEW_SIZE; + const ctx = canvas.getContext("2d"); + drawGradient(ctx, PREVIEW_SIZE); +} + +function download() { + const size = parseInt(imageSizeEl.value); + const offscreen = document.createElement("canvas"); + offscreen.width = size; + offscreen.height = size; + const ctx = offscreen.getContext("2d"); + drawGradient(ctx, size); + + const link = document.createElement("a"); + link.download = `gradient-${size}x${size}.png`; + link.href = offscreen.toDataURL("image/png"); + link.click(); +} + +fromColorEl.addEventListener("input", renderPreview); +toColorEl.addEventListener("input", renderPreview); +gradientTypeEl.addEventListener("input", renderPreview); +rotationEl.addEventListener("input", () => { + rotationValueEl.textContent = rotationEl.value; + renderPreview(); +}); +downloadBtn.addEventListener("click", download); + +renderPreview(); diff --git a/site/gradient-image/style.css b/site/gradient-image/style.css new file mode 100644 index 0000000..bfe7c47 --- /dev/null +++ b/site/gradient-image/style.css @@ -0,0 +1,22 @@ +.controls { + display: flex; + flex-direction: column; +} + +.preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#preview-canvas { + width: 256px; + height: 256px; + border: 1px solid var(--pico-muted-border-color); + border-radius: 4px; +} + +#download-btn { + width: 100%; +} diff --git a/site/image-inner-resize/index.html b/site/image-inner-resize/index.html new file mode 100644 index 0000000..521be20 --- /dev/null +++ b/site/image-inner-resize/index.html @@ -0,0 +1,32 @@ + + + + + + Image Inner Resize - Tools + + + + +
+
+

Image Inner Resize

+

Scale an image within its original dimensions, centered on a transparent background

+
+
+
+
+ + + + + +
+
+ + +
+
+ + + diff --git a/site/image-inner-resize/main.js b/site/image-inner-resize/main.js new file mode 100644 index 0000000..a22fc52 --- /dev/null +++ b/site/image-inner-resize/main.js @@ -0,0 +1,57 @@ +const fileInput = document.getElementById("file-input"); +const scaleEl = document.getElementById("scale"); +const scaleValueEl = document.getElementById("scale-value"); +const canvas = document.getElementById("preview-canvas"); +const downloadBtn = document.getElementById("download-btn"); + +let sourceImage = null; + +function render() { + if (!sourceImage) return; + + const w = sourceImage.naturalWidth; + const h = sourceImage.naturalHeight; + const scale = parseInt(scaleEl.value) / 100; + + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, w, h); + + const sw = w * scale; + const sh = h * scale; + const sx = (w - sw) / 2; + const sy = (h - sh) / 2; + + ctx.drawImage(sourceImage, sx, sy, sw, sh); +} + +fileInput.addEventListener("change", () => { + const file = fileInput.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + sourceImage = img; + downloadBtn.disabled = false; + render(); + }; + img.src = e.target.result; + }; + reader.readAsDataURL(file); +}); + +scaleEl.addEventListener("input", () => { + scaleValueEl.textContent = scaleEl.value; + render(); +}); + +downloadBtn.addEventListener("click", () => { + if (!sourceImage) return; + const link = document.createElement("a"); + link.download = "resized.png"; + link.href = canvas.toDataURL("image/png"); + link.click(); +}); diff --git a/site/image-inner-resize/style.css b/site/image-inner-resize/style.css new file mode 100644 index 0000000..dbaa0f5 --- /dev/null +++ b/site/image-inner-resize/style.css @@ -0,0 +1,23 @@ +.controls { + display: flex; + flex-direction: column; +} + +.preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#preview-canvas { + max-width: 100%; + max-height: 512px; + border: 1px solid var(--pico-muted-border-color); + border-radius: 4px; + background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px; +} + +#download-btn { + width: 100%; +} diff --git a/site/index.html b/site/index.html index 23d989a..437258e 100644 --- a/site/index.html +++ b/site/index.html @@ -31,6 +31,9 @@
  • Mental Arithmatic Game
  • Neon Snake: vibe-coded by Google Gemini
  • Hex Color Converter
  • +
  • Android Icon Resizer
  • +
  • Gradient Image
  • +
  • Image Inner Resize