feat(misc): render wheel beta with canvas

This commit is contained in:
2026-07-06 15:18:47 +07:00
parent 079c5c1a53
commit a639949d96
10 changed files with 734 additions and 310 deletions
+24 -2
View File
@@ -5,18 +5,40 @@ go 1.25.0
require (
github.com/go-telegram/bot v1.20.0
github.com/robfig/cron/v3 v3.0.1
github.com/tdewolff/canvas v0.0.0-20260617131110-529326a1955e
go.mongodb.org/mongo-driver/v2 v2.7.0
golang.org/x/image v0.41.0
)
require (
github.com/google/go-cmp v0.7.0 // indirect
codeberg.org/go-pdf/fpdf v0.11.1 // indirect
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 // indirect
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc // indirect
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 // indirect
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/benoitkugler/textlayout v0.3.2 // indirect
github.com/benoitkugler/textprocessing v0.0.6 // indirect
github.com/go-fonts/latin-modern v0.3.3 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 // indirect
github.com/tdewolff/font v0.0.0-20260424075104-b5eeb1e23189 // indirect
github.com/tdewolff/minify/v2 v2.24.13 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
modernc.org/knuth v0.5.5 // indirect
modernc.org/token v1.1.0 // indirect
star-tex.org/x/tex v0.7.1 // indirect
)
+81 -2
View File
@@ -1,40 +1,108 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
codeberg.org/go-fonts/liberation v0.5.0 h1:SsKoMO1v1OZmzkG2DY+7ZkCL9U+rrWI09niOLfQ5Bo0=
codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU=
codeberg.org/go-latex/latex v0.2.0 h1:Ol/a6VHY06N+5gPfewswymoRb5ZcKDXWVaVegcx4hbI=
codeberg.org/go-latex/latex v0.2.0/go.mod h1:VJAwQir7/T8LZxj7xAPivISKiVOwkMpQ8bTuPQ31X0Y=
codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs=
codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU=
git.sr.ht/~sbinet/cmpimg v0.1.0 h1:E0zPRk2muWuCqSKSVZIWsgtU9pjsw3eKHi8VmQeScxo=
git.sr.ht/~sbinet/cmpimg v0.1.0/go.mod h1:FU12psLbF4TfNXkKH2ZZQ29crIqoiqTZmeQ7dkp/pxE=
git.sr.ht/~sbinet/gg v0.7.0 h1:YmNf7YKd7diDMTPm86hZa1EM3pbkOyD/zzjl0LZUdNM=
git.sr.ht/~sbinet/gg v0.7.0/go.mod h1:VYeli15tpMM4EvqlivlVbbyvWZlOU+EZn4XZmfBGUdM=
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 h1:1qlsVAQJXZHsaM8b6OLVo6muQUQd4CwkH/D3fnnbHXA=
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ=
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 h1:lTG4HQym5oPKjL7nGs+csTgiDna685ZXjxijkne828g=
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0=
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk=
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF7V5IcmiE2sMFV2q3J47BEirxbXJAdzA=
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v/9acCWA64Yz/HDLqjcRTvc0noQACi4MsJw=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.3.2 h1:Y/LTkwpQ9G0Fia9yhPmZA9IR5AnE8Cq30j3C+Gx5/IE=
github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
github.com/benoitkugler/textprocessing v0.0.6 h1:obkMyj62GEPg3xUVYqROlCN22z1OleuZm6ULqX9Om1g=
github.com/benoitkugler/textprocessing v0.0.6/go.mod h1:Io0gN08/PXEzrSOWFa88xHx2Xv3VjvLMY7H76YoI23A=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-fonts/latin-modern v0.3.3 h1:g2xNgI8yzdNzIVm+qvbMryB6yGPe0pSMss8QT3QwlJ0=
github.com/go-fonts/latin-modern v0.3.3/go.mod h1:tHaiWDGze4EPB0Go4cLT5M3QzRY3peya09Z/8KSCrpY=
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f h1:uLR2GaV0kWYZ3Ns3l3sjtiN+mOWAQadvrL8HXcyKjl0=
github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f/go.mod h1:LZwgIPG9X6nH6j5Ef+xMFspl6Hru4b5EJxzMfeqHYJY=
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 h1:ZdkidVdpLW13BQ9a+/3uerT2ezy9J7KQWH18JCfhDmI=
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388/go.mod h1:C/WY5lmWfMtPFYYBTd3Lzdn4FTLr+RxlIeiBNye+/os=
github.com/tdewolff/canvas v0.0.0-20260617131110-529326a1955e h1:Z9HqI3WUPBDwJEDH9BCLpQGvjmGs0XNRDBLry4shZfs=
github.com/tdewolff/canvas v0.0.0-20260617131110-529326a1955e/go.mod h1:Lz12ClttuKvo2jb1UxBfJJWmKV0LmEc8IRo/30R7z+k=
github.com/tdewolff/font v0.0.0-20260424075104-b5eeb1e23189 h1:wP/+8oeXiF55DUy9U0BP4iDD2mNu+o7evt5QLbLHv9E=
github.com/tdewolff/font v0.0.0-20260424075104-b5eeb1e23189/go.mod h1:KjNTiF0TXOXJyWGS/0DPXRknEC20c12kc0JcCYNqtz4=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -42,6 +110,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
@@ -50,3 +119,13 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/plot v0.17.0 h1:d0DwPVBe9jnEGqQBoZGl/P2M9WciJbG2CnV59C9QBT4=
gonum.org/v1/plot v0.17.0/go.mod h1:ipt2GUN1oqzr2O7wCjLDtw1ShfIYYNBp4o0O1Ez5B3Y=
modernc.org/knuth v0.5.5 h1:6lap2U/ISm8aC/4NU58ALFCRllNPaK0EZcIGY/oDgUg=
modernc.org/knuth v0.5.5/go.mod h1:e5SBb35HQBj2aFwbBO3ClPcViLY3Wi0LzaOd7c/3qMk=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
star-tex.org/x/tex v0.7.1 h1:4qGAByRyY0WQsOjtcHlxz+FgrYxz8fzxIds2Gjepp5U=
star-tex.org/x/tex v0.7.1/go.mod h1:Y3y0U7sZTltTh/CDZIx0oAtMjG7eMaTuTtvDZGdyhJo=
+11 -35
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"image"
"image/color"
"image/gif"
"math"
"math/rand/v2"
@@ -13,7 +12,7 @@ import (
"time"
"github.com/go-telegram/bot/models"
"golang.org/x/image/font/sfnt"
"github.com/tdewolff/canvas"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
@@ -427,25 +426,6 @@ func TestWheelOfNamesBeta_RotatesOptionLabelsWithSlices(t *testing.T) {
}
}
func TestWheelOfNamesBeta_RotatedLabelIgnoresFaintMaskPixels(t *testing.T) {
img := image.NewPaletted(image.Rect(0, 0, 7, 7), wheelBetaPalette)
mask := image.NewAlpha(image.Rect(0, 0, 3, 1))
mask.SetAlpha(0, 0, color.Alpha{A: wheelBetaSliceLabelAlphaThreshold - 1})
mask.SetAlpha(1, 0, color.Alpha{A: wheelBetaSliceLabelAlphaThreshold})
mask.SetAlpha(2, 0, color.Alpha{A: 255})
stampRotatedTextMask(img, mask, 3, 3, 0, wheelBetaInkColorIndex, wheelBetaSliceLabelAlphaThreshold)
if got := img.ColorIndexAt(2, 3); got == wheelBetaInkColorIndex {
t.Fatalf("faint mask pixel became ink color %d", got)
}
for _, x := range []int{3, 4} {
if got := img.ColorIndexAt(x, 3); got != wheelBetaInkColorIndex {
t.Fatalf("strong mask pixel at x=%d color = %d, want ink color %d", x, got, wheelBetaInkColorIndex)
}
}
}
func TestWheelOfNamesBeta_DisplayTextPreservesVietnamese(t *testing.T) {
input := "Tiếng Việt Đặng"
got := wheelBetaDisplayText(input, 32)
@@ -458,30 +438,26 @@ func TestWheelOfNamesBeta_DisplayTextPreservesVietnamese(t *testing.T) {
}
func TestWheelOfNamesBeta_FontUsesNormalWeight(t *testing.T) {
subfamily, err := wheelBetaTextFont.Name(nil, sfnt.NameIDSubfamily)
if err != nil {
t.Fatalf("font subfamily: %v", err)
if got := wheelBetaTextFont.Style().Weight(); got != canvas.FontRegular {
t.Fatalf("font weight = %v, want regular non-bold face", got)
}
normalized := strings.ToLower(subfamily)
if strings.Contains(normalized, "bold") {
t.Fatalf("font subfamily = %q, want normal non-bold face", subfamily)
}
if normalized != "book" && normalized != "regular" {
t.Fatalf("font subfamily = %q, want normal face such as Book or Regular", subfamily)
if wheelBetaTextFont.Style().Italic() {
t.Fatalf("font style = %v, want non-italic regular face", wheelBetaTextFont.Style())
}
}
func TestWheelOfNamesBeta_FontSupportsVietnameseGlyphs(t *testing.T) {
face := newWheelBetaTextFace(wheelBetaInkColorIndex)
for _, r := range "Tiếng Việt Đặng Ơ Ư ấ ệ" {
if r == ' ' {
continue
}
glyphIndex, err := wheelBetaTextFont.GlyphIndex(nil, r)
if err != nil {
t.Fatalf("GlyphIndex(%q): %v", r, err)
glyphs := face.Glyphs(string(r))
if len(glyphs) == 0 {
t.Fatalf("Glyphs(%q) is empty, want Vietnamese glyph support", r)
}
if glyphIndex == 0 {
t.Fatalf("GlyphIndex(%q) = 0, want Vietnamese glyph support", r)
if glyphs[0].ID == 0 {
t.Fatalf("Glyphs(%q)[0].ID = 0, want Vietnamese glyph support", r)
}
}
}
+1 -33
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
"math"
)
@@ -104,38 +103,8 @@ func renderWheelBetaFrameWithStatus(options []string, winner int, rotation float
}
func renderWheelBetaFrameWithCelebration(options []string, winner int, rotation float64, reveal bool, status string, celebrateStep int) *image.Paletted {
rect := image.Rect(0, 0, wheelBetaSize, wheelBetaSize)
img := image.NewPaletted(rect, wheelBetaPalette)
draw.Draw(img, rect, image.NewUniform(wheelBetaPalette[wheelBetaBackgroundColorIndex]), image.Point{}, draw.Src)
cx, cy := wheelBetaSize/2, wheelBetaSize/2
drawWheelDropShadow(img, cx, cy)
segment := 2 * math.Pi / float64(len(options))
r2 := wheelBetaRadius * wheelBetaRadius
for y := cy - wheelBetaRadius; y <= cy+wheelBetaRadius; y++ {
for x := cx - wheelBetaRadius; x <= cx+wheelBetaRadius; x++ {
dx, dy := x-cx, y-cy
if dx*dx+dy*dy > r2 {
continue
}
theta := normalizeAngle(math.Atan2(float64(dy), float64(dx)) - rotation)
idx := int(theta / segment)
colorIndex := wheelBetaSliceColorIndexes[idx%len(wheelBetaSliceColorIndexes)]
img.SetColorIndex(x, y, colorIndex)
}
}
currentIndex := currentWheelBetaIndex(len(options), rotation)
pointerColor := wheelBetaSliceColorIndexes[currentIndex%len(wheelBetaSliceColorIndexes)]
drawWheelLighting(img, cx, cy)
drawWheelRim(img, cx, cy)
drawWinnerCelebration(img, celebrateStep)
drawWheelSliceLabels(img, options, rotation)
drawCircle(img, cx, cy, 24, wheelBetaPaperColorIndex)
drawCircle(img, cx, cy, 18, wheelBetaInkColorIndex)
drawPointer(img, cx+wheelBetaRadius, cy, pointerColor)
drawCenteredText(img, "WHEELOFNAMES BETA", cy+wheelBetaRadius+34, wheelBetaInkColorIndex)
label := status
if label == "" {
label = "CURRENT"
@@ -145,8 +114,7 @@ func renderWheelBetaFrameWithCelebration(options []string, winner int, rotation
label = "RESULT"
value = wheelBetaDisplayText(options[winner], 28)
}
drawStatusBand(img, label, value)
return img
return palettizeWheelBetaFrame(renderWheelBetaCanvasImage(options, rotation, pointerColor, label, value, celebrateStep))
}
func finalWheelRotation(optionCount, winner int) float64 {
+177 -238
View File
@@ -3,61 +3,119 @@ package misc
import (
_ "embed"
"image"
"image/color"
"image/draw"
"math"
"strings"
"unicode"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/rasterizer"
)
const (
wheelBetaSliceLabelRadius = 64
wheelBetaSliceLabelAlphaThreshold = 96
wheelBetaFontSize = 10
wheelBetaFontDPI = 72
wheelBetaSliceLabelRadius = 64
wheelBetaFontSize = 10
)
//go:embed fonts/dejavu-sans.ttf
var wheelBetaTextFontBytes []byte
var wheelBetaTextFont = mustWheelBetaTextFont()
var (
wheelBetaTextFont = mustWheelBetaTextFont()
wheelBetaTextFontFamily = mustWheelBetaTextFontFamily()
)
func mustWheelBetaTextFont() *opentype.Font {
parsed, err := opentype.Parse(wheelBetaTextFontBytes)
func mustWheelBetaTextFont() *canvas.Font {
parsed, err := canvas.LoadFont(wheelBetaTextFontBytes, 0, canvas.FontRegular)
if err != nil {
panic(err)
}
return parsed
}
func newWheelBetaTextFace() font.Face {
face, err := opentype.NewFace(wheelBetaTextFont, &opentype.FaceOptions{
Size: wheelBetaFontSize,
DPI: wheelBetaFontDPI,
Hinting: font.HintingFull,
})
if err != nil {
func mustWheelBetaTextFontFamily() *canvas.FontFamily {
family := canvas.NewFontFamily("wheel-beta")
if err := family.LoadFont(wheelBetaTextFontBytes, 0, canvas.FontRegular); err != nil {
panic(err)
}
return face
return family
}
func drawWheelSliceLabels(img *image.Paletted, options []string, rotation float64) {
func newWheelBetaTextFace(colorIndex byte) *canvas.FontFace {
return wheelBetaTextFontFamily.Face(wheelBetaFontSize, wheelBetaPalette[colorIndex], canvas.FontRegular)
}
func renderWheelBetaCanvasImage(options []string, rotation float64, pointerColorIndex byte, label, value string, celebrateStep int) image.Image {
c := canvas.New(float64(wheelBetaSize), float64(wheelBetaSize))
ctx := canvas.NewContext(c)
ctx.SetCoordSystem(canvas.CartesianIV)
ctx.SetStrokeCapper(canvas.RoundCap)
ctx.SetStrokeJoiner(canvas.RoundJoin)
drawWheelBetaRect(ctx, 0, 0, float64(wheelBetaSize), float64(wheelBetaSize), wheelBetaBackgroundColorIndex)
cx := float64(wheelBetaSize) / 2
cy := float64(wheelBetaSize) / 2
drawWheelDropShadow(ctx, cx, cy)
drawWheelSlices(ctx, options, rotation)
drawWheelLighting(ctx, cx, cy)
drawWheelRim(ctx, cx, cy)
drawWinnerCelebration(ctx, celebrateStep)
drawWheelSliceLabels(ctx, options, rotation)
drawCircle(ctx, cx, cy, 24, wheelBetaPaperColorIndex)
drawCircle(ctx, cx, cy, 18, wheelBetaInkColorIndex)
drawPointer(ctx, cx+float64(wheelBetaRadius), cy, pointerColorIndex)
drawCenteredText(ctx, "WHEELOFNAMES BETA", cy+float64(wheelBetaRadius)+24, wheelBetaInkColorIndex)
drawStatusBand(ctx, label, value)
return rasterizer.Draw(c, canvas.DPMM(1), canvas.DefaultColorSpace)
}
func palettizeWheelBetaFrame(src image.Image) *image.Paletted {
rect := image.Rect(0, 0, wheelBetaSize, wheelBetaSize)
img := image.NewPaletted(rect, wheelBetaPalette)
draw.FloydSteinberg.Draw(img, rect, src, image.Point{})
return img
}
func drawWheelSlices(ctx *canvas.Context, options []string, rotation float64) {
if len(options) == 0 {
return
}
cx, cy := wheelBetaSize/2, wheelBetaSize/2
cx := float64(wheelBetaSize) / 2
cy := float64(wheelBetaSize) / 2
segment := 2 * math.Pi / float64(len(options))
steps := int(math.Ceil(float64(wheelBetaRadius) * segment / 4))
if steps < 8 {
steps = 8
}
for idx := range options {
start := rotation + float64(idx)*segment
path := &canvas.Path{}
path.MoveTo(cx, cy)
for step := 0; step <= steps; step++ {
angle := start + segment*float64(step)/float64(steps)
path.LineTo(cx+math.Cos(angle)*float64(wheelBetaRadius), cy+math.Sin(angle)*float64(wheelBetaRadius))
}
path.Close()
drawWheelBetaPath(ctx, path, wheelBetaSliceColorIndexes[idx%len(wheelBetaSliceColorIndexes)])
}
}
func drawWheelSliceLabels(ctx *canvas.Context, options []string, rotation float64) {
if len(options) == 0 {
return
}
cx := float64(wheelBetaSize) / 2
cy := float64(wheelBetaSize) / 2
segment := 2 * math.Pi / float64(len(options))
limit := wheelBetaSliceLabelLimit(len(options))
for idx, option := range options {
angle := rotation + (float64(idx)+0.5)*segment
centerX := cx + int(math.Round(math.Cos(angle)*float64(wheelBetaSliceLabelRadius)))
centerY := cy + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius)))
centerX := cx + math.Cos(angle)*float64(wheelBetaSliceLabelRadius)
centerY := cy + math.Sin(angle)*float64(wheelBetaSliceLabelRadius)
text := wheelBetaDisplayText(option, limit)
drawRotatedCenteredTextAt(img, text, centerX, centerY, angle, wheelBetaInkColorIndex, wheelBetaSliceLabelAlphaThreshold)
drawRotatedCenteredTextAt(ctx, text, centerX, centerY, angle, wheelBetaInkColorIndex)
}
}
@@ -74,216 +132,116 @@ func wheelBetaSliceLabelLimit(optionCount int) int {
}
}
func drawCircle(img *image.Paletted, cx, cy, radius int, colorIndex byte) {
r2 := radius * radius
for y := cy - radius; y <= cy+radius; y++ {
for x := cx - radius; x <= cx+radius; x++ {
dx, dy := x-cx, y-cy
if dx*dx+dy*dy <= r2 {
img.SetColorIndex(x, y, colorIndex)
}
}
}
func drawCircle(ctx *canvas.Context, cx, cy, radius float64, colorIndex byte) {
ctx.SetFillColor(wheelBetaPalette[colorIndex])
ctx.DrawPath(cx, cy, canvas.Circle(radius))
ctx.Fill()
}
func drawWheelDropShadow(img *image.Paletted, cx, cy int) {
rx := wheelBetaRadius + 9
ry := wheelBetaRadius + 5
shadowCY := cy + 8
limit := rx * rx * ry * ry
for y := shadowCY - ry; y <= shadowCY+ry; y++ {
for x := cx - rx; x <= cx+rx; x++ {
dx := x - cx
dy := y - shadowCY
if dx*dx*ry*ry+dy*dy*rx*rx <= limit {
setWheelBetaPixel(img, x, y, wheelBetaShadowColorIndex)
}
}
}
func drawWheelDropShadow(ctx *canvas.Context, cx, cy float64) {
ctx.SetFillColor(wheelBetaPalette[wheelBetaShadowColorIndex])
ctx.DrawPath(cx, cy+8, canvas.Ellipse(float64(wheelBetaRadius+9), float64(wheelBetaRadius+5)))
ctx.Fill()
}
func drawWheelLighting(img *image.Paletted, cx, cy int) {
outer := wheelBetaRadius * wheelBetaRadius
edgeStart := wheelBetaRadius - 13
edge := edgeStart * edgeStart
for y := cy - wheelBetaRadius; y <= cy+wheelBetaRadius; y++ {
for x := cx - wheelBetaRadius; x <= cx+wheelBetaRadius; x++ {
dx, dy := x-cx, y-cy
d2 := dx*dx + dy*dy
if d2 > outer || d2 < edge {
continue
}
if dx+dy > wheelBetaRadius/3 {
img.SetColorIndex(x, y, wheelBetaBevelColorIndex)
}
if dx+dy < -wheelBetaRadius {
img.SetColorIndex(x, y, wheelBetaHighlightColorIndex)
}
}
}
drawHighlightOval(img, cx-30, cy-50, 44, 18)
func drawWheelLighting(ctx *canvas.Context, cx, cy float64) {
drawHighlightOval(ctx, cx-30, cy-50, 44, 18)
drawCircleOutline(ctx, cx, cy, float64(wheelBetaRadius-5), 2, wheelBetaBevelColorIndex)
drawCircleOutline(ctx, cx, cy, float64(wheelBetaRadius-8), 1, wheelBetaHighlightColorIndex)
}
func drawHighlightOval(img *image.Paletted, cx, cy, rx, ry int) {
limit := rx * rx * ry * ry
for y := cy - ry; y <= cy+ry; y++ {
for x := cx - rx; x <= cx+rx; x++ {
dx := x - cx
dy := y - cy
if dx*dx*ry*ry+dy*dy*rx*rx <= limit {
setWheelBetaPixel(img, x, y, wheelBetaHighlightColorIndex)
}
}
}
func drawHighlightOval(ctx *canvas.Context, cx, cy, rx, ry float64) {
ctx.SetFillColor(wheelBetaPalette[wheelBetaHighlightColorIndex])
ctx.DrawPath(cx, cy, canvas.Ellipse(rx, ry))
ctx.Fill()
}
func drawWheelRim(img *image.Paletted, cx, cy int) {
drawCircleOutline(img, cx, cy, wheelBetaRadius, 3, wheelBetaInkColorIndex)
drawCircleOutline(img, cx, cy, wheelBetaRadius-5, 1, wheelBetaBevelColorIndex)
drawCircleOutline(img, cx, cy, wheelBetaRadius-8, 1, wheelBetaHighlightColorIndex)
func drawWheelRim(ctx *canvas.Context, cx, cy float64) {
drawCircleOutline(ctx, cx, cy, float64(wheelBetaRadius)-1.5, 3, wheelBetaInkColorIndex)
drawCircleOutline(ctx, cx, cy, float64(wheelBetaRadius-5), 1, wheelBetaBevelColorIndex)
drawCircleOutline(ctx, cx, cy, float64(wheelBetaRadius-8), 1, wheelBetaHighlightColorIndex)
}
func drawCircleOutline(img *image.Paletted, cx, cy, radius, thickness int, colorIndex byte) {
outer := radius * radius
innerRadius := radius - thickness
inner := innerRadius * innerRadius
for y := cy - radius; y <= cy+radius; y++ {
for x := cx - radius; x <= cx+radius; x++ {
dx, dy := x-cx, y-cy
d2 := dx*dx + dy*dy
if d2 <= outer && d2 >= inner {
img.SetColorIndex(x, y, colorIndex)
}
}
}
func drawCircleOutline(ctx *canvas.Context, cx, cy, radius, thickness float64, colorIndex byte) {
ctx.SetFillColor(canvas.Transparent)
ctx.SetStrokeColor(wheelBetaPalette[colorIndex])
ctx.SetStrokeWidth(thickness)
ctx.DrawPath(cx, cy, canvas.Circle(radius))
ctx.Stroke()
}
func drawPointer(img *image.Paletted, tipX, cy int, fillColorIndex byte) {
for xOffset := 0; xOffset < 34; xOffset++ {
half := xOffset / 2
x := tipX + xOffset
for y := cy - half; y <= cy+half; y++ {
setWheelBetaPixel(img, x, y, wheelBetaInkColorIndex)
}
}
for xOffset := 3; xOffset < 30; xOffset++ {
half := xOffset/2 - 2
if half < 0 {
continue
}
x := tipX + xOffset
for y := cy - half; y <= cy+half; y++ {
setWheelBetaPixel(img, x, y, fillColorIndex)
}
}
func drawPointer(ctx *canvas.Context, tipX, cy float64, fillColorIndex byte) {
outer := &canvas.Path{}
outer.MoveTo(tipX+2, cy)
outer.LineTo(tipX+34, cy+24)
outer.LineTo(tipX+34, cy-24)
outer.Close()
drawWheelBetaPath(ctx, outer, wheelBetaInkColorIndex)
drawWheelBetaRect(ctx, tipX-1, cy-2, tipX+5, cy+2, wheelBetaInkColorIndex)
inner := &canvas.Path{}
inner.MoveTo(tipX+4, cy)
inner.LineTo(tipX+30, cy+10)
inner.LineTo(tipX+30, cy-10)
inner.Close()
drawWheelBetaPath(ctx, inner, fillColorIndex)
}
func drawWinnerCelebration(img *image.Paletted, step int) {
func drawWinnerCelebration(ctx *canvas.Context, step int) {
if step < 0 {
return
}
cx, cy := wheelBetaSize/2, wheelBetaSize/2
cx := float64(wheelBetaSize) / 2
cy := float64(wheelBetaSize) / 2
phase := step % wheelBetaCelebrateFrames
ringRadius := 34 + phase*5
if ringRadius < wheelBetaRadius-8 {
drawCircleOutline(img, cx, cy, ringRadius, 1, wheelBetaSparkColorIndex)
drawCircleOutline(ctx, cx, cy, float64(ringRadius), 1, wheelBetaSparkColorIndex)
}
for i := 0; i < 14; i++ {
angle := (float64(i)/14)*2*math.Pi + float64(phase)*0.31
inner := float64(wheelBetaRadius + 9 + phase%3)
outer := inner + 7 + float64(phase%4)
x1 := cx + int(math.Round(math.Cos(angle)*inner))
y1 := cy + int(math.Round(math.Sin(angle)*inner))
x2 := cx + int(math.Round(math.Cos(angle)*outer))
y2 := cy + int(math.Round(math.Sin(angle)*outer))
x1 := cx + math.Cos(angle)*inner
y1 := cy + math.Sin(angle)*inner
x2 := cx + math.Cos(angle)*outer
y2 := cy + math.Sin(angle)*outer
colorIndex := wheelBetaSliceColorIndexes[(i+phase)%len(wheelBetaSliceColorIndexes)]
if i%5 == 0 {
colorIndex = wheelBetaSparkColorIndex
}
drawPalettedLine(img, x1, y1, x2, y2, colorIndex)
drawSpark(img, x2, y2, colorIndex)
drawWheelBetaLine(ctx, x1, y1, x2, y2, 2, colorIndex)
drawSpark(ctx, x2, y2, colorIndex)
}
}
func drawStatusBand(img *image.Paletted, label, value string) {
for y := 235; y < 286; y++ {
for x := 31; x < wheelBetaSize-25; x++ {
img.SetColorIndex(x, y, wheelBetaShadowColorIndex)
}
}
for y := 230; y < 282; y++ {
for x := 28; x < wheelBetaSize-28; x++ {
img.SetColorIndex(x, y, wheelBetaPaperColorIndex)
}
}
for x := 28; x < wheelBetaSize-28; x++ {
img.SetColorIndex(x, 230, wheelBetaHighlightColorIndex)
img.SetColorIndex(x, 281, wheelBetaBevelColorIndex)
}
drawCenteredText(img, label, 250, wheelBetaInkColorIndex)
drawCenteredText(img, value, 270, wheelBetaInkColorIndex)
func drawStatusBand(ctx *canvas.Context, label, value string) {
drawWheelBetaRect(ctx, 31, 235, float64(wheelBetaSize-25), 286, wheelBetaShadowColorIndex)
drawWheelBetaRect(ctx, 28, 230, float64(wheelBetaSize-28), 282, wheelBetaPaperColorIndex)
drawWheelBetaLine(ctx, 28, 230, float64(wheelBetaSize-28), 230, 1, wheelBetaHighlightColorIndex)
drawWheelBetaLine(ctx, 28, 281, float64(wheelBetaSize-28), 281, 1, wheelBetaBevelColorIndex)
drawCenteredText(ctx, label, 240, wheelBetaInkColorIndex)
drawCenteredText(ctx, value, 260, wheelBetaInkColorIndex)
}
func drawCenteredText(img *image.Paletted, text string, baselineY int, colorIndex byte) {
drawCenteredTextAt(img, text, wheelBetaSize/2, baselineY, colorIndex)
func drawCenteredText(ctx *canvas.Context, text string, topY float64, colorIndex byte) {
drawCenteredTextAt(ctx, text, float64(wheelBetaSize)/2, topY, colorIndex)
}
func drawCenteredTextAt(img *image.Paletted, text string, centerX, baselineY int, colorIndex byte) {
face := newWheelBetaTextFace()
defer func() {
_ = face.Close()
}()
width := font.MeasureString(face, text).Ceil()
x := centerX - width/2
drawer := font.Drawer{
Dst: img,
Src: image.NewUniform(wheelBetaPalette[colorIndex]),
Face: face,
Dot: fixed.P(x, baselineY),
}
drawer.DrawString(text)
func drawCenteredTextAt(ctx *canvas.Context, text string, centerX, topY float64, colorIndex byte) {
face := newWheelBetaTextFace(colorIndex)
ctx.DrawText(centerX, topY, canvas.NewTextLine(face, text, canvas.Center))
}
func drawRotatedCenteredTextAt(img *image.Paletted, text string, centerX, centerY int, angle float64, colorIndex byte, alphaThreshold byte) {
face := newWheelBetaTextFace()
defer func() {
_ = face.Close()
}()
padding := 2
metrics := face.Metrics()
textWidth := font.MeasureString(face, text).Ceil()
textHeight := metrics.Height.Ceil()
mask := image.NewAlpha(image.Rect(0, 0, textWidth+padding*2, textHeight+padding*2))
drawer := font.Drawer{
Dst: mask,
Src: image.NewUniform(color.Alpha{A: 255}),
Face: face,
Dot: fixed.P(padding, padding+metrics.Ascent.Ceil()),
}
drawer.DrawString(text)
stampRotatedTextMask(img, mask, centerX, centerY, angle, colorIndex, alphaThreshold)
}
func stampRotatedTextMask(img *image.Paletted, mask *image.Alpha, centerX, centerY int, angle float64, colorIndex byte, alphaThreshold byte) {
sourceCenterX := float64(mask.Bounds().Dx()-1) / 2
sourceCenterY := float64(mask.Bounds().Dy()-1) / 2
sin, cos := math.Sin(angle), math.Cos(angle)
for y := mask.Bounds().Min.Y; y < mask.Bounds().Max.Y; y++ {
for x := mask.Bounds().Min.X; x < mask.Bounds().Max.X; x++ {
if mask.AlphaAt(x, y).A < alphaThreshold {
continue
}
localX := float64(x) - sourceCenterX
localY := float64(y) - sourceCenterY
targetX := centerX + int(math.Round(localX*cos-localY*sin))
targetY := centerY + int(math.Round(localX*sin+localY*cos))
setWheelBetaPixel(img, targetX, targetY, colorIndex)
}
}
func drawRotatedCenteredTextAt(ctx *canvas.Context, text string, centerX, centerY, angle float64, colorIndex byte) {
face := newWheelBetaTextFace(colorIndex)
topY := centerY - face.Metrics().LineHeight/2
ctx.Push()
ctx.RotateAbout(angle*180/math.Pi, centerX, centerY)
ctx.DrawText(centerX, topY, canvas.NewTextLine(face, text, canvas.Center))
ctx.Pop()
}
func wheelBetaDisplayText(s string, limit int) string {
@@ -308,54 +266,35 @@ func wheelBetaDisplayText(s string, limit int) string {
return out
}
func drawSpark(img *image.Paletted, cx, cy int, colorIndex byte) {
for y := cy - 1; y <= cy+1; y++ {
for x := cx - 1; x <= cx+1; x++ {
if x == cx || y == cy {
setWheelBetaPixel(img, x, y, colorIndex)
}
}
}
func drawSpark(ctx *canvas.Context, cx, cy float64, colorIndex byte) {
drawWheelBetaLine(ctx, cx-2, cy, cx+2, cy, 1, colorIndex)
drawWheelBetaLine(ctx, cx, cy-2, cx, cy+2, 1, colorIndex)
}
func drawPalettedLine(img *image.Paletted, x0, y0, x1, y1 int, colorIndex byte) {
dx := absInt(x1 - x0)
sx := -1
if x0 < x1 {
sx = 1
}
dy := -absInt(y1 - y0)
sy := -1
if y0 < y1 {
sy = 1
}
err := dx + dy
for {
setWheelBetaPixel(img, x0, y0, colorIndex)
if x0 == x1 && y0 == y1 {
return
}
e2 := 2 * err
if e2 >= dy {
err += dy
x0 += sx
}
if e2 <= dx {
err += dx
y0 += sy
}
}
func drawWheelBetaLine(ctx *canvas.Context, x0, y0, x1, y1, width float64, colorIndex byte) {
ctx.SetFillColor(canvas.Transparent)
ctx.SetStrokeColor(wheelBetaPalette[colorIndex])
ctx.SetStrokeWidth(width)
path := &canvas.Path{}
path.MoveTo(x0, y0)
path.LineTo(x1, y1)
ctx.DrawPath(0, 0, path)
ctx.Stroke()
}
func setWheelBetaPixel(img *image.Paletted, x, y int, colorIndex byte) {
if image.Pt(x, y).In(img.Rect) {
img.SetColorIndex(x, y, colorIndex)
}
func drawWheelBetaRect(ctx *canvas.Context, x0, y0, x1, y1 float64, colorIndex byte) {
path := &canvas.Path{}
path.MoveTo(x0, y0)
path.LineTo(x1, y0)
path.LineTo(x1, y1)
path.LineTo(x0, y1)
path.Close()
drawWheelBetaPath(ctx, path, colorIndex)
}
func absInt(v int) int {
if v < 0 {
return -v
}
return v
func drawWheelBetaPath(ctx *canvas.Context, path *canvas.Path, colorIndex byte) {
ctx.SetStrokeColor(canvas.Transparent)
ctx.SetFillColor(wheelBetaPalette[colorIndex])
ctx.DrawPath(0, 0, path)
ctx.Fill()
}
@@ -0,0 +1,45 @@
---
phase: 1
title: Add Canvas Dependency
status: completed
effort: ''
priority: P2
dependencies: []
---
# Phase 1: Add Canvas Dependency
## Overview
Add `github.com/tdewolff/canvas` as the renderer dependency and inspect its local API surface enough to choose the smallest integration path.
## Requirements
- Functional: project builds with the new dependency available to `internal/modules/misc`.
- Non-functional: avoid GUI/game/native-runtime dependencies; keep dependency addition Go-only.
## Architecture
Dependency is used only by the wheel beta renderer. GIF encoding remains in `image/gif`; canvas is only responsible for drawing frame imagery into a Go image.
## Related Code Files
- Modify: `go.mod`
- Modify: `go.sum`
- Read: module docs/source under Go module cache as needed.
## Implementation Steps
1. Run `go get github.com/tdewolff/canvas@latest`.
2. Inspect the installed package APIs for raster image rendering and font/text support.
3. Record any API constraints directly in implementation notes or comments only if useful.
## Success Criteria
- [x] `go.mod` includes `github.com/tdewolff/canvas`.
- [x] No native runtime package such as raylib/cairo/skia is introduced.
- [x] `go test ./internal/modules/misc -run TestWheelOfNamesBeta_RenderGIFTiming -count=1` still compiles or fails only on not-yet-migrated code.
## Risk Assessment
Risk: canvas latest is pseudo-version and API may differ from docs. Mitigation: inspect local package docs/source before editing renderer.
@@ -0,0 +1,55 @@
---
phase: 2
title: Migrate Renderer
status: completed
effort: ''
priority: P1
dependencies:
- 1
---
# Phase 2: Migrate Renderer
## Overview
Move wheel beta frame drawing onto `tdewolff/canvas` while retaining the current frame-generation functions and observable command behavior.
## Requirements
- Functional: `renderWheelBetaFrame` returns frames with same size, palette expectations, pointer alignment, status band, labels, and celebration states.
- Non-functional: keep renderer headless, deterministic, and local to misc module.
## Architecture
The spin profile and GIF assembly stay unchanged. The per-frame renderer creates a canvas-backed raster image, draws the wheel using paths/text/transforms, converts the result to the existing GIF palette, then returns `*image.Paletted` for `gif.EncodeAll`.
```text
spin profile -> canvas frame renderer -> paletted frame -> image/gif.EncodeAll
```
## Related Code Files
- Modify: `internal/modules/misc/wheelofnames_beta.go`
- Modify: `internal/modules/misc/wheelofnames_beta_drawing.go`
- Modify: `internal/modules/misc/handlers_test.go` only if internal rendering tests need adaptation.
## Implementation Steps
1. Add canvas frame creation and raster-to-paletted conversion helpers.
2. Port slices, rim, pointer, highlight, status band, title/status text, labels, and celebration drawing to canvas-backed helpers.
3. Preserve current constants where possible so tests and behavior remain stable.
4. Keep `renderWheelBetaFrame`, `renderWheelBetaFrameWithStatus`, and `renderWheelBetaFrameWithCelebration` call sites intact.
5. Remove obsolete manual text-mask helpers only if no tests depend on them; otherwise update tests to target observable behavior.
## Success Criteria
- [x] Existing GIF timing/frame transition tests pass.
- [x] Existing pointer and winner slice tests pass.
- [x] Existing label placement/rotation and Vietnamese glyph tests pass.
- [x] No command registration, caption, filename, dimensions, or duration changes.
## Risk Assessment
Risk: palette conversion can shift exact color indexes and break tests. Mitigation: keep final paletted frame using `wheelBetaPalette` and adjust only tests that were over-coupled to manual-pixel internals.
Risk: canvas text APIs may require font face plumbing that is heavier than expected. Mitigation: keep embedded font bytes and use canvas text/font APIs only within misc renderer.
@@ -0,0 +1,52 @@
---
phase: 3
title: Validate Behavior
status: completed
effort: ''
priority: P1
dependencies:
- 2
---
# Phase 3: Validate Behavior
## Overview
Verify the canvas-backed renderer preserves command behavior and does not introduce lint, vet, or repo-wide test failures.
## Requirements
- Functional: all acceptance criteria from `plan.md` are verified by tests or code inspection.
- Non-functional: lint and vet pass before any commit.
## Architecture
Validation centers on existing misc tests plus repo-wide Go gates because command behavior and renderer internals share misc package files.
## Related Code Files
- Read/verify: `internal/modules/misc/handlers_test.go`
- Read/verify: `internal/modules/misc/wheelofnames_beta*.go`
- Read/verify: `go.mod`
## Implementation Steps
1. Run focused wheel beta tests first.
2. Run full misc package tests.
3. Run `go test ./...`.
4. Run `go vet ./...`.
5. Run `golangci-lint run`.
6. Review diff for public contract changes and unintended docs/command surface changes.
## Success Criteria
- [x] Focused wheel beta tests pass.
- [x] `go test ./internal/modules/misc -count=1` passes.
- [x] `go test ./...` passes.
- [x] `go vet ./...` passes.
- [x] `golangci-lint run` passes.
- [x] Diff contains no command rename, menu, storage, or Telegram payload contract changes.
## Risk Assessment
Risk: transitive dependencies increase lint/test time. Mitigation: measure via standard gates and keep changes scoped.
@@ -0,0 +1,85 @@
---
title: Wheel Beta Canvas Renderer
description: >-
Migrate /wheelofnamesbeta frame rendering from manual pixel drawing to
github.com/tdewolff/canvas while preserving Telegram behavior.
status: completed
priority: P2
branch: main
tags:
- feature
- backend
- graphics
blockedBy: []
blocks: []
created: '2026-07-06T07:48:34.819Z'
createdBy: 'ck:plan'
source: skill
---
# Wheel Beta Canvas Renderer
## Overview
Migrate `/wheelofnamesbeta` frame rendering to `github.com/tdewolff/canvas` so wheel slices, text, highlights, rim, pointer, and celebration effects are drawn through a higher-level vector renderer. Keep the existing Telegram command contract and stdlib GIF encoding.
Expected output: `/wheelofnamesbeta <options>` still sends a 320x320 animated GIF named `wheelofnamesbeta.gif`, but frames are generated through canvas-backed rendering instead of manual low-level pixel/text stamping.
Acceptance criteria:
- GIF frame count, loop count, delays, duration, width, and height stay unchanged.
- Winner/current option tracking remains aligned with the 3h pointer.
- Vietnamese option text still renders without `?` glyph replacement.
- Slice labels still rotate with their slices and appear inside the wheel.
- `/wheelofnamesbeta` send behavior and caption stay unchanged.
- Focused misc tests, `go test ./...`, `go vet ./...`, and `golangci-lint run` pass.
Scope boundary:
- Do not add `gifski`, ffmpeg, shell commands, cgo graphics stacks, or external runtime binaries.
- Do not change command names, command menu metadata, Telegram API payload shape, or spin-profile math.
- Do not refactor unrelated misc commands.
Constraints:
- Go-only/headless deployment for Coolify.
- Keep embedded DejaVu Sans font use; no runtime font discovery.
- Keep changes localized to `internal/modules/misc` and module dependency files unless tests require otherwise.
- Preserve public/internal function signatures where tests and command code depend on them.
Scout context:
- Project is a Go 1.25 Telegram bot with pluggable modules under `internal/modules`.
- Current wheel beta code lives in `internal/modules/misc/wheelofnames_beta.go`, `wheelofnames_beta_drawing.go`, `wheelofnames_beta_spin_profile.go`, and `wheelofnames_beta_command.go`.
- Existing tests in `internal/modules/misc/handlers_test.go` cover GIF timing, frame transitions, pointer alignment, label placement/rotation, Vietnamese glyph support, and Telegram send payloads.
- Prior completed plan `plans/260703-0404-wheelofnames-misc-command` is complete and non-blocking.
- Research report `plans/reports/260706-1441-wheel-beta-gif-rendering-library-research.md` recommends `tdewolff/canvas` as renderer and keeping `image/gif` first.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Add Canvas Dependency](./phase-01-add-canvas-dependency.md) | Completed |
| 2 | [Migrate Renderer](./phase-02-migrate-renderer.md) | Completed |
| 3 | [Validate Behavior](./phase-03-validate-behavior.md) | Completed |
## Dependencies
Cross-plan dependencies: none.
Implementation dependencies:
- Add `github.com/tdewolff/canvas` via `go get`.
- Continue using stdlib `image/gif` for animation encoding.
- Continue using current embedded `internal/modules/misc/fonts/dejavu-sans.ttf`.
## Touchpoints
| File | Action |
|---|---|
| `go.mod` / `go.sum` | Add canvas module and transitive dependency metadata. |
| `internal/modules/misc/wheelofnames_beta.go` | Route frame generation through canvas-backed renderer while preserving GIF contract. |
| `internal/modules/misc/wheelofnames_beta_drawing.go` | Replace or reduce manual draw helpers with canvas equivalents where practical. |
| `internal/modules/misc/handlers_test.go` | Adjust/add tests only where rendering internals legitimately change. |
## Verification
- `go test ./internal/modules/misc -count=1`
- `go test ./...`
- `go vet ./...`
- `golangci-lint run`
@@ -0,0 +1,203 @@
---
type: research-report
created: 2026-07-06 14:41 UTC
topic: wheel beta gif rendering library options
status: complete
---
# Research Report: Wheel Beta GIF Rendering Library
## Summary
Best fit: use `github.com/tdewolff/canvas` for frame rendering, keep the current stdlib `image/gif` encoder first, and evaluate `gifski` only if GIF color quality remains poor.
Reason: this bot runs server-side as a Go Telegram bot. The rendering stack should stay deterministic, headless, and low-ops. `canvas` gives higher-level vector drawing, affine transforms, text handling, gradients, and raster output without bringing a GUI/game runtime. `gifski` is excellent for final GIF encoding quality, but it adds CLI/C library operational complexity.
Important correction: current code does not use `x/image` to encode GIFs. It uses standard `image/gif` for animation encoding and `golang.org/x/image/font/opentype` for font rasterization. Replacing direct `x/image` usage mostly means replacing our low-level text/glyph-mask drawing path.
## Method
- Scope: Go libraries suitable for rendering animated wheel GIF frames for `/wheelofnamesbeta`.
- Criteria: server deploy friction, text quality, transforms, gradients, animation frame pipeline, dependency size, license, maintenance, integration cost.
- Sources: project code, pkg.go.dev, GitHub READMEs, project homepages.
- Search limit: within `ck:research` 5-call max.
## Current State
Current wheel beta stack:
```text
spin math -> render each frame into image.Paletted -> image/gif.EncodeAll
|
+-> direct pixel loops for slices, rim, pointer
+-> x/image/opentype for title/status/label text
```
Pain points:
- Manual pixel loops make anti-aliasing and gradients awkward.
- Rotated text is custom mask stamping.
- Fixed 14-color palette restricts smooth highlights and anti-aliased text.
- Better animation feel is already mostly math; better visual output needs a higher-level renderer and possibly better quantization.
## Recommendation
### 1. Primary Renderer: `tdewolff/canvas`
Use `github.com/tdewolff/canvas` for drawing each wheel frame into a raster image.
Why:
- It is a Go vector graphics library that outputs raster formats, SVG, PDF, EPS, and more. Its README describes it as a Cairo/node-canvas-style target with path manipulation and text formatting support.
- It supports text drawing through `Context.DrawText`, view transforms through `Context.Rotate`, and raster writers including PNG/GIF via `renderers`.
- The package is current enough for this repo: `go list -m -json github.com/tdewolff/canvas@latest` returned `v0.0.0-20260617131110-529326a1955e` with Go 1.25.
- It avoids GUI/display server concerns.
Trade-offs:
- More complex API than `gg`.
- Not v1/stable-tagged on pkg.go.dev.
- Full package has many imports; use only the subpackages needed.
- It may still use Go image-related packages internally. The value is removing our direct low-level glyph/pixel renderer, not purging every image dependency.
Fit for wheel:
```text
for each frame:
c := canvas.New(widthMM, heightMM)
ctx := canvas.NewContext(c)
draw slices as paths/arcs
draw labels with text object + rotated view
draw pointer, rim, status band
rasterize to RGBA/paletted image
append frame to GIF
```
### 2. Keep `image/gif` Initially
Keep `image/gif.EncodeAll` for first migration. It is already working, tested, and simple.
Change only the frame source first:
```text
old: renderWheelBetaFrame(...) *image.Paletted
new: renderWheelBetaFrameRGBA(...) *image.RGBA
palettize frame
gif.EncodeAll(...)
```
If anti-aliasing is added, the fixed 14-color palette will limit quality. Move to a larger 128/256-color palette before introducing external encoders.
### 3. Optional Encoder Upgrade: `gifski`
Use `gifski` only if we specifically need better GIF color quality after renderer migration.
Why:
- `gifski` is designed for high-quality GIF conversion. Its docs highlight pngquant palettes, temporal dithering, lossy LZW, temporal smoothing, and denoising.
- It accepts PNG frames through CLI, and can also be compiled as a C library.
Why not first:
- Adds external binary or C/Rust/FFI complexity.
- More moving parts in Coolify/self-host deploy.
- Current animation is only 320x320 and simple; the standard GIF pipeline may be good enough with a better renderer and palette.
## Comparison
| Option | Role | Pros | Cons | Verdict |
|---|---|---|---|---|
| `tdewolff/canvas` | Vector renderer | Good transforms, paths, text, gradients, raster targets, current Go metadata | Larger API/import surface, no v1 tag | Best primary renderer |
| `fogleman/gg` | Simple 2D RGBA renderer | Very easy API, anchored text, transforms, gradients | Last tagged release 2019; still uses font/image stack; less advanced text/layout | Good fallback for quick prototype |
| `llgcode/draw2d` | Canvas-style vector renderer | Pure Go, affine transforms, text, image/PDF/SVG backends | Older API style, less compelling than `canvas` for text/layout | Acceptable but not first choice |
| `gifski` | GIF encoder | Best output quality, temporal dithering, PNG-frame input | External binary/C library operational cost | Optional phase 2 encoder |
| `raylib-go` | Game/graphics runtime | Mature 2D/game APIs and animation examples | OpenGL/window/runtime requirements, first build can be heavy, poor fit for headless bot | Avoid |
| Cairo/Skia bindings | Native renderer | Excellent rendering quality | C/C++ deps, cgo/build complexity | Avoid unless Go-native options fail |
## Implementation Path
1. Add a renderer seam:
```go
type wheelBetaFrameRenderer interface {
Render(options []string, winner int, rotation float64, reveal bool, status string, celebrateStep int) image.Image
}
```
2. Keep current renderer as baseline.
3. Add `canvas` renderer behind a small internal function, not public command behavior.
4. Preserve current animation contract:
- same frame count
- same delay array
- same final winner alignment at 3h pointer
- same Telegram `sendAnimation` behavior
- same Vietnamese glyph coverage
5. Add visual-focused tests:
- GIF decodes and has expected frame count/delay.
- Result frame differs from last spin frame.
- Text appears near expected slice label centers.
- Current/winner index still tracks pointer.
- Optional golden-ish image metrics: count non-background pixels, pointer bounds, label bounds.
6. Only after renderer looks better, decide whether encoder upgrade is needed:
- If color banding/text edges remain bad: evaluate 256-color palette and `gifski`.
- If file size too large: tune frame count, palette, or consider MP4 upload if acceptable for Telegram.
## Code Sketch
```go
func renderWheelOfNamesBetaGIF(options []string, winner int) ([]byte, error) {
frames := make([]*image.Paletted, 0, wheelBetaSpinFrames+wheelBetaHoldFrames)
delays := make([]int, 0, cap(frames))
renderer := newWheelBetaCanvasRenderer()
profile := newWheelBetaSpinProfile(len(options), winner, nil)
for i := 0; i < wheelBetaSpinFrames; i++ {
t := float64(i) / float64(wheelBetaSpinFrames-1)
rgba := renderer.Render(options, winner, profile.rotationAt(t), false, profile.statusAt(t), -1)
frames = append(frames, palettizeWheelFrame(rgba))
delays = append(delays, wheelBetaSpinDelay)
}
// hold frames unchanged
return encodeWheelGIF(frames, delays)
}
```
Keep this as an internal refactor; no Telegram command contract change.
## Security And Operations
- Prefer pure Go. Avoid spawning shell commands from message handlers unless strongly justified.
- If using `gifski` CLI later, never pass user-controlled paths or shell strings; write temp frames in a controlled temp dir and execute with `exec.Command` args.
- Watch memory: 50 frames at 320x320 RGBA is about 20 MB before GIF/palette overhead. Fine, but avoid retaining extra PNG buffers if using `gifski`.
- Avoid runtime font discovery. Continue embedding font bytes in the Go package.
## Source Notes
- `tdewolff/canvas` README: vector graphics target with raster output and text formatting; can output PNG/JPG/GIF and other formats. Source: https://github.com/tdewolff/canvas
- `tdewolff/canvas/renderers`: GIF writer accepts `canvas.Resolution`, `canvas.Colorspace`, and `image/gif.*Options`; `Write` supports `.gif`, `.png`, `.webp`, `.svg`, `.pdf`, and more. Source: https://pkg.go.dev/github.com/tdewolff/canvas/renderers
- `tdewolff/canvas` pkg metadata: latest pseudo-version published 2026-06-17, MIT, Go 1.25. Source: https://pkg.go.dev/github.com/tdewolff/canvas
- `fogleman/gg`: creates `image.RGBA` contexts, supports text drawing, anchored text, gradients, transforms, and rotated drawing helpers. Source: https://pkg.go.dev/github.com/fogleman/gg and https://github.com/fogleman/gg
- `draw2d`: supports image/PDF/OpenGL/SVG outputs, text rendering with TrueType fonts, and affine transforms. Source: https://pkg.go.dev/github.com/llgcode/draw2d and https://github.com/llgcode/draw2d
- `gifski`: uses pngquant palettes, temporal dithering, lossy LZW compression, smoothing/denoising; supports CLI and C library integration. Source: https://gif.ski/ and https://github.com/ImageOptim/gifski
- `raylib-go`: game-oriented Go bindings; includes raylib C source and has Linux graphics dependencies or embedded native libraries depending mode. Source: https://github.com/gen2brain/raylib-go
- Cairo: strong renderer but C library/bindings; supports affine transforms and antialiased text, but increases native dependency surface. Source: https://www.cairographics.org/
## Next Steps
1. Prototype `tdewolff/canvas` in a small internal spike rendering one static wheel frame to PNG/GIF.
2. Compare output against current renderer at 320x320 and maybe 640x640 downsampled.
3. If quality is clearly better and build/test cost acceptable, migrate `/wheelofnamesbeta` frame rendering.
4. Only then evaluate `gifski` or broader GIF quantization.
## Unresolved Questions
None.