浏览代码

Generate desktop icons for web modules

Add desktop icon generation for module shortcuts when a web app does not provide its own desktop_icon.png. The new pipeline resolves/sanitizes asset paths, loads raster or SVG icons, rasterizes SVGs with stroke-width scaling fixes, and renders a padded squircle backplate with adaptive black/white contrast before saving the generated icon into the app folder. Also adds comprehensive unit tests for path resolution, squircle math/coverage, backplate color selection, icon scaling/rendering, SVG stroke scaling, and rasterized stroke thickness.
Toby Chui 3 周之前
父节点
当前提交
411e4fc09a
共有 2 个文件被更改,包括 629 次插入0 次删除
  1. 330 0
      src/desktop.go
  2. 299 0
      src/desktop_icon_test.go

+ 330 - 0
src/desktop.go

@@ -1,15 +1,27 @@
 package main
 
 import (
+	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
+	"image"
+	"image/color"
+	"image/draw"
+	_ "image/gif"
+	_ "image/jpeg"
+	"image/png"
+	"math"
 	"net/http"
 	"os"
+	"path"
 	"path/filepath"
 	"strconv"
 	"strings"
 
+	"github.com/disintegration/imaging"
+	"github.com/srwiley/oksvg"
+	"github.com/srwiley/rasterx"
 	fs "imuslab.com/arozos/mod/filesystem"
 	"imuslab.com/arozos/mod/filesystem/arozfs"
 	"imuslab.com/arozos/mod/filesystem/shortcut"
@@ -776,6 +788,16 @@ func desktop_shortcutHandler(w http.ResponseWriter, r *http.Request) {
 		counter++
 	}
 
+	//Module icons are edge to edge by design. Render a padded squircle desktop
+	//icon for the web app if it does not ship one of its own, so the shortcut
+	//does not end up with a fully filled icon on the desktop.
+	if shortcutType == "module" {
+		_, err := desktop_ensureDesktopIcon(shortcutIcon)
+		if err != nil {
+			systemWideLogger.PrintAndLog("Desktop", "Unable to generate desktop icon for "+shortcutIcon, err)
+		}
+	}
+
 	//Write the shortcut to file
 	shortcutContent := shortcut.GenerateShortcutBytes(shortcutPath, shortcutType, shortcutText, shortcutIcon)
 	err = fshAbs.WriteFile(shortcutFilename, shortcutContent, 0775)
@@ -785,3 +807,311 @@ func desktop_shortcutHandler(w http.ResponseWriter, r *http.Request) {
 	}
 	utils.SendOK(w)
 }
