From a639949d96bed3a387287f6a5ec4e723f016e344 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 6 Jul 2026 15:18:47 +0700 Subject: [PATCH] feat(misc): render wheel beta with canvas --- go.mod | 26 +- go.sum | 83 +++- internal/modules/misc/handlers_test.go | 46 +- internal/modules/misc/wheelofnames_beta.go | 34 +- .../modules/misc/wheelofnames_beta_drawing.go | 415 ++++++++---------- .../phase-01-add-canvas-dependency.md | 45 ++ .../phase-02-migrate-renderer.md | 55 +++ .../phase-03-validate-behavior.md | 52 +++ .../plan.md | 85 ++++ ...eel-beta-gif-rendering-library-research.md | 203 +++++++++ 10 files changed, 734 insertions(+), 310 deletions(-) create mode 100644 plans/260706-1448-wheel-beta-canvas-renderer/phase-01-add-canvas-dependency.md create mode 100644 plans/260706-1448-wheel-beta-canvas-renderer/phase-02-migrate-renderer.md create mode 100644 plans/260706-1448-wheel-beta-canvas-renderer/phase-03-validate-behavior.md create mode 100644 plans/260706-1448-wheel-beta-canvas-renderer/plan.md create mode 100644 plans/reports/260706-1441-wheel-beta-gif-rendering-library-research.md diff --git a/go.mod b/go.mod index d29173a..c5a9f6c 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index f381f70..254c45e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 7bb2da3..31ab3e6 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -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) } } } diff --git a/internal/modules/misc/wheelofnames_beta.go b/internal/modules/misc/wheelofnames_beta.go index fd0bc8a..99e7a86 100644 --- a/internal/modules/misc/wheelofnames_beta.go +++ b/internal/modules/misc/wheelofnames_beta.go @@ -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 { diff --git a/internal/modules/misc/wheelofnames_beta_drawing.go b/internal/modules/misc/wheelofnames_beta_drawing.go index 17710a0..b5edf86 100644 --- a/internal/modules/misc/wheelofnames_beta_drawing.go +++ b/internal/modules/misc/wheelofnames_beta_drawing.go @@ -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() } diff --git a/plans/260706-1448-wheel-beta-canvas-renderer/phase-01-add-canvas-dependency.md b/plans/260706-1448-wheel-beta-canvas-renderer/phase-01-add-canvas-dependency.md new file mode 100644 index 0000000..72423ff --- /dev/null +++ b/plans/260706-1448-wheel-beta-canvas-renderer/phase-01-add-canvas-dependency.md @@ -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. diff --git a/plans/260706-1448-wheel-beta-canvas-renderer/phase-02-migrate-renderer.md b/plans/260706-1448-wheel-beta-canvas-renderer/phase-02-migrate-renderer.md new file mode 100644 index 0000000..680a5a9 --- /dev/null +++ b/plans/260706-1448-wheel-beta-canvas-renderer/phase-02-migrate-renderer.md @@ -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. diff --git a/plans/260706-1448-wheel-beta-canvas-renderer/phase-03-validate-behavior.md b/plans/260706-1448-wheel-beta-canvas-renderer/phase-03-validate-behavior.md new file mode 100644 index 0000000..1718143 --- /dev/null +++ b/plans/260706-1448-wheel-beta-canvas-renderer/phase-03-validate-behavior.md @@ -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. diff --git a/plans/260706-1448-wheel-beta-canvas-renderer/plan.md b/plans/260706-1448-wheel-beta-canvas-renderer/plan.md new file mode 100644 index 0000000..f71b845 --- /dev/null +++ b/plans/260706-1448-wheel-beta-canvas-renderer/plan.md @@ -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 ` 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` diff --git a/plans/reports/260706-1441-wheel-beta-gif-rendering-library-research.md b/plans/reports/260706-1441-wheel-beta-gif-rendering-library-research.md new file mode 100644 index 0000000..38ce8d6 --- /dev/null +++ b/plans/reports/260706-1441-wheel-beta-gif-rendering-library-research.md @@ -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.