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:
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) |