icns

Easily create .icns files (Mac Icons) with this Go library or the included CLI.
Log | Files | Refs | LICENSE

commit d6caefcda1131a84358e50aae17ed893b49fdd9a
parent bc979c7cfe88f1a680eaf1ebeca33001ffcfcbaf
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:09:27 -0400

icns: resample with x/image/draw instead of nfnt/resize

nfnt/resize was archived in 2018 and filters in straight alpha, haloing
downscaled icons by dragging colour out of transparent pixels. x/image/draw
filters premultiplied. InterpolationFunction stops aliasing the old library's
type, so callers passing its constants must switch to this package's.

Diffstat:
Mcmd/preview/go.mod | 2+-
Mcmd/shell-extension/go.mod | 2+-
Mgo.mod | 4++--
Mgo.sum | 4++--
Mgo.work | 2+-
Micns.go | 14++++++++++----
Micns_test.go | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minterpolation.go | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mreadme.md | 2+-
9 files changed, 171 insertions(+), 15 deletions(-)

diff --git a/cmd/preview/go.mod b/cmd/preview/go.mod @@ -1,6 +1,6 @@ module github.com/jackmordaunt/icns/cmd/preview -go 1.21.5 +go 1.23.0 require ( gioui.org v0.3.1 diff --git a/cmd/shell-extension/go.mod b/cmd/shell-extension/go.mod @@ -1,6 +1,6 @@ module github.com/jackmordaunt/icns/cmd/shell-extension -go 1.21.5 +go 1.23.0 require ( github.com/jackmordaunt/icns/v4 v4.0.0 diff --git a/go.mod b/go.mod @@ -1,5 +1,5 @@ module github.com/jackmordaunt/icns/v4 -go 1.21.5 +go 1.23.0 -require github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 +require golang.org/x/image v0.30.0 diff --git a/go.sum b/go.sum @@ -1,2 +1,2 @@ -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= +golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= diff --git a/go.work b/go.work @@ -1,4 +1,4 @@ -go 1.21.5 +go 1.23.0 use ( . diff --git a/icns.go b/icns.go @@ -7,7 +7,7 @@ import ( "io" "sync" - "github.com/nfnt/resize" + "golang.org/x/image/draw" ) // Encoder encodes ICNS files from a source image. @@ -65,6 +65,7 @@ func NewIconSet(img image.Image, interp InterpolationFunction) (*IconSet, error) return nil, ErrImageTooSmall{image: img, need: 16} } icons := make([]*Icon, len(osTypes)) + scaler := interp.scaler() work := sync.WaitGroup{} var iconIdx int for _, size := range sizesFrom(biggest) { @@ -78,7 +79,7 @@ func NewIconSet(img image.Image, interp InterpolationFunction) (*IconSet, error) go func(iconIdx int, osType OsType, size uint) { icons[iconIdx] = &Icon{ Type: osType, - Image: resizeSquare(img, size, interp), + Image: resizeSquare(img, size, scaler), } work.Done() }(iconIdx, osType, size) @@ -96,12 +97,17 @@ func NewIconSet(img image.Image, interp InterpolationFunction) (*IconSet, error) // aspect ratio. An image already at that size is passed through untouched, // which keeps the largest icon identical to the source and skips the most // expensive resample in the common case. -func resizeSquare(img image.Image, size uint, interp InterpolationFunction) image.Image { +// +// Scaling happens in alpha-premultiplied space, which is what keeps colour +// from bleeding out of fully transparent pixels into the icon's edges. +func resizeSquare(img image.Image, size uint, scaler draw.Interpolator) image.Image { bounds := img.Bounds() if bounds.Dx() == int(size) && bounds.Dy() == int(size) { return img } - return resize.Resize(size, size, img, interp) + dst := image.NewRGBA(image.Rect(0, 0, int(size), int(size))) + scaler.Scale(dst, dst.Bounds(), img, bounds, draw.Src, nil) + return dst } // Big-endian. diff --git a/icns_test.go b/icns_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "image" + "image/color" "image/jpeg" "image/png" "io" @@ -67,6 +68,91 @@ func TestRoundTrip(t *testing.T) { } } +// TestInterpolationFunctions checks that every algorithm, and any value +// outside the enumeration, produces the full icon set at the right sizes. +func TestInterpolationFunctions(t *testing.T) { + t.Parallel() + src := gradient(128) + tests := []struct { + desc string + interp InterpolationFunction + }{ + {"nearest neighbor", NearestNeighbor}, + {"bilinear", Bilinear}, + {"bicubic", Bicubic}, + {"mitchell-netravali", MitchellNetravali}, + {"lanczos2", Lanczos2}, + {"lanczos3", Lanczos3}, + {"out of range falls back to the default", InterpolationFunction(99)}, + } + for _, tt := range tests { + t.Run(tt.desc, func(st *testing.T) { + buf := bytes.NewBuffer(nil) + if err := NewEncoder(buf).WithAlgorithm(tt.interp).Encode(src); err != nil { + st.Fatalf("encoding: %v", err) + } + imgs, err := DecodeAll(buf) + if err != nil { + st.Fatalf("decoding: %v", err) + } + var sides []int + for _, img := range imgs { + b := img.Bounds() + if b.Dx() != b.Dy() { + st.Fatalf("icon is not square: %v", b) + } + sides = append(sides, b.Dx()) + } + if want := []int{128, 64, 32}; !reflect.DeepEqual(sides, want) { + st.Fatalf("icon sides = %v, want %v", sides, want) + } + // A resampled icon must carry the source's colour, not a blank + // or transparent frame. + if _, _, _, a := imgs[1].At(32, 32).RGBA(); a == 0 { + st.Error("the resampled 64px icon is transparent at its centre") + } + }) + } +} + +// TestResizeKeepsColorOutOfTransparentPixels guards the reason resampling +// happens in premultiplied space. Filtering an opaque edge against +// transparent pixels in straight space drags their colour into the edge, +// the familiar dark halo around a downscaled icon. +func TestResizeKeepsColorOutOfTransparentPixels(t *testing.T) { + t.Parallel() + // Left half opaque white, right half transparent black. + src := image.NewNRGBA(image.Rect(0, 0, 64, 64)) + for y := 0; y < 64; y++ { + for x := 0; x < 32; x++ { + src.SetNRGBA(x, y, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) + } + } + // Bilinear has no negative lobes, so every output pixel is a plain + // average of its neighbours and the expected values are exact. + got := resizeSquare(src, 32, Bilinear.scaler()) + var blended int + for y := got.Bounds().Min.Y; y < got.Bounds().Max.Y; y++ { + for x := got.Bounds().Min.X; x < got.Bounds().Max.X; x++ { + c := color.NRGBAModel.Convert(got.At(x, y)).(color.NRGBA) + if c.A == 0 { + continue // Fully transparent: colour is unobservable. + } + if c.A < 255 { + blended++ + } + if c.R != 255 || c.G != 255 || c.B != 255 { + t.Fatalf("pixel (%d,%d) is %v, want white at any alpha", x, y, c) + } + } + } + // Without pixels that actually mix the two halves there is nothing to + // bleed, and the check above would pass for the wrong reason. + if blended == 0 { + t.Fatal("no partially transparent pixels: the edge never blended") + } +} + // imageCompare reports whether two images have identical bounds and pixels. func imageCompare(left, right image.Image) bool { if left == nil || right == nil { diff --git a/interpolation.go b/interpolation.go @@ -1,13 +1,19 @@ package icns import ( - "github.com/nfnt/resize" + "math" + + "golang.org/x/image/draw" ) // InterpolationFunction is the algorithm used to resize the image. -type InterpolationFunction = resize.InterpolationFunction +// +// It is an enumeration of this package's own rather than an alias for the +// resampling library's type, so the resampler can be replaced without +// breaking callers. +type InterpolationFunction int -// InterpolationFunction constants. +// InterpolationFunction constants, ordered from fastest to highest quality. const ( // Nearest-neighbor interpolation NearestNeighbor InterpolationFunction = iota @@ -22,3 +28,61 @@ const ( // Lanczos interpolation (a=3) Lanczos3 ) + +// scaler returns the resampler implementing f. Values outside the +// enumeration fall back to MitchellNetravali, the package default. +func (f InterpolationFunction) scaler() draw.Interpolator { + switch f { + case NearestNeighbor: + return draw.NearestNeighbor + case Bilinear: + return draw.BiLinear + case Bicubic: + // Catmull-Rom is the cubic hermite spline interpolant. + return draw.CatmullRom + case Lanczos2: + return lanczos2 + case Lanczos3: + return lanczos3 + default: + return mitchellNetravali + } +} + +// Kernels receive the distance from the sample as a non-negative value, and +// return the weight to give the pixel at that distance. + +// mitchellNetravali is the Mitchell-Netravali filter with B = C = 1/3, the +// parameters its authors found to be the best compromise between blurring +// and ringing. +var mitchellNetravali = &draw.Kernel{Support: 2, At: func(t float64) float64 { + const b, c = 1.0 / 3.0, 1.0 / 3.0 + switch { + case t < 1: + return ((12-9*b-6*c)*t*t*t + (-18+12*b+6*c)*t*t + (6 - 2*b)) / 6 + case t < 2: + return ((-b-6*c)*t*t*t + (6*b+30*c)*t*t + (-12*b-48*c)*t + (8*b + 24*c)) / 6 + default: + return 0 + } +}} + +var ( + lanczos2 = lanczos(2) + lanczos3 = lanczos(3) +) + +// lanczos returns the Lanczos filter of order a: a sinc windowed by another +// sinc stretched across the kernel's whole support. +func lanczos(a float64) *draw.Kernel { + return &draw.Kernel{Support: a, At: func(t float64) float64 { + switch { + case t == 0: + return 1 + case t < a: + return a * math.Sin(math.Pi*t) * math.Sin(math.Pi*t/a) / (math.Pi * math.Pi * t * t) + default: + return 0 + } + }} +} diff --git a/readme.md b/readme.md @@ -127,7 +127,7 @@ The repository is a Go workspace of three modules: | Module | Contents | Why separate | |---|---|---| -| `github.com/jackmordaunt/icns/v4` (root) | The library and `cmd/icnsify` | Dependency-free apart from `nfnt/resize`; one tag versions both | +| `github.com/jackmordaunt/icns/v4` (root) | The library and `cmd/icnsify` | Depends only on `golang.org/x/image`; one tag versions both | | `github.com/jackmordaunt/icns/cmd/preview` | The Gio GUI | Keeps Gio's dependency tree out of library consumers' module graphs | | `github.com/jackmordaunt/icns/cmd/shell-extension` | The Windows DLL | Windows-only and needs cgo (mingw) |