+
+/*
+	Desktop Icon Generator
+
+	A web app's module icon (the one declared in its init.agi) is designed to be
+	edge to edge, which looks wrong when placed on the desktop where icons are
+	expected to have padding around them. When a web app does not ship its own
+	desktop_icon.png, the functions below render one on the fly: the module icon
+	is scaled down and centered on top of an opaque squircle backplate, which is
+	then written back into the web app folder next to the module icon.
+*/
+
+const (
+	/*
+		desktopIconSquircleFactor is the "squareness" factor f of the generated
+		squircle backplate. The backplate is the superellipse (Lame curve)
+
+			|x/r|^n + |y/r|^n = 1,  where n = 2 / (1 - f)
+
+		so f = 0 renders a perfect circle, f = 0.5 the classic squircle and
+		f approaching 1 approaches a plain square. Tune this value to change the
+		roundness of every generated desktop icon.
+	*/
+	desktopIconSquircleFactor = 0.75
+
+	//desktopIconSize is the output resolution in px of the generated
+	//desktop_icon.png, matching the hand made desktop icons of the built-in apps
+	desktopIconSize = 128
+
+	//desktopIconBackplateRatio is the width of the squircle backplate relative
+	//to the canvas size. The hand drawn desktop icons of the built-in web apps
+	//all sit at around 0.70 of their canvas, so match that to keep the generated
+	//icons the same visual size as the rest of the desktop.
+	desktopIconBackplateRatio = 0.70
+
+	//desktopIconContentRatio is the width of the module icon relative to the
+	//*backplate* (not the canvas), so the backplate size can be tuned above
+	//without having to re-balance the padding. The remainder is the padding
+	//drawn around the icon.
+	desktopIconContentRatio = 0.66
+
+	//desktopIconSampleSteps is the supersampling grid size (n x n samples per
+	//pixel) used to antialias the edge of the squircle backplate
+	desktopIconSampleSteps = 4
+
+	//desktopIconLumaThreshold is the perceived luminance (0 - 1) above which a
+	//module icon counts as "bright" and gets a black backplate instead of white
+	desktopIconLumaThreshold = 0.5
+
+	//desktopIconSVGRenderSize is the resolution the SVG module icons are
+	//rasterized at before being scaled down onto the backplate
+	desktopIconSVGRenderSize = 512
+)
+
+// desktop_ensureDesktopIcon makes sure a desktop_icon.png exists next to the
+// given module icon. moduleIconPath is a path relative to the web root, e.g.
+// "Photo/img/module_icon.png". If the desktop icon is already there nothing is
+// done; otherwise one is generated from the module icon and written into the
+// web app folder. The web root relative path of the desktop icon is returned.
+func desktop_ensureDesktopIcon(moduleIconPath string) (string, error) {
+	moduleIconRel, err := desktop_resolveWebAssetPath(moduleIconPath)
+	if err != nil {
+		return "", err
+	}
+
+	desktopIconRel := path.Join(path.Dir(moduleIconRel), "desktop_icon.png")
+	desktopIconAbs := filepath.Join("./web", filepath.FromSlash(desktopIconRel))
+	if utils.FileExists(desktopIconAbs) {
+		//This web app already ships a desktop icon. Nothing to do.
+		return desktopIconRel, nil
+	}
+
+	if strings.EqualFold(path.Base(moduleIconRel), "desktop_icon.png") {
+		//The module icon is the desktop icon we are trying to create. Bail out
+		//instead of recursing on a file that does not exist.
+		return "", errors.New("desktop icon not found and cannot be generated from itself")
+	}
+
+	moduleIcon, err := desktop_loadWebImage(moduleIconRel)
+	if err != nil {
+		return "", err
+	}
+
+	var generatedIcon bytes.Buffer
+	err = png.Encode(&generatedIcon, desktop_renderDesktopIcon(moduleIcon))
+	if err != nil {
+		return "", err
+	}
+
+	err = os.WriteFile(desktopIconAbs, generatedIcon.Bytes(), 0775)
+	if err != nil {
+		return "", err
+	}
+
+	systemWideLogger.PrintAndLog("Desktop", "Generated desktop icon for "+desktopIconRel, nil)
+	return desktopIconRel, nil
+}
+
+// desktop_resolveWebAssetPath cleans a user supplied web root relative asset
+// path and rejects anything that tries to escape the web root.
+func desktop_resolveWebAssetPath(relPath string) (string, error) {
+	relPath = strings.TrimSpace(strings.ReplaceAll(relPath, "\\", "/"))
+	if relPath == "" {
+		return "", errors.New("empty asset path")
+	}
+
+	//Cleaning against the root collapses any ".." segments trying to climb out
+	//of the web root. Use path (not filepath) so this behaves the same on the
+	//platforms where the OS separator is not a slash.
+	cleanedPath := strings.TrimPrefix(path.Clean("/"+strings.TrimLeft(relPath, "/")), "/")
+	if cleanedPath == "" || cleanedPath == "." {
+		return "", errors.New("invalid asset path: " + relPath)
+	}
+
+	return cleanedPath, nil
+}
+
+// desktop_loadWebImage decodes an image stored under the web root. Both the
+// raster formats used by the web apps and SVG module icons are supported.
+func desktop_loadWebImage(webRelPath string) (image.Image, error) {
+	imageContent, err := os.ReadFile(filepath.Join("./web", filepath.FromSlash(webRelPath)))
+	if err != nil {
+		return nil, err
+	}
+
+	if strings.EqualFold(path.Ext(webRelPath), ".svg") {
+		return desktop_rasterizeSVG(imageContent, desktopIconSVGRenderSize)
+	}
+
+	loadedImage, _, err := image.Decode(bytes.NewReader(imageContent))
+	return loadedImage, err
+}
+
+// desktop_rasterizeSVG renders an SVG into a square RGBA image of the given
+// size, preserving the aspect ratio of the source viewBox.
+func desktop_rasterizeSVG(svgContent []byte, renderSize int) (image.Image, error) {
+	parsedIcon, err := oksvg.ReadIconStream(bytes.NewReader(svgContent))
+	if err != nil {
+		return nil, err
+	}
+
+	viewWidth := parsedIcon.ViewBox.W
+	viewHeight := parsedIcon.ViewBox.H
+	if viewWidth <= 0 || viewHeight <= 0 {
+		viewWidth, viewHeight = float64(renderSize), float64(renderSize)
+	}
+
+	scale := math.Min(float64(renderSize)/viewWidth, float64(renderSize)/viewHeight)
+	targetWidth := viewWidth * scale
+	targetHeight := viewHeight * scale
+	parsedIcon.SetTarget((float64(renderSize)-targetWidth)/2, (float64(renderSize)-targetHeight)/2, targetWidth, targetHeight)
+	desktop_scaleSVGStrokes(parsedIcon, scale)
+
+	renderedIcon := image.NewRGBA(image.Rect(0, 0, renderSize, renderSize))
+	scanner := rasterx.NewScannerGV(renderSize, renderSize, renderedIcon, renderedIcon.Bounds())
+	parsedIcon.Draw(rasterx.NewDasher(renderSize, renderSize, scanner), 1.0)
+	return renderedIcon, nil
+}
+
+// desktop_scaleSVGStrokes multiplies the stroke widths of an SVG icon by the
+// given scale factor.
+//
+// oksvg only applies the SetTarget transform to the path geometry: the stroke
+// width handed to the rasterizer is the raw value in viewBox units. Rendering a
+// 64x64 viewBox at 512px therefore leaves every stroke 8 times too thin, which
+// makes line art module icons come out as hairlines. Pre-scaling the stroke
+// styling to match the transform restores the intended thickness.
+func desktop_scaleSVGStrokes(parsedIcon *oksvg.SvgIcon, scale float64) {
+	if scale <= 0 || scale == 1 {
+		return
+	}
+
+	for i := range parsedIcon.SVGPaths {
+		svgPath := &parsedIcon.SVGPaths[i]
+		svgPath.LineWidth *= scale
+		svgPath.DashOffset *= scale
+		for j := range svgPath.Dash {
+			svgPath.Dash[j] *= scale
+		}
+	}
+}
+
+// desktop_renderDesktopIcon composites a module icon onto a padded squircle
+// backplate and returns the resulting desktop icon image.
+func desktop_renderDesktopIcon(moduleIcon image.Image) image.Image {
+	canvas := image.NewNRGBA(image.Rect(0, 0, desktopIconSize, desktopIconSize))
+
+	//Paint the antialiased squircle backplate
+	backplateColor := desktop_pickBackplateColor(moduleIcon)
+	exponent := desktop_squircleExponent(desktopIconSquircleFactor)
+	center := float64(desktopIconSize) / 2
+	radius := float64(desktopIconSize) * desktopIconBackplateRatio / 2
+	for y := 0; y < desktopIconSize; y++ {
+		for x := 0; x < desktopIconSize; x++ {
+			coverage := desktop_squircleCoverage(float64(x), float64(y), center, radius, exponent)
+			if coverage <= 0 {
+				continue
+			}
+			pixelColor := backplateColor
+			pixelColor.A = uint8(math.Round(float64(backplateColor.A) * coverage))
+			canvas.SetNRGBA(x, y, pixelColor)
+		}
+	}
+
+	//Scale the module icon into the padded content box and center it
+	contentBox := int(math.Round(float64(desktopIconSize) * desktopIconBackplateRatio * desktopIconContentRatio))
+	scaledIcon := desktop_scaleIconToBox(moduleIcon, contentBox)
+	if scaledIcon != nil {
+		offset := image.Pt((desktopIconSize-scaledIcon.Bounds().Dx())/2, (desktopIconSize-scaledIcon.Bounds().Dy())/2)
+		draw.Draw(canvas, scaledIcon.Bounds().Add(offset), scaledIcon, scaledIcon.Bounds().Min, draw.Over)
+	}
+
+	return canvas
+}
+
+// desktop_scaleIconToBox resizes an icon so its longest side matches boxSize
+// while keeping its aspect ratio. Returns nil for degenerate source images.
+func desktop_scaleIconToBox(moduleIcon image.Image, boxSize int) image.Image {
+	sourceWidth := moduleIcon.Bounds().Dx()
+	sourceHeight := moduleIcon.Bounds().Dy()
+	if sourceWidth <= 0 || sourceHeight <= 0 || boxSize <= 0 {
+		return nil
+	}
+
+	scale := math.Min(float64(boxSize)/float64(sourceWidth), float64(boxSize)/float64(sourceHeight))
+	scaledWidth := int(math.Round(float64(sourceWidth) * scale))
+	scaledHeight := int(math.Round(float64(sourceHeight) * scale))
+	if scaledWidth < 1 {
+		scaledWidth = 1
+	}
+	if scaledHeight < 1 {
+		scaledHeight = 1
+	}
+
+	return imaging.Resize(moduleIcon, scaledWidth, scaledHeight, imaging.Lanczos)
+}
+
+// desktop_squircleExponent converts the squircle "squareness" factor f into the
+// superellipse exponent n = 2 / (1 - f).
+func desktop_squircleExponent(squircleFactor float64) float64 {
+	if squircleFactor < 0 {
+		squircleFactor = 0
+	} else if squircleFactor > 0.99 {
+		//Keep the exponent finite so the math below stays well behaved
+		squircleFactor = 0.99
+	}
+	return 2 / (1 - squircleFactor)
+}
+
+// desktop_squircleCoverage returns how much (0 - 1) of the pixel at the given
+// top-left coordinates falls inside the squircle, supersampled for antialiasing.
+func desktop_squircleCoverage(pixelX float64, pixelY float64, center float64, radius float64, exponent float64) float64 {
+	if radius <= 0 {
+		return 0
+	}
+
+	sampleStep := 1.0 / float64(desktopIconSampleSteps)
+	samplesInside := 0
+	for sampleY := 0; sampleY < desktopIconSampleSteps; sampleY++ {
+		for sampleX := 0; sampleX < desktopIconSampleSteps; sampleX++ {
+			offsetX := math.Abs(pixelX+(float64(sampleX)+0.5)*sampleStep-center) / radius
+			offsetY := math.Abs(pixelY+(float64(sampleY)+0.5)*sampleStep-center) / radius
+			if math.Pow(offsetX, exponent)+math.Pow(offsetY, exponent) <= 1 {
+				samplesInside++
+			}
+		}
+	}
+
+	return float64(samplesInside) / float64(desktopIconSampleSteps*desktopIconSampleSteps)
+}
+
+// desktop_pickBackplateColor samples the theme color of a module icon and picks
+// the backplate that keeps the icon readable: black behind a bright icon, white
+// behind a dark one.
+func desktop_pickBackplateColor(moduleIcon image.Image) color.NRGBA {
+	white := color.NRGBA{R: 255, G: 255, B: 255, A: 255}
+	black := color.NRGBA{R: 0, G: 0, B: 0, A: 255}
+
+	bounds := moduleIcon.Bounds()
+	lumaSum := 0.0
+	weightSum := 0.0
+	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
+		for x := bounds.Min.X; x < bounds.Max.X; x++ {
+			r, g, b, a := moduleIcon.At(x, y).RGBA()
+			if a == 0 {
+				//Fully transparent pixels carry no theme color
+				continue
+			}
+
+			//RGBA() is alpha-premultiplied, so divide by alpha to get the real
+			//channel values and weight the sample by how opaque the pixel is
+			luma := (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)) / float64(a)
+			alpha := float64(a) / 65535
+			lumaSum += luma * alpha
+			weightSum += alpha
+		}
+	}
+
+	if weightSum == 0 {
+		//Blank icon, fall back to the light backplate
+		return white
+	}
+
+	if lumaSum/weightSum > desktopIconLumaThreshold {
+		return black
+	}
+	return white
+}

