appview/repo: construct and serve og image for repos #648

merged
opened by anirudh.fi targeting master from push-vyusnwqnmxwy
Changed files
+432 -3
appview
pages
templates
fragments
repo
+3 -3
appview/pages/funcmap.go
···
},
"tinyAvatar": func(handle string) string {
-
return p.avatarUri(handle, "tiny")
+
return p.AvatarUrl(handle, "tiny")
},
"fullAvatar": func(handle string) string {
-
return p.avatarUri(handle, "")
+
return p.AvatarUrl(handle, "")
},
"langColor": enry.GetColor,
"layoutSide": func() string {
···
}
}
-
func (p *Pages) avatarUri(handle, size string) string {
+
func (p *Pages) AvatarUrl(handle, size string) string {
handle = strings.TrimPrefix(handle, "@")
secret := p.avatar.SharedSecret
+44
appview/pages/templates/fragments/dolly/silhouette.svg
···
+
<svg
+
version="1.1"
+
id="svg1"
+
width="32"
+
height="32"
+
viewBox="0 0 25 25"
+
sodipodi:docname="tangled_dolly_silhouette.png"
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+
xmlns="http://www.w3.org/2000/svg"
+
xmlns:svg="http://www.w3.org/2000/svg">
+
<title>Dolly</title>
+
<defs
+
id="defs1" />
+
<sodipodi:namedview
+
id="namedview1"
+
pagecolor="#ffffff"
+
bordercolor="#000000"
+
borderopacity="0.25"
+
inkscape:showpageshadow="2"
+
inkscape:pageopacity="0.0"
+
inkscape:pagecheckerboard="true"
+
inkscape:deskcolor="#d1d1d1">
+
<inkscape:page
+
x="0"
+
y="0"
+
width="25"
+
height="25"
+
id="page2"
+
margin="0"
+
bleed="0" />
+
</sodipodi:namedview>
+
<g
+
inkscape:groupmode="layer"
+
inkscape:label="Image"
+
id="g1">
+
<path
+
class="dolly"
+
fill="currentColor"
+
style="stroke-width:1.12248"
+
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
+
id="path1" />
+
</g>
+
</svg>
+376
appview/repo/opengraph.go
···
+
package repo
+
+
import (
+
"bytes"
+
"context"
+
"encoding/hex"
+
"fmt"
+
"image/color"
+
"image/png"
+
"log"
+
"net/http"
+
"sort"
+
"strings"
+
+
"github.com/go-enry/go-enry/v2"
+
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
+
"tangled.org/core/appview/repo/ogcard"
+
"tangled.org/core/types"
+
)
+
+
func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) {
+
width, height := ogcard.DefaultSize()
+
mainCard, err := ogcard.NewCard(width, height)
+
if err != nil {
+
return nil, err
+
}
+
+
// Split: content area (75%) and language bar + icons (25%)
+
contentCard, bottomArea := mainCard.Split(false, 75)
+
+
// Add padding to content
+
contentCard.SetMargin(30)
+
+
// Split content horizontally: main content (80%) and avatar area (20%)
+
mainContent, avatarArea := contentCard.Split(true, 80)
+
+
// Split main content: 50% for name/description, 50% for spacing
+
topSection, _ := mainContent.Split(false, 50)
+
+
// Split top section: 40% for repo name, 60% for description
+
repoNameCard, descriptionCard := topSection.Split(false, 50)
+
+
// Draw repo name with owner in regular and repo name in bold
+
repoNameCard.SetMargin(10)
+
var ownerHandle string
+
owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
+
if err != nil {
+
ownerHandle = repo.Did
+
} else {
+
ownerHandle = "@" + owner.Handle.String()
+
}
+
+
// Draw repo name with wrapping support
+
repoNameCard.SetMargin(10)
+
bounds := repoNameCard.Img.Bounds()
+
startX := bounds.Min.X + repoNameCard.Margin
+
startY := bounds.Min.Y + repoNameCard.Margin
+
currentX := startX
+
textColor := color.RGBA{88, 96, 105, 255}
+
+
// Draw owner handle in gray
+
ownerWidth, err := repoNameCard.DrawTextAtWithWidth(ownerHandle, currentX, startY, textColor, 54, ogcard.Top, ogcard.Left)
+
if err != nil {
+
return nil, err
+
}
+
currentX += ownerWidth
+
+
// Draw separator
+
sepWidth, err := repoNameCard.DrawTextAtWithWidth(" / ", currentX, startY, textColor, 54, ogcard.Top, ogcard.Left)
+
if err != nil {
+
return nil, err
+
}
+
currentX += sepWidth
+
+
// Draw repo name in bold
+
_, err = repoNameCard.DrawBoldText(repo.Name, currentX, startY, color.Black, 54, ogcard.Top, ogcard.Left)
+
if err != nil {
+
return nil, err
+
}
+
+
// Draw description (DrawText handles multi-line wrapping automatically)
+
descriptionCard.SetMargin(10)
+
description := repo.Description
+
if len(description) > 80 {
+
description = description[:100] + "…"
+
}
+
+
_, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left)
+
if err != nil {
+
log.Printf("failed to draw description: %v", err)
+
return nil, err
+
}
+
+
// Draw avatar circle on the right side
+
avatarBounds := avatarArea.Img.Bounds()
+
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
+
if avatarSize > 220 {
+
avatarSize = 220
+
}
+
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
+
avatarY := avatarBounds.Min.Y + 20
+
+
// Get avatar URL and draw it
+
avatarURL := rp.pages.AvatarUrl(ownerHandle, "256")
+
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
+
if err != nil {
+
log.Printf("failed to draw avatar (non-fatal): %v", err)
+
}
+
+
// Split bottom area: icons area (65%) and language bar (35%)
+
iconsArea, languageBarCard := bottomArea.Split(false, 75)
+
+
// Split icons area: left side for stats (80%), right side for dolly (20%)
+
statsArea, dollyArea := iconsArea.Split(true, 80)
+
+
// Draw stats with icons in the stats area
+
starsText := repo.RepoStats.StarCount
+
issuesText := repo.RepoStats.IssueCount.Open
+
pullRequestsText := repo.RepoStats.PullCount.Open
+
+
iconColor := color.RGBA{88, 96, 105, 255}
+
iconSize := 36
+
textSize := 36.0
+
+
// Position stats in the middle of the stats area
+
statsBounds := statsArea.Img.Bounds()
+
statsX := statsBounds.Min.X + 60 // left padding
+
statsY := statsBounds.Min.Y
+
currentX = statsX
+
labelSize := 22.0
+
// Draw star icon, count, and label
+
// Align icon baseline with text baseline
+
iconBaselineOffset := int(textSize) / 2
+
err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
+
if err != nil {
+
log.Printf("failed to draw star icon: %v", err)
+
}
+
starIconX := currentX
+
currentX += iconSize + 15
+
+
starText := fmt.Sprintf("%d", starsText)
+
err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
+
if err != nil {
+
log.Printf("failed to draw star text: %v", err)
+
}
+
starTextWidth := len(starText) * 20
+
starGroupWidth := iconSize + 15 + starTextWidth
+
+
// Draw "stars" label below and centered under the icon+text group
+
labelY := statsY + iconSize + 15
+
labelX := starIconX + starGroupWidth/2
+
err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
+
if err != nil {
+
log.Printf("failed to draw stars label: %v", err)
+
}
+
+
currentX += starTextWidth + 50
+
+
// Draw issues icon, count, and label
+
issueStartX := currentX
+
err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
+
if err != nil {
+
log.Printf("failed to draw circle-dot icon: %v", err)
+
}
+
currentX += iconSize + 15
+
+
issueText := fmt.Sprintf("%d", issuesText)
+
err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
+
if err != nil {
+
log.Printf("failed to draw issue text: %v", err)
+
}
+
issueTextWidth := len(issueText) * 20
+
issueGroupWidth := iconSize + 15 + issueTextWidth
+
+
// Draw "issues" label below and centered under the icon+text group
+
labelX = issueStartX + issueGroupWidth/2
+
err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
+
if err != nil {
+
log.Printf("failed to draw issues label: %v", err)
+
}
+
+
currentX += issueTextWidth + 50
+
+
// Draw pull request icon, count, and label
+
prStartX := currentX
+
err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
+
if err != nil {
+
log.Printf("failed to draw git-pull-request icon: %v", err)
+
}
+
currentX += iconSize + 15
+
+
prText := fmt.Sprintf("%d", pullRequestsText)
+
err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
+
if err != nil {
+
log.Printf("failed to draw PR text: %v", err)
+
}
+
prTextWidth := len(prText) * 20
+
prGroupWidth := iconSize + 15 + prTextWidth
+
+
// Draw "pulls" label below and centered under the icon+text group
+
labelX = prStartX + prGroupWidth/2
+
err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center)
+
if err != nil {
+
log.Printf("failed to draw pulls label: %v", err)
+
}
+
+
dollyBounds := dollyArea.Img.Bounds()
+
dollySize := 90
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
+
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
+
if err != nil {
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
+
}
+
+
// Draw language bar at bottom
+
err = drawLanguagesCard(languageBarCard, languageStats)
+
if err != nil {
+
log.Printf("failed to draw language bar: %v", err)
+
return nil, err
+
}
+
+
return mainCard, nil
+
}
+
+
// hexToColor converts a hex color to a go color
+
func hexToColor(colorStr string) (*color.RGBA, error) {
+
colorStr = strings.TrimLeft(colorStr, "#")
+
+
b, err := hex.DecodeString(colorStr)
+
if err != nil {
+
return nil, err
+
}
+
+
if len(b) < 3 {
+
return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b))
+
}
+
+
clr := color.RGBA{b[0], b[1], b[2], 255}
+
+
return &clr, nil
+
}
+
+
func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error {
+
bounds := card.Img.Bounds()
+
cardWidth := bounds.Dx()
+
+
if len(languageStats) == 0 {
+
// Draw a light gray bar if no languages detected
+
card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255})
+
return nil
+
}
+
+
// Limit to top 5 languages for the visual bar
+
displayLanguages := languageStats
+
if len(displayLanguages) > 5 {
+
displayLanguages = displayLanguages[:5]
+
}
+
+
currentX := bounds.Min.X
+
+
for _, lang := range displayLanguages {
+
var langColor *color.RGBA
+
var err error
+
+
if lang.Color != "" {
+
langColor, err = hexToColor(lang.Color)
+
if err != nil {
+
// Fallback to a default color
+
langColor = &color.RGBA{149, 157, 165, 255}
+
}
+
} else {
+
// Default color if no color specified
+
langColor = &color.RGBA{149, 157, 165, 255}
+
}
+
+
langWidth := float32(cardWidth) * (lang.Percentage / 100)
+
card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor)
+
currentX += int(langWidth)
+
}
+
+
// Fill remaining space with the last color (if any gap due to rounding)
+
if currentX < bounds.Max.X && len(displayLanguages) > 0 {
+
lastLang := displayLanguages[len(displayLanguages)-1]
+
var lastColor *color.RGBA
+
var err error
+
+
if lastLang.Color != "" {
+
lastColor, err = hexToColor(lastLang.Color)
+
if err != nil {
+
lastColor = &color.RGBA{149, 157, 165, 255}
+
}
+
} else {
+
lastColor = &color.RGBA{149, 157, 165, 255}
+
}
+
card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor)
+
}
+
+
return nil
+
}
+
+
func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
// Get language stats directly from database
+
var languageStats []types.RepoLanguageDetails
+
langs, err := db.GetRepoLanguages(
+
rp.db,
+
db.FilterEq("repo_at", f.RepoAt()),
+
db.FilterEq("is_default_ref", 1),
+
)
+
if err != nil {
+
log.Printf("failed to get language stats from db: %v", err)
+
// non-fatal, continue without language stats
+
} else if len(langs) > 0 {
+
var total int64
+
for _, l := range langs {
+
total += l.Bytes
+
}
+
+
for _, l := range langs {
+
percentage := float32(l.Bytes) / float32(total) * 100
+
color := enry.GetColor(l.Language)
+
languageStats = append(languageStats, types.RepoLanguageDetails{
+
Name: l.Language,
+
Percentage: percentage,
+
Color: color,
+
})
+
}
+
+
sort.Slice(languageStats, func(i, j int) bool {
+
if languageStats[i].Name == enry.OtherLanguage {
+
return false
+
}
+
if languageStats[j].Name == enry.OtherLanguage {
+
return true
+
}
+
if languageStats[i].Percentage != languageStats[j].Percentage {
+
return languageStats[i].Percentage > languageStats[j].Percentage
+
}
+
return languageStats[i].Name < languageStats[j].Name
+
})
+
}
+
+
card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats)
+
if err != nil {
+
log.Println("failed to draw repo summary card", err)
+
http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
+
return
+
}
+
+
var imageBuffer bytes.Buffer
+
err = png.Encode(&imageBuffer, card.Img)
+
if err != nil {
+
log.Println("failed to encode repo summary card", err)
+
http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError)
+
return
+
}
+
+
imageBytes := imageBuffer.Bytes()
+
+
w.Header().Set("Content-Type", "image/png")
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
+
w.WriteHeader(http.StatusOK)
+
_, err = w.Write(imageBytes)
+
if err != nil {
+
log.Println("failed to write repo summary card", err)
+
return
+
}
+
}
+1
appview/repo/router.go
···
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
r.Get("/", rp.RepoIndex)
+
r.Get("/opengraph", rp.RepoOpenGraphSummary)
r.Get("/feed.atom", rp.RepoAtomFeed)
r.Get("/commits/{ref}", rp.RepoLog)
r.Route("/tree/{ref}", func(r chi.Router) {
+2
go.mod
···
github.com/redis/go-redis/v9 v9.7.3
github.com/resend/resend-go/v2 v2.15.0
github.com/sethvargo/go-envconfig v1.1.0
+
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
+
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.3.3
github.com/whyrusleeping/cbor-gen v0.3.1
+6
go.sum
···
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+
github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A=
+
github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
···
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=