From 36208161e177d277a22e32fb21295df37cd8d218 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 15 Sep 2026 17:49:34 +0200 Subject: feat(notify): parse the image hints and encode them to PNG image-data (and the deprecated icon_data) is the (iiibiiay) struct; the PNG encoder honours rowstride and both 3- and 4-channel data. The path and data readers are pure, so the service can apply the spec's priority and the store stays free of image handling. --- internal/notify/image.go | 134 ++++++++++++++++++++++++++++++++++++++++++ internal/notify/image_test.go | 101 +++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 internal/notify/image.go create mode 100644 internal/notify/image_test.go diff --git a/internal/notify/image.go b/internal/notify/image.go new file mode 100644 index 0000000..4a0f6df --- /dev/null +++ b/internal/notify/image.go @@ -0,0 +1,134 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +package notify + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/png" + + "github.com/godbus/dbus/v5" +) + +// RawImage is the spec's image-data structure (iiibiiay). Data is RGB byte +// order: 4 bytes per pixel with alpha, 3 without, and rows are RowStride +// bytes apart, which may exceed Width*Channels. +type RawImage struct { + Width int + Height int + RowStride int + HasAlpha bool + BitsPerSample int + Channels int + Data []byte +} + +// ImageDataFromHints reads the raw image struct, preferring the spec key then +// the deprecated icon_data, then the underscore alias older libnotify sent. +func ImageDataFromHints(hints map[string]dbus.Variant) (*RawImage, bool) { + for _, key := range []string{"image-data", "icon_data", "image_data"} { + v, ok := hints[key] + if !ok { + continue + } + if r, ok := rawImageFromVariant(v); ok { + return r, true + } + } + return nil, false +} + +// ImagePathFromHints reads image-path, a URI, a path, or a theme icon name. +func ImagePathFromHints(hints map[string]dbus.Variant) (string, bool) { + v, ok := hints["image-path"] + if !ok { + return "", false + } + s, ok := v.Value().(string) + if !ok || s == "" { + return "", false + } + return s, true +} + +// rawImageFromVariant accepts the []interface{} godbus yields for a struct. +// Each numeric field may arrive as int32 or int depending on the encoder. +func rawImageFromVariant(v dbus.Variant) (*RawImage, bool) { + f, ok := v.Value().([]interface{}) + if !ok || len(f) != 7 { + return nil, false + } + r := &RawImage{} + var okW, okH, okS, okC, okD bool + r.Width, okW = asInt(f[0]) + r.Height, okH = asInt(f[1]) + r.RowStride, okS = asInt(f[2]) + r.HasAlpha, _ = f[3].(bool) + r.BitsPerSample, _ = asInt(f[4]) + r.Channels, okC = asInt(f[5]) + r.Data, okD = f[6].([]byte) + if !okW || !okH || !okS || !okC || !okD { + return nil, false + } + return r, true +} + +func asInt(v any) (int, bool) { + switch n := v.(type) { + case int: + return n, true + case int32: + return int(n), true + case int64: + return int(n), true + case uint32: + return int(n), true + } + return 0, false +} + +// PNG encodes the raw pixels as a PNG the renderer can load. +func (r *RawImage) PNG() ([]byte, error) { + if r.Width <= 0 || r.Height <= 0 { + return nil, fmt.Errorf("notifyd: image %dx%d", r.Width, r.Height) + } + if r.BitsPerSample != 8 || (r.Channels != 3 && r.Channels != 4) { + return nil, fmt.Errorf("notifyd: image bits=%d channels=%d", r.BitsPerSample, r.Channels) + } + stride := r.RowStride + if stride < r.Width*r.Channels { + stride = r.Width * r.Channels + } + if len(r.Data) < stride*(r.Height-1)+r.Width*r.Channels { + return nil, fmt.Errorf("notifyd: image data short: %d bytes", len(r.Data)) + } + img := image.NewRGBA(image.Rect(0, 0, r.Width, r.Height)) + for y := 0; y < r.Height; y++ { + row := r.Data[y*stride:] + for x := 0; x < r.Width; x++ { + if r.Channels == 4 { + i := x * 4 + img.SetRGBA(x, y, color.RGBA{row[i], row[i+1], row[i+2], row[i+3]}) + } else { + i := x * 3 + img.SetRGBA(x, y, color.RGBA{row[i], row[i+1], row[i+2], 255}) + } + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/notify/image_test.go b/internal/notify/image_test.go new file mode 100644 index 0000000..2db4c75 --- /dev/null +++ b/internal/notify/image_test.go @@ -0,0 +1,101 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +package notify + +import ( + "bytes" + "image/png" + "testing" + + "github.com/godbus/dbus/v5" +) + +// rawVariant builds the (iiibiiay) struct the bus delivers for image-data. +func rawVariant(w, h, stride int, alpha bool, ch int, data []byte) dbus.Variant { + return dbus.MakeVariant([]interface{}{ + int32(w), int32(h), int32(stride), alpha, int32(8), int32(ch), data, + }) +} + +func TestImageDataFromHints(t *testing.T) { + // 2x1 RGBA: red, green. + rgba := []byte{255, 0, 0, 255, 0, 255, 0, 255} + cases := []struct { + name string + hints map[string]dbus.Variant + wantW int + want bool + }{ + {"image-data wins", map[string]dbus.Variant{ + "image-data": rawVariant(2, 1, 8, true, 4, rgba), + "image-path": dbus.MakeVariant("/tmp/x.png"), + }, 2, true}, + {"icon_data fallback", map[string]dbus.Variant{ + "icon_data": rawVariant(2, 1, 8, true, 4, rgba), + }, 2, true}, + {"image_data alias", map[string]dbus.Variant{ + "image_data": rawVariant(2, 1, 8, true, 4, rgba), + }, 2, true}, + {"absent", map[string]dbus.Variant{}, 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := ImageDataFromHints(c.hints) + if ok != c.want { + t.Fatalf("ok = %v, want %v", ok, c.want) + } + if ok && got.Width != c.wantW { + t.Fatalf("width = %d, want %d", got.Width, c.wantW) + } + }) + } +} + +func TestImagePathFromHints(t *testing.T) { + got, ok := ImagePathFromHints(map[string]dbus.Variant{"image-path": dbus.MakeVariant("/tmp/shot.png")}) + if !ok || got != "/tmp/shot.png" { + t.Fatalf("got %q ok=%v", got, ok) + } + if _, ok := ImagePathFromHints(map[string]dbus.Variant{}); ok { + t.Fatal("empty hints must not report a path") + } +} + +func TestRawImagePNG(t *testing.T) { + // 2x1 RGBA on a rowstride wider than the data, to prove stride is honoured. + r := &RawImage{Width: 2, Height: 1, RowStride: 12, HasAlpha: true, BitsPerSample: 8, Channels: 4, + Data: []byte{255, 0, 0, 255, 0, 255, 0, 255, 9, 9, 9, 9}} + data, err := r.PNG() + if err != nil { + t.Fatalf("PNG: %v", err) + } + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if img.Bounds().Dx() != 2 || img.Bounds().Dy() != 1 { + t.Fatalf("bounds = %v", img.Bounds()) + } + r0, g0, b0, a0 := img.At(0, 0).RGBA() + if r0>>8 != 255 || g0>>8 != 0 || b0>>8 != 0 || a0>>8 != 255 { + t.Fatalf("pixel 0 = %d %d %d %d", r0>>8, g0>>8, b0>>8, a0>>8) + } +} + +func TestRawImagePNGRejectsBadData(t *testing.T) { + if _, err := (&RawImage{Width: 0, Height: 1, Channels: 4, BitsPerSample: 8}).PNG(); err == nil { + t.Fatal("zero width must error") + } + if _, err := (&RawImage{Width: 1, Height: 1, Channels: 2, BitsPerSample: 8}).PNG(); err == nil { + t.Fatal("channels 2 must error") + } +} -- cgit v1.2.3