+ 299 - 0
src/desktop_icon_test.go

@@ -0,0 +1,299 @@
+package main
+
+import (
+	"bytes"
+	"image"
+	"image/color"
+	"math"
+	"testing"
+
+	"github.com/srwiley/oksvg"
+)
+
+func TestDesktopResolveWebAssetPath(t *testing.T) {
+	tests := []struct {
+		name      string
+		input     string
+		want      string
+		expectErr bool
+	}{
+		{"plain path", "Photo/img/module_icon.png", "Photo/img/module_icon.png", false},
+		{"windows separator", "Photo\\img\\module_icon.png", "Photo/img/module_icon.png", false},
+		{"redundant segments", "Photo/./img//module_icon.png", "Photo/img/module_icon.png", false},
+		{"leading traversal", "../../etc/passwd", "etc/passwd", false},
+		{"embedded traversal", "Photo/img/../../Music/img/icon.png", "Music/img/icon.png", false},
+		{"leading slash", "/Photo/img/icon.png", "Photo/img/icon.png", false},
+		{"empty", "", "", true},
+		{"whitespace only", "   ", "", true},
+		{"root only", "/", "", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := desktop_resolveWebAssetPath(tt.input)
+			if tt.expectErr {
+				if err == nil {
+					t.Fatalf("desktop_resolveWebAssetPath(%q) = %q, want error", tt.input, got)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("desktop_resolveWebAssetPath(%q) returned unexpected error: %v", tt.input, err)
+			}
+			if got != tt.want {
+				t.Errorf("desktop_resolveWebAssetPath(%q) = %q, want %q", tt.input, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestDesktopSquircleExponent(t *testing.T) {
+	tests := []struct {
+		name string
+		f    float64
+		want float64
+	}{
+		{"circle", 0, 2},
+		{"classic squircle", 0.5, 4},
+		{"default factor", desktopIconSquircleFactor, 8},
+		{"negative clamped to circle", -1, 2},
+		{"above one clamped", 5, 200},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := desktop_squircleExponent(tt.f)
+			if math.Abs(got-tt.want) > 1e-9 {
+				t.Errorf("desktop_squircleExponent(%v) = %v, want %v", tt.f, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestDesktopSquircleCoverage(t *testing.T) {
+	exponent := desktop_squircleExponent(desktopIconSquircleFactor)
+	center := 50.0
+	radius := 40.0
+
+	tests := []struct {
+		name   string
+		x, y   float64
+		want   float64
+		strict bool
+	}{
+		{"center is fully covered", center, center, 1, true},
+		{"far outside is empty", 0, 0, 0, true},
+		{"just inside the right edge", center + radius - 2, center, 1, true},
+		{"just outside the right edge", center + radius + 1, center, 0, true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := desktop_squircleCoverage(tt.x, tt.y, center, radius, exponent)
+			if got != tt.want {
+				t.Errorf("desktop_squircleCoverage(%v, %v) = %v, want %v", tt.x, tt.y, got, tt.want)
+			}
+		})
+	}
+
+	//A pixel sitting exactly on the boundary must be partially covered so the
+	//edge of the squircle ends up antialiased instead of hard cut
+	edgeCoverage := desktop_squircleCoverage(center+radius-0.5, center, center, radius, exponent)
+	if edgeCoverage <= 0 || edgeCoverage >= 1 {
+		t.Errorf("boundary pixel coverage = %v, want a value between 0 and 1", edgeCoverage)
+	}
+
+	if got := desktop_squircleCoverage(center, center, center, 0, exponent); got != 0 {
+		t.Errorf("desktop_squircleCoverage with zero radius = %v, want 0", got)
+	}
+}
+
+// buildTestIcon creates a solid square icon of the given size and color
+func buildTestIcon(size int, fill color.NRGBA) image.Image {
+	icon := image.NewNRGBA(image.Rect(0, 0, size, size))
+	for y := 0; y < size; y++ {
+		for x := 0; x < size; x++ {
+			icon.SetNRGBA(x, y, fill)
+		}
+	}
+	return icon
+}
+
+func TestDesktopPickBackplateColor(t *testing.T) {
+	white := color.NRGBA{R: 255, G: 255, B: 255, A: 255}
+	black := color.NRGBA{R: 0, G: 0, B: 0, A: 255}
+
+	tests := []struct {
+		name string
+		icon image.Image
+		want color.NRGBA
+	}{
+		{"bright icon gets black backplate", buildTestIcon(8, white), black},
+		{"dark icon gets white backplate", buildTestIcon(8, black), white},
+		{"fully transparent icon falls back to white", buildTestIcon(8, color.NRGBA{R: 255, G: 255, B: 255, A: 0}), white},
+		{"translucent bright icon still reads as bright", buildTestIcon(8, color.NRGBA{R: 255, G: 255, B: 255, A: 40}), black},
+		{"mid grey icon stays below threshold", buildTestIcon(8, color.NRGBA{R: 100, G: 100, B: 100, A: 255}), white},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := desktop_pickBackplateColor(tt.icon)
+			if got != tt.want {
+				t.Errorf("desktop_pickBackplateColor() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestDesktopScaleIconToBox(t *testing.T) {
+	tests := []struct {
+		name       string
+		icon       image.Image
+		boxSize    int
+		wantW      int
+		wantH      int
+		wantNilOut bool
+	}{
+		{"square icon is scaled down", buildTestIcon(256, color.NRGBA{A: 255}), 64, 64, 64, false},
+		{"small icon is scaled up", buildTestIcon(16, color.NRGBA{A: 255}), 64, 64, 64, false},
+		{"wide icon keeps aspect ratio", image.NewNRGBA(image.Rect(0, 0, 200, 100)), 80, 80, 40, false},
+		{"tall icon keeps aspect ratio", image.NewNRGBA(image.Rect(0, 0, 100, 200)), 80, 40, 80, false},
+		{"empty icon returns nil", image.NewNRGBA(image.Rect(0, 0, 0, 0)), 64, 0, 0, true},
+		{"zero box returns nil", buildTestIcon(32, color.NRGBA{A: 255}), 0, 0, 0, true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := desktop_scaleIconToBox(tt.icon, tt.boxSize)
+			if tt.wantNilOut {
+				if got != nil {
+					t.Fatalf("desktop_scaleIconToBox() = %v, want nil", got.Bounds())
+				}
+				return
+			}
+			if got == nil {
+				t.Fatal("desktop_scaleIconToBox() = nil, want an image")
+			}
+			if got.Bounds().Dx() != tt.wantW || got.Bounds().Dy() != tt.wantH {
+				t.Errorf("desktop_scaleIconToBox() size = %dx%d, want %dx%d",
+					got.Bounds().Dx(), got.Bounds().Dy(), tt.wantW, tt.wantH)
+			}
+		})
+	}
+}
+
+func TestDesktopRenderDesktopIcon(t *testing.T) {
+	darkIcon := buildTestIcon(64, color.NRGBA{R: 20, G: 20, B: 20, A: 255})
+	rendered := desktop_renderDesktopIcon(darkIcon)
+
+	if rendered.Bounds().Dx() != desktopIconSize || rendered.Bounds().Dy() != desktopIconSize {
+		t.Fatalf("rendered icon size = %dx%d, want %dx%d",
+			rendered.Bounds().Dx(), rendered.Bounds().Dy(), desktopIconSize, desktopIconSize)
+	}
+
+	//The corners sit outside the squircle and must stay fully transparent
+	corners := [][2]int{{0, 0}, {desktopIconSize - 1, 0}, {0, desktopIconSize - 1}, {desktopIconSize - 1, desktopIconSize - 1}}
+	for _, corner := range corners {
+		if _, _, _, a := rendered.At(corner[0], corner[1]).RGBA(); a != 0 {
+			t.Errorf("corner (%d, %d) alpha = %d, want 0", corner[0], corner[1], a)
+		}
+	}
+
+	//The center of a dark icon should be drawn on top of a white backplate
+	if _, _, _, a := rendered.At(desktopIconSize/2, desktopIconSize/2).RGBA(); a == 0 {
+		t.Error("center pixel is transparent, want the module icon drawn there")
+	}
+
+	//The padding ring between the icon and the backplate edge must show the
+	//backplate color rather than the module icon
+	contentRadius := float64(desktopIconSize) * desktopIconBackplateRatio * desktopIconContentRatio / 2
+	backplateRadius := float64(desktopIconSize) * desktopIconBackplateRatio / 2
+	paddingOffset := int(math.Round((contentRadius + backplateRadius) / 2))
+	pr, pg, pb, pa := rendered.At(desktopIconSize/2+paddingOffset, desktopIconSize/2).RGBA()
+	if pa != 0xffff || pr != 0xffff || pg != 0xffff || pb != 0xffff {
+		t.Errorf("padding pixel = (%d, %d, %d, %d), want opaque white backplate", pr, pg, pb, pa)
+	}
+
+	//The generated icon must occupy roughly the same fraction of the canvas as
+	//the hand drawn desktop icons shipped with the built-in web apps
+	opaqueWidth := 0
+	for x := 0; x < desktopIconSize; x++ {
+		if _, _, _, a := rendered.At(x, desktopIconSize/2).RGBA(); a > 0 {
+			opaqueWidth++
+		}
+	}
+	occupancy := float64(opaqueWidth) / float64(desktopIconSize)
+	if occupancy < 0.66 || occupancy > 0.74 {
+		t.Errorf("icon occupancy = %.3f of canvas, want roughly 0.70 to match the bundled icons", occupancy)
+	}
+}
+
+func TestDesktopScaleSVGStrokes(t *testing.T) {
+	svgSource := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+		<path d="M22 16 V48" stroke="#ffffff" stroke-width="4" fill="none"/>
+	</svg>`)
+
+	parsedIcon, err := oksvg.ReadIconStream(bytes.NewReader(svgSource))
+	if err != nil {
+		t.Fatalf("unable to parse test SVG: %v", err)
+	}
+	if len(parsedIcon.SVGPaths) == 0 {
+		t.Fatal("test SVG parsed into zero paths")
+	}
+
+	originalWidth := parsedIcon.SVGPaths[0].LineWidth
+	if originalWidth <= 0 {
+		t.Fatalf("test SVG stroke width = %v, want a positive width", originalWidth)
+	}
+
+	tests := []struct {
+		name  string
+		scale float64
+		want  float64
+	}{
+		{"scaled up", 8, originalWidth * 8},
+		{"scaled down", 0.5, originalWidth * 0.5},
+		{"identity is a no-op", 1, originalWidth},
+		{"zero scale is ignored", 0, originalWidth},
+		{"negative scale is ignored", -2, originalWidth},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			icon, err := oksvg.ReadIconStream(bytes.NewReader(svgSource))
+			if err != nil {
+				t.Fatalf("unable to parse test SVG: %v", err)
+			}
+			desktop_scaleSVGStrokes(icon, tt.scale)
+			if got := icon.SVGPaths[0].LineWidth; math.Abs(got-tt.want) > 1e-9 {
+				t.Errorf("LineWidth after scaling by %v = %v, want %v", tt.scale, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestDesktopRasterizeSVGKeepsStrokeWeight(t *testing.T) {
+	//A 64 unit wide viewBox with a 4 unit stroke rendered at 512px should draw
+	//a 32px wide line. Without stroke scaling oksvg would draw it 4px wide.
+	svgSource := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+		<path d="M32 8 V56" stroke="#ffffff" stroke-width="4" fill="none"/>
+	</svg>`)
+
+	const renderSize = 512
+	rasterized, err := desktop_rasterizeSVG(svgSource, renderSize)
+	if err != nil {
+		t.Fatalf("desktop_rasterizeSVG() returned error: %v", err)
+	}
+
+	drawnWidth := 0
+	for x := 0; x < renderSize; x++ {
+		if _, _, _, a := rasterized.At(x, renderSize/2).RGBA(); a > 0x7fff {
+			drawnWidth++
+		}
+	}
+
+	expectedWidth := 4.0 / 64.0 * renderSize
+	if math.Abs(float64(drawnWidth)-expectedWidth) > 2 {
+		t.Errorf("rasterized stroke width = %dpx, want about %.0fpx", drawnWidth, expectedWidth)
+	}
+}