diff --git a/bskyweb/cmd/bskyweb/passimage.go b/bskyweb/cmd/bskyweb/passimage.go new file mode 100644 index 0000000000..2cf5442ee0 --- /dev/null +++ b/bskyweb/cmd/bskyweb/passimage.go @@ -0,0 +1,99 @@ +package main + +import ( + "bytes" + "image" + "image/color" + "image/png" + "io/fs" + "strconv" + + "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/math/fixed" +) + +// CompositeStrip overlays a circular avatar and centered handle text onto the +// per-theme gradient base. Returns PNG bytes. If avatar is nil, a white circle +// is drawn as the fallback (the Bluesky butterfly is rendered by the client +// dialog only; the pass uses an empty circle to keep the server stateless w.r.t. +// brand vector assets). +func CompositeStrip(base image.Image, avatar image.Image, handle string, fontFace font.Face) ([]byte, error) { + bounds := base.Bounds() + dst := image.NewRGBA(bounds) + draw.Draw(dst, bounds, base, bounds.Min, draw.Src) + + cx := bounds.Dx() / 2 + cy := bounds.Dy() / 3 + radius := bounds.Dy() / 4 + + mask := newCircleMask(image.Point{X: cx, Y: cy}, radius, bounds) + if avatar != nil { + fit := fitToSquare(avatar, radius*2) + draw.DrawMask(dst, image.Rect(cx-radius, cy-radius, cx+radius, cy+radius), + fit, image.Point{}, mask, image.Point{X: cx - radius, Y: cy - radius}, draw.Over) + } else { + white := &image.Uniform{C: image.White} + draw.DrawMask(dst, image.Rect(cx-radius, cy-radius, cx+radius, cy+radius), + white, image.Point{}, mask, image.Point{X: cx - radius, Y: cy - radius}, draw.Over) + } + + label := "@" + handle + drawer := &font.Drawer{ + Dst: dst, + Src: &image.Uniform{C: image.White}, + Face: fontFace, + } + advance := drawer.MeasureString(label) + drawer.Dot = fixed.Point26_6{ + X: fixed.I(cx) - advance/2, + Y: fixed.I(cy + radius + fontFace.Metrics().Ascent.Ceil() + 4), + } + drawer.DrawString(label) + + var buf bytes.Buffer + if err := png.Encode(&buf, dst); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func LoadGradientBase(staticFS fs.FS, theme string, scale int) (image.Image, error) { + theme = CoerceTheme(theme) + name := "passes/strip-" + theme + densitySuffix(scale) + ".png" + f, err := staticFS.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + img, _, err := image.Decode(f) + return img, err +} + +func densitySuffix(scale int) string { + if scale <= 1 { + return "" + } + return "@" + strconv.Itoa(scale) + "x" +} + +func newCircleMask(center image.Point, radius int, area image.Rectangle) *image.Alpha { + mask := image.NewAlpha(area) + r2 := radius * radius + for y := area.Min.Y; y < area.Max.Y; y++ { + dy := y - center.Y + for x := area.Min.X; x < area.Max.X; x++ { + dx := x - center.X + if dx*dx+dy*dy <= r2 { + mask.SetAlpha(x, y, color.Alpha{255}) + } + } + } + return mask +} + +func fitToSquare(src image.Image, size int) image.Image { + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + return dst +} diff --git a/bskyweb/cmd/bskyweb/passimage_test.go b/bskyweb/cmd/bskyweb/passimage_test.go new file mode 100644 index 0000000000..bb260f4cbf --- /dev/null +++ b/bskyweb/cmd/bskyweb/passimage_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "bytes" + "image" + _ "image/png" + "os" + "testing" + + "golang.org/x/image/font/basicfont" +) + +func TestCompositeStrip_ProducesValidPNG(t *testing.T) { + baseF, _ := os.Open("testdata/strip-day-base.png") + defer baseF.Close() + base, _, err := image.Decode(baseF) + if err != nil { + t.Fatalf("base decode: %v", err) + } + avF, _ := os.Open("testdata/avatar-test.png") + defer avF.Close() + avatar, _, err := image.Decode(avF) + if err != nil { + t.Fatalf("avatar decode: %v", err) + } + got, err := CompositeStrip(base, avatar, "alice.bsky.social", basicfont.Face7x13) + if err != nil { + t.Fatalf("composite: %v", err) + } + if len(got) < 100 { + t.Fatalf("output too small to be valid PNG: %d bytes", len(got)) + } + if _, _, err := image.Decode(bytes.NewReader(got)); err != nil { + t.Fatalf("output not a valid PNG: %v", err) + } +} + +func TestCompositeStrip_NilAvatarFallsBack(t *testing.T) { + baseF, _ := os.Open("testdata/strip-day-base.png") + defer baseF.Close() + base, _, _ := image.Decode(baseF) + got, err := CompositeStrip(base, nil, "alice.bsky.social", basicfont.Face7x13) + if err != nil { + t.Fatalf("composite: %v", err) + } + if _, _, err := image.Decode(bytes.NewReader(got)); err != nil { + t.Fatalf("output not a valid PNG: %v", err) + } +} diff --git a/bskyweb/cmd/bskyweb/testdata/avatar-test.png b/bskyweb/cmd/bskyweb/testdata/avatar-test.png new file mode 100644 index 0000000000..c62529b3d6 Binary files /dev/null and b/bskyweb/cmd/bskyweb/testdata/avatar-test.png differ diff --git a/bskyweb/cmd/bskyweb/testdata/strip-day-base.png b/bskyweb/cmd/bskyweb/testdata/strip-day-base.png new file mode 100644 index 0000000000..81ae393398 Binary files /dev/null and b/bskyweb/cmd/bskyweb/testdata/strip-day-base.png differ diff --git a/bskyweb/go.mod b/bskyweb/go.mod index e25349e947..9361277de7 100644 --- a/bskyweb/go.mod +++ b/bskyweb/go.mod @@ -91,10 +91,11 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/crypto v0.45.0 // indirect + golang.org/x/image v0.43.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/bskyweb/go.sum b/bskyweb/go.sum index d9b4b0141a..00346d8d96 100644 --- a/bskyweb/go.sum +++ b/bskyweb/go.sum @@ -249,6 +249,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -276,6 +278,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -306,6 +310,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=