Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/issues: og image for issues

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

authored by

Anirudh Oppiliappan and committed by
Tangled
(Oct 15, 2025, 3:24 PM UTC) 824b0d25 36015515

+826 -504
+267
appview/issues/opengraph.go
··· 1 + package issues 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "log" 11 + "net/http" 12 + 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/ogcard" 15 + ) 16 + 17 + func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) { 18 + width, height := ogcard.DefaultSize() 19 + mainCard, err := ogcard.NewCard(width, height) 20 + if err != nil { 21 + return nil, err 22 + } 23 + 24 + // Split: content area (75%) and status/stats area (25%) 25 + contentCard, statsArea := mainCard.Split(false, 75) 26 + 27 + // Add padding to content 28 + contentCard.SetMargin(50) 29 + 30 + // Split content horizontally: main content (80%) and avatar area (20%) 31 + mainContent, avatarArea := contentCard.Split(true, 80) 32 + 33 + // Add margin to main content like repo card 34 + mainContent.SetMargin(10) 35 + 36 + // Use full main content area for repo name and title 37 + bounds := mainContent.Img.Bounds() 38 + startX := bounds.Min.X + mainContent.Margin 39 + startY := bounds.Min.Y + mainContent.Margin 40 + 41 + // Draw full repository name at top (owner/repo format) 42 + var repoOwner string 43 + owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 44 + if err != nil { 45 + repoOwner = repo.Did 46 + } else { 47 + repoOwner = "@" + owner.Handle.String() 48 + } 49 + 50 + fullRepoName := repoOwner + " / " + repo.Name 51 + if len(fullRepoName) > 60 { 52 + fullRepoName = fullRepoName[:60] + "…" 53 + } 54 + 55 + grayColor := color.RGBA{88, 96, 105, 255} 56 + err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 57 + if err != nil { 58 + return nil, err 59 + } 60 + 61 + // Draw issue title below repo name with wrapping 62 + titleY := startY + 60 63 + titleX := startX 64 + 65 + // Truncate title if too long 66 + issueTitle := issue.Title 67 + maxTitleLength := 80 68 + if len(issueTitle) > maxTitleLength { 69 + issueTitle = issueTitle[:maxTitleLength] + "…" 70 + } 71 + 72 + // Create a temporary card for the title area to enable wrapping 73 + titleBounds := mainContent.Img.Bounds() 74 + titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 75 + titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID 76 + 77 + titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 78 + titleCard := &ogcard.Card{ 79 + Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 80 + Font: mainContent.Font, 81 + Margin: 0, 82 + } 83 + 84 + // Draw wrapped title 85 + lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left) 86 + if err != nil { 87 + return nil, err 88 + } 89 + 90 + // Calculate where title ends (number of lines * line height) 91 + lineHeight := 60 // Approximate line height for 54pt font 92 + titleEndY := titleY + (len(lines) * lineHeight) + 10 93 + 94 + // Draw issue ID in gray below the title 95 + issueIdText := fmt.Sprintf("#%d", issue.IssueId) 96 + err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 97 + if err != nil { 98 + return nil, err 99 + } 100 + 101 + // Get issue author handle (needed for avatar and metadata) 102 + var authorHandle string 103 + author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) 104 + if err != nil { 105 + authorHandle = issue.Did 106 + } else { 107 + authorHandle = "@" + author.Handle.String() 108 + } 109 + 110 + // Draw avatar circle on the right side 111 + avatarBounds := avatarArea.Img.Bounds() 112 + avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 113 + if avatarSize > 220 { 114 + avatarSize = 220 115 + } 116 + avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 117 + avatarY := avatarBounds.Min.Y + 20 118 + 119 + // Get avatar URL for issue author 120 + avatarURL := rp.pages.AvatarUrl(authorHandle, "256") 121 + err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 122 + if err != nil { 123 + log.Printf("failed to draw avatar (non-fatal): %v", err) 124 + } 125 + 126 + // Split stats area: left side for status/comments (80%), right side for dolly (20%) 127 + statusCommentsArea, dollyArea := statsArea.Split(true, 80) 128 + 129 + // Draw status and comment count in status/comments area 130 + statsBounds := statusCommentsArea.Img.Bounds() 131 + statsX := statsBounds.Min.X + 60 // left padding 132 + statsY := statsBounds.Min.Y 133 + 134 + iconColor := color.RGBA{88, 96, 105, 255} 135 + iconSize := 36 136 + textSize := 36.0 137 + labelSize := 28.0 138 + iconBaselineOffset := int(textSize) / 2 139 + 140 + // Draw status (open/closed) with colored icon and text 141 + var statusIcon string 142 + var statusText string 143 + var statusBgColor color.RGBA 144 + 145 + if issue.Open { 146 + statusIcon = "static/icons/circle-dot.svg" 147 + statusText = "open" 148 + statusBgColor = color.RGBA{34, 139, 34, 255} // green 149 + } else { 150 + statusIcon = "static/icons/circle-dot.svg" 151 + statusText = "closed" 152 + statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray 153 + } 154 + 155 + badgeIconSize := 36 156 + 157 + // Draw icon with status color (no background) 158 + err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 159 + if err != nil { 160 + log.Printf("failed to draw status icon: %v", err) 161 + } 162 + 163 + // Draw text with status color (no background) 164 + textX := statsX + badgeIconSize + 12 165 + badgeTextSize := 32.0 166 + err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left) 167 + if err != nil { 168 + log.Printf("failed to draw status text: %v", err) 169 + } 170 + 171 + statusTextWidth := len(statusText) * 20 172 + currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50 173 + 174 + // Draw comment count 175 + err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 176 + if err != nil { 177 + log.Printf("failed to draw comment icon: %v", err) 178 + } 179 + 180 + currentX += iconSize + 15 181 + commentText := fmt.Sprintf("%d comments", commentCount) 182 + if commentCount == 1 { 183 + commentText = "1 comment" 184 + } 185 + err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 186 + if err != nil { 187 + log.Printf("failed to draw comment text: %v", err) 188 + } 189 + 190 + // Draw dolly logo on the right side 191 + dollyBounds := dollyArea.Img.Bounds() 192 + dollySize := 90 193 + dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 + dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 + dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 + err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 197 + if err != nil { 198 + log.Printf("dolly silhouette not available (this is ok): %v", err) 199 + } 200 + 201 + // Draw "opened by @author" and date at the bottom with more spacing 202 + labelY := statsY + iconSize + 30 203 + 204 + // Format the opened date 205 + openedDate := issue.Created.Format("Jan 2, 2006") 206 + metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 207 + 208 + err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 209 + if err != nil { 210 + log.Printf("failed to draw metadata: %v", err) 211 + } 212 + 213 + return mainCard, nil 214 + } 215 + 216 + func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 217 + f, err := rp.repoResolver.Resolve(r) 218 + if err != nil { 219 + log.Println("failed to get repo and knot", err) 220 + return 221 + } 222 + 223 + issue, ok := r.Context().Value("issue").(*models.Issue) 224 + if !ok { 225 + log.Println("issue not found in context") 226 + http.Error(w, "issue not found", http.StatusNotFound) 227 + return 228 + } 229 + 230 + // Get comment count 231 + commentCount := len(issue.Comments) 232 + 233 + // Get owner handle for avatar 234 + var ownerHandle string 235 + owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did) 236 + if err != nil { 237 + ownerHandle = f.Repo.Did 238 + } else { 239 + ownerHandle = "@" + owner.Handle.String() 240 + } 241 + 242 + card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle) 243 + if err != nil { 244 + log.Println("failed to draw issue summary card", err) 245 + http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError) 246 + return 247 + } 248 + 249 + var imageBuffer bytes.Buffer 250 + err = png.Encode(&imageBuffer, card.Img) 251 + if err != nil { 252 + log.Println("failed to encode issue summary card", err) 253 + http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError) 254 + return 255 + } 256 + 257 + imageBytes := imageBuffer.Bytes() 258 + 259 + w.Header().Set("Content-Type", "image/png") 260 + w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 261 + w.WriteHeader(http.StatusOK) 262 + _, err = w.Write(imageBytes) 263 + if err != nil { 264 + log.Println("failed to write issue summary card", err) 265 + return 266 + } 267 + }
+1
appview/issues/router.go
··· 16 16 r.Route("/{issue}", func(r chi.Router) { 17 17 r.Use(mw.ResolveIssue) 18 18 r.Get("/", i.RepoSingleIssue) 19 + r.Get("/opengraph", i.IssueOpenGraphSummary) 19 20 20 21 // authenticated routes 21 22 r.Group(func(r chi.Router) {
+535
appview/ogcard/card.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 + // SPDX-License-Identifier: MIT 4 + 5 + package ogcard 6 + 7 + import ( 8 + "bytes" 9 + "fmt" 10 + "image" 11 + "image/color" 12 + "io" 13 + "log" 14 + "math" 15 + "net/http" 16 + "strings" 17 + "sync" 18 + "time" 19 + 20 + "github.com/goki/freetype" 21 + "github.com/goki/freetype/truetype" 22 + "github.com/srwiley/oksvg" 23 + "github.com/srwiley/rasterx" 24 + "golang.org/x/image/draw" 25 + "golang.org/x/image/font" 26 + "tangled.org/core/appview/pages" 27 + 28 + _ "golang.org/x/image/webp" // for processing webp images 29 + ) 30 + 31 + type Card struct { 32 + Img *image.RGBA 33 + Font *truetype.Font 34 + Margin int 35 + Width int 36 + Height int 37 + } 38 + 39 + var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 + interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 + if err != nil { 42 + return nil, err 43 + } 44 + return truetype.Parse(interVar) 45 + }) 46 + 47 + // DefaultSize returns the default size for a card 48 + func DefaultSize() (int, int) { 49 + return 1200, 630 50 + } 51 + 52 + // NewCard creates a new card with the given dimensions in pixels 53 + func NewCard(width, height int) (*Card, error) { 54 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 + draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 + 57 + font, err := fontCache() 58 + if err != nil { 59 + return nil, err 60 + } 61 + 62 + return &Card{ 63 + Img: img, 64 + Font: font, 65 + Margin: 0, 66 + Width: width, 67 + Height: height, 68 + }, nil 69 + } 70 + 71 + // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 + // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 + func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 + bounds := c.Img.Bounds() 75 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 76 + if vertical { 77 + mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 + subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 + subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 + return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 + &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 + } 83 + mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 + subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 + subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 + return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 + &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 + } 89 + 90 + // SetMargin sets the margins for the card 91 + func (c *Card) SetMargin(margin int) { 92 + c.Margin = margin 93 + } 94 + 95 + type ( 96 + VAlign int64 97 + HAlign int64 98 + ) 99 + 100 + const ( 101 + Top VAlign = iota 102 + Middle 103 + Bottom 104 + ) 105 + 106 + const ( 107 + Left HAlign = iota 108 + Center 109 + Right 110 + ) 111 + 112 + // DrawText draws text within the card, respecting margins and alignment 113 + func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 + ft := freetype.NewContext() 115 + ft.SetDPI(72) 116 + ft.SetFont(c.Font) 117 + ft.SetFontSize(sizePt) 118 + ft.SetClip(c.Img.Bounds()) 119 + ft.SetDst(c.Img) 120 + ft.SetSrc(image.NewUniform(textColor)) 121 + 122 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 + fontHeight := ft.PointToFixed(sizePt).Ceil() 124 + 125 + bounds := c.Img.Bounds() 126 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 127 + boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 + // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 + 130 + // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 131 + // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 + // knowing the total height, which is related to how many lines we'll have. 133 + lines := make([]string, 0) 134 + textWords := strings.Split(text, " ") 135 + currentLine := "" 136 + heightTotal := 0 137 + 138 + for { 139 + if len(textWords) == 0 { 140 + // Ran out of words. 141 + if currentLine != "" { 142 + heightTotal += fontHeight 143 + lines = append(lines, currentLine) 144 + } 145 + break 146 + } 147 + 148 + nextWord := textWords[0] 149 + proposedLine := currentLine 150 + if proposedLine != "" { 151 + proposedLine += " " 152 + } 153 + proposedLine += nextWord 154 + 155 + proposedLineWidth := font.MeasureString(face, proposedLine) 156 + if proposedLineWidth.Ceil() > boxWidth { 157 + // no, proposed line is too big; we'll use the last "currentLine" 158 + heightTotal += fontHeight 159 + if currentLine != "" { 160 + lines = append(lines, currentLine) 161 + currentLine = "" 162 + // leave nextWord in textWords and keep going 163 + } else { 164 + // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 + // regardless as a line by itself. It will be clipped by the drawing routine. 166 + lines = append(lines, nextWord) 167 + textWords = textWords[1:] 168 + } 169 + } else { 170 + // yes, it will fit 171 + currentLine = proposedLine 172 + textWords = textWords[1:] 173 + } 174 + } 175 + 176 + textY := 0 177 + switch valign { 178 + case Top: 179 + textY = fontHeight 180 + case Bottom: 181 + textY = boxHeight - heightTotal + fontHeight 182 + case Middle: 183 + textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 + } 185 + 186 + for _, line := range lines { 187 + lineWidth := font.MeasureString(face, line) 188 + 189 + textX := 0 190 + switch halign { 191 + case Left: 192 + textX = 0 193 + case Right: 194 + textX = boxWidth - lineWidth.Ceil() 195 + case Center: 196 + textX = (boxWidth - lineWidth.Ceil()) / 2 197 + } 198 + 199 + pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 + _, err := ft.DrawString(line, pt) 201 + if err != nil { 202 + return nil, err 203 + } 204 + 205 + textY += fontHeight 206 + } 207 + 208 + return lines, nil 209 + } 210 + 211 + // DrawTextAt draws text at a specific position with the given alignment 212 + func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 + _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 + return err 215 + } 216 + 217 + // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 + func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 + ft := freetype.NewContext() 220 + ft.SetDPI(72) 221 + ft.SetFont(c.Font) 222 + ft.SetFontSize(sizePt) 223 + ft.SetClip(c.Img.Bounds()) 224 + ft.SetDst(c.Img) 225 + ft.SetSrc(image.NewUniform(textColor)) 226 + 227 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 + fontHeight := ft.PointToFixed(sizePt).Ceil() 229 + lineWidth := font.MeasureString(face, text) 230 + textWidth := lineWidth.Ceil() 231 + 232 + // Adjust position based on alignment 233 + adjustedX := x 234 + adjustedY := y 235 + 236 + switch halign { 237 + case Left: 238 + // x is already at the left position 239 + case Right: 240 + adjustedX = x - textWidth 241 + case Center: 242 + adjustedX = x - textWidth/2 243 + } 244 + 245 + switch valign { 246 + case Top: 247 + adjustedY = y + fontHeight 248 + case Bottom: 249 + adjustedY = y 250 + case Middle: 251 + adjustedY = y + fontHeight/2 252 + } 253 + 254 + pt := freetype.Pt(adjustedX, adjustedY) 255 + _, err := ft.DrawString(text, pt) 256 + return textWidth, err 257 + } 258 + 259 + // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 + func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 + // Draw the text multiple times with slight offsets to create bold effect 262 + offsets := []struct{ dx, dy int }{ 263 + {0, 0}, // original 264 + {1, 0}, // right 265 + {0, 1}, // down 266 + {1, 1}, // diagonal 267 + } 268 + 269 + var width int 270 + for _, offset := range offsets { 271 + w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 + if err != nil { 273 + return 0, err 274 + } 275 + if width == 0 { 276 + width = w 277 + } 278 + } 279 + return width, nil 280 + } 281 + 282 + // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 + func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 + svgData, err := pages.Files.ReadFile(svgPath) 285 + if err != nil { 286 + return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 + } 288 + 289 + // Convert color to hex string for SVG 290 + rgba, isRGBA := iconColor.(color.RGBA) 291 + if !isRGBA { 292 + r, g, b, a := iconColor.RGBA() 293 + rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 + } 295 + colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 + 297 + // Replace currentColor with our desired color in the SVG 298 + svgString := string(svgData) 299 + svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 + 301 + // Make the stroke thicker 302 + svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 + 304 + // Parse SVG 305 + icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 + if err != nil { 307 + return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 + } 309 + 310 + // Set the icon size 311 + w, h := float64(size), float64(size) 312 + icon.SetTarget(0, 0, w, h) 313 + 314 + // Create a temporary RGBA image for the icon 315 + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 + 317 + // Create scanner and rasterizer 318 + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 + raster := rasterx.NewDasher(size, size, scanner) 320 + 321 + // Draw the icon 322 + icon.Draw(raster, 1.0) 323 + 324 + // Draw the icon onto the card at the specified position 325 + bounds := c.Img.Bounds() 326 + destRect := image.Rect(x, y, x+size, y+size) 327 + 328 + // Make sure we don't draw outside the card bounds 329 + if destRect.Max.X > bounds.Max.X { 330 + destRect.Max.X = bounds.Max.X 331 + } 332 + if destRect.Max.Y > bounds.Max.Y { 333 + destRect.Max.Y = bounds.Max.Y 334 + } 335 + 336 + draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 + 338 + return nil 339 + } 340 + 341 + // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 342 + func (c *Card) DrawImage(img image.Image) { 343 + bounds := c.Img.Bounds() 344 + targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 345 + srcBounds := img.Bounds() 346 + srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 + targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 + 349 + var scale float64 350 + if srcAspect > targetAspect { 351 + // Image is wider than target, scale by width 352 + scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 + } else { 354 + // Image is taller or equal, scale by height 355 + scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 + } 357 + 358 + newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 + newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 + 361 + // Center the image within the target rectangle 362 + offsetX := (targetRect.Dx() - newWidth) / 2 363 + offsetY := (targetRect.Dy() - newHeight) / 2 364 + 365 + scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 + draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 + } 368 + 369 + func fallbackImage() image.Image { 370 + // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 + img.Set(0, 0, color.White) 373 + return img 374 + } 375 + 376 + // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 + func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 378 + // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 379 + // this rendering process to be slowed down 380 + client := &http.Client{ 381 + Timeout: 1 * time.Second, // 1 second timeout 382 + } 383 + 384 + resp, err := client.Get(url) 385 + if err != nil { 386 + log.Printf("error when fetching external image from %s: %v", url, err) 387 + return nil, false 388 + } 389 + defer resp.Body.Close() 390 + 391 + if resp.StatusCode != http.StatusOK { 392 + log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 + return nil, false 394 + } 395 + 396 + contentType := resp.Header.Get("Content-Type") 397 + 398 + body := resp.Body 399 + bodyBytes, err := io.ReadAll(body) 400 + if err != nil { 401 + log.Printf("error when fetching external image from %s: %v", url, err) 402 + return nil, false 403 + } 404 + 405 + // Handle SVG separately 406 + if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 407 + return c.convertSVGToPNG(bodyBytes) 408 + } 409 + 410 + // Support content types are in-sync with the allowed custom avatar file types 411 + if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 412 + log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 413 + return nil, false 414 + } 415 + 416 + bodyBuffer := bytes.NewReader(bodyBytes) 417 + _, imgType, err := image.DecodeConfig(bodyBuffer) 418 + if err != nil { 419 + log.Printf("error when decoding external image from %s: %v", url, err) 420 + return nil, false 421 + } 422 + 423 + // Verify that we have a match between actual data understood in the image body and the reported Content-Type 424 + if (contentType == "image/png" && imgType != "png") || 425 + (contentType == "image/jpeg" && imgType != "jpeg") || 426 + (contentType == "image/gif" && imgType != "gif") || 427 + (contentType == "image/webp" && imgType != "webp") { 428 + log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 429 + return nil, false 430 + } 431 + 432 + _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 433 + if err != nil { 434 + log.Printf("error w/ bodyBuffer.Seek") 435 + return nil, false 436 + } 437 + img, _, err := image.Decode(bodyBuffer) 438 + if err != nil { 439 + log.Printf("error when decoding external image from %s: %v", url, err) 440 + return nil, false 441 + } 442 + 443 + return img, true 444 + } 445 + 446 + // convertSVGToPNG converts SVG data to a PNG image 447 + func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 448 + // Parse the SVG 449 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 450 + if err != nil { 451 + log.Printf("error parsing SVG: %v", err) 452 + return nil, false 453 + } 454 + 455 + // Set a reasonable size for the rasterized image 456 + width := 256 457 + height := 256 458 + icon.SetTarget(0, 0, float64(width), float64(height)) 459 + 460 + // Create an image to draw on 461 + rgba := image.NewRGBA(image.Rect(0, 0, width, height)) 462 + 463 + // Fill with white background 464 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) 465 + 466 + // Create a scanner and rasterize the SVG 467 + scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds()) 468 + raster := rasterx.NewDasher(width, height, scanner) 469 + 470 + icon.Draw(raster, 1.0) 471 + 472 + return rgba, true 473 + } 474 + 475 + func (c *Card) DrawExternalImage(url string) { 476 + image, ok := c.fetchExternalImage(url) 477 + if !ok { 478 + image = fallbackImage() 479 + } 480 + c.DrawImage(image) 481 + } 482 + 483 + // DrawCircularExternalImage draws an external image as a circle at the specified position 484 + func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 485 + img, ok := c.fetchExternalImage(url) 486 + if !ok { 487 + img = fallbackImage() 488 + } 489 + 490 + // Create a circular mask 491 + circle := image.NewRGBA(image.Rect(0, 0, size, size)) 492 + center := size / 2 493 + radius := float64(size / 2) 494 + 495 + // Scale the source image to fit the circle 496 + srcBounds := img.Bounds() 497 + scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 498 + draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 499 + 500 + // Draw the image with circular clipping 501 + for cy := 0; cy < size; cy++ { 502 + for cx := 0; cx < size; cx++ { 503 + // Calculate distance from center 504 + dx := float64(cx - center) 505 + dy := float64(cy - center) 506 + distance := math.Sqrt(dx*dx + dy*dy) 507 + 508 + // Only draw pixels within the circle 509 + if distance <= radius { 510 + circle.Set(cx, cy, scaledImg.At(cx, cy)) 511 + } 512 + } 513 + } 514 + 515 + // Draw the circle onto the card 516 + bounds := c.Img.Bounds() 517 + destRect := image.Rect(x, y, x+size, y+size) 518 + 519 + // Make sure we don't draw outside the card bounds 520 + if destRect.Max.X > bounds.Max.X { 521 + destRect.Max.X = bounds.Max.X 522 + } 523 + if destRect.Max.Y > bounds.Max.Y { 524 + destRect.Max.Y = bounds.Max.Y 525 + } 526 + 527 + draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 528 + 529 + return nil 530 + } 531 + 532 + // DrawRect draws a rect with the given color 533 + func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 534 + draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 535 + }
+4 -4
appview/repo/opengraph.go
··· 15 15 "github.com/go-enry/go-enry/v2" 16 16 "tangled.org/core/appview/db" 17 17 "tangled.org/core/appview/models" 18 - "tangled.org/core/appview/repo/ogcard" 18 + "tangled.org/core/appview/ogcard" 19 19 "tangled.org/core/types" 20 20 ) 21 21 ··· 158 158 // Draw star icon, count, and label 159 159 // Align icon baseline with text baseline 160 160 iconBaselineOffset := int(textSize) / 2 161 - err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 161 + err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 162 162 if err != nil { 163 163 log.Printf("failed to draw star icon: %v", err) 164 164 } ··· 185 185 186 186 // Draw issues icon, count, and label 187 187 issueStartX := currentX 188 - err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 188 + err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 189 189 if err != nil { 190 190 log.Printf("failed to draw circle-dot icon: %v", err) 191 191 } ··· 210 210 211 211 // Draw pull request icon, count, and label 212 212 prStartX := currentX 213 - err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 213 + err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 214 214 if err != nil { 215 215 log.Printf("failed to draw git-pull-request icon: %v", err) 216 216 }
-500
appview/repo/ogcard/card.go
··· 1 - // Copyright 2024 The Forgejo Authors. All rights reserved. 2 - // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 - // SPDX-License-Identifier: MIT 4 - 5 - package ogcard 6 - 7 - import ( 8 - "bytes" 9 - "fmt" 10 - "image" 11 - "image/color" 12 - "io" 13 - "log" 14 - "math" 15 - "net/http" 16 - "strings" 17 - "sync" 18 - "time" 19 - 20 - "github.com/goki/freetype" 21 - "github.com/goki/freetype/truetype" 22 - "github.com/srwiley/oksvg" 23 - "github.com/srwiley/rasterx" 24 - "golang.org/x/image/draw" 25 - "golang.org/x/image/font" 26 - "tangled.org/core/appview/pages" 27 - 28 - _ "golang.org/x/image/webp" // for processing webp images 29 - ) 30 - 31 - type Card struct { 32 - Img *image.RGBA 33 - Font *truetype.Font 34 - Margin int 35 - Width int 36 - Height int 37 - } 38 - 39 - var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 - interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 - if err != nil { 42 - return nil, err 43 - } 44 - return truetype.Parse(interVar) 45 - }) 46 - 47 - // DefaultSize returns the default size for a card 48 - func DefaultSize() (int, int) { 49 - return 1200, 630 50 - } 51 - 52 - // NewCard creates a new card with the given dimensions in pixels 53 - func NewCard(width, height int) (*Card, error) { 54 - img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 - draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 - 57 - font, err := fontCache() 58 - if err != nil { 59 - return nil, err 60 - } 61 - 62 - return &Card{ 63 - Img: img, 64 - Font: font, 65 - Margin: 0, 66 - Width: width, 67 - Height: height, 68 - }, nil 69 - } 70 - 71 - // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 - // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 - func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 - bounds := c.Img.Bounds() 75 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 76 - if vertical { 77 - mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 - subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 - subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 - return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 - &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 - } 83 - mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 - subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 - subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 - return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 - &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 - } 89 - 90 - // SetMargin sets the margins for the card 91 - func (c *Card) SetMargin(margin int) { 92 - c.Margin = margin 93 - } 94 - 95 - type ( 96 - VAlign int64 97 - HAlign int64 98 - ) 99 - 100 - const ( 101 - Top VAlign = iota 102 - Middle 103 - Bottom 104 - ) 105 - 106 - const ( 107 - Left HAlign = iota 108 - Center 109 - Right 110 - ) 111 - 112 - // DrawText draws text within the card, respecting margins and alignment 113 - func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 - ft := freetype.NewContext() 115 - ft.SetDPI(72) 116 - ft.SetFont(c.Font) 117 - ft.SetFontSize(sizePt) 118 - ft.SetClip(c.Img.Bounds()) 119 - ft.SetDst(c.Img) 120 - ft.SetSrc(image.NewUniform(textColor)) 121 - 122 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 - fontHeight := ft.PointToFixed(sizePt).Ceil() 124 - 125 - bounds := c.Img.Bounds() 126 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 127 - boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 - // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 - 130 - // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 131 - // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 - // knowing the total height, which is related to how many lines we'll have. 133 - lines := make([]string, 0) 134 - textWords := strings.Split(text, " ") 135 - currentLine := "" 136 - heightTotal := 0 137 - 138 - for { 139 - if len(textWords) == 0 { 140 - // Ran out of words. 141 - if currentLine != "" { 142 - heightTotal += fontHeight 143 - lines = append(lines, currentLine) 144 - } 145 - break 146 - } 147 - 148 - nextWord := textWords[0] 149 - proposedLine := currentLine 150 - if proposedLine != "" { 151 - proposedLine += " " 152 - } 153 - proposedLine += nextWord 154 - 155 - proposedLineWidth := font.MeasureString(face, proposedLine) 156 - if proposedLineWidth.Ceil() > boxWidth { 157 - // no, proposed line is too big; we'll use the last "currentLine" 158 - heightTotal += fontHeight 159 - if currentLine != "" { 160 - lines = append(lines, currentLine) 161 - currentLine = "" 162 - // leave nextWord in textWords and keep going 163 - } else { 164 - // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 - // regardless as a line by itself. It will be clipped by the drawing routine. 166 - lines = append(lines, nextWord) 167 - textWords = textWords[1:] 168 - } 169 - } else { 170 - // yes, it will fit 171 - currentLine = proposedLine 172 - textWords = textWords[1:] 173 - } 174 - } 175 - 176 - textY := 0 177 - switch valign { 178 - case Top: 179 - textY = fontHeight 180 - case Bottom: 181 - textY = boxHeight - heightTotal + fontHeight 182 - case Middle: 183 - textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 - } 185 - 186 - for _, line := range lines { 187 - lineWidth := font.MeasureString(face, line) 188 - 189 - textX := 0 190 - switch halign { 191 - case Left: 192 - textX = 0 193 - case Right: 194 - textX = boxWidth - lineWidth.Ceil() 195 - case Center: 196 - textX = (boxWidth - lineWidth.Ceil()) / 2 197 - } 198 - 199 - pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 - _, err := ft.DrawString(line, pt) 201 - if err != nil { 202 - return nil, err 203 - } 204 - 205 - textY += fontHeight 206 - } 207 - 208 - return lines, nil 209 - } 210 - 211 - // DrawTextAt draws text at a specific position with the given alignment 212 - func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 - _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 - return err 215 - } 216 - 217 - // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 - func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 - ft := freetype.NewContext() 220 - ft.SetDPI(72) 221 - ft.SetFont(c.Font) 222 - ft.SetFontSize(sizePt) 223 - ft.SetClip(c.Img.Bounds()) 224 - ft.SetDst(c.Img) 225 - ft.SetSrc(image.NewUniform(textColor)) 226 - 227 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 - fontHeight := ft.PointToFixed(sizePt).Ceil() 229 - lineWidth := font.MeasureString(face, text) 230 - textWidth := lineWidth.Ceil() 231 - 232 - // Adjust position based on alignment 233 - adjustedX := x 234 - adjustedY := y 235 - 236 - switch halign { 237 - case Left: 238 - // x is already at the left position 239 - case Right: 240 - adjustedX = x - textWidth 241 - case Center: 242 - adjustedX = x - textWidth/2 243 - } 244 - 245 - switch valign { 246 - case Top: 247 - adjustedY = y + fontHeight 248 - case Bottom: 249 - adjustedY = y 250 - case Middle: 251 - adjustedY = y + fontHeight/2 252 - } 253 - 254 - pt := freetype.Pt(adjustedX, adjustedY) 255 - _, err := ft.DrawString(text, pt) 256 - return textWidth, err 257 - } 258 - 259 - // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 - func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 - // Draw the text multiple times with slight offsets to create bold effect 262 - offsets := []struct{ dx, dy int }{ 263 - {0, 0}, // original 264 - {1, 0}, // right 265 - {0, 1}, // down 266 - {1, 1}, // diagonal 267 - } 268 - 269 - var width int 270 - for _, offset := range offsets { 271 - w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 - if err != nil { 273 - return 0, err 274 - } 275 - if width == 0 { 276 - width = w 277 - } 278 - } 279 - return width, nil 280 - } 281 - 282 - // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 - func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 - svgData, err := pages.Files.ReadFile(svgPath) 285 - if err != nil { 286 - return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 - } 288 - 289 - // Convert color to hex string for SVG 290 - rgba, isRGBA := iconColor.(color.RGBA) 291 - if !isRGBA { 292 - r, g, b, a := iconColor.RGBA() 293 - rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 - } 295 - colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 - 297 - // Replace currentColor with our desired color in the SVG 298 - svgString := string(svgData) 299 - svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 - 301 - // Make the stroke thicker 302 - svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 - 304 - // Parse SVG 305 - icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 - if err != nil { 307 - return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 - } 309 - 310 - // Set the icon size 311 - w, h := float64(size), float64(size) 312 - icon.SetTarget(0, 0, w, h) 313 - 314 - // Create a temporary RGBA image for the icon 315 - iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 - 317 - // Create scanner and rasterizer 318 - scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 - raster := rasterx.NewDasher(size, size, scanner) 320 - 321 - // Draw the icon 322 - icon.Draw(raster, 1.0) 323 - 324 - // Draw the icon onto the card at the specified position 325 - bounds := c.Img.Bounds() 326 - destRect := image.Rect(x, y, x+size, y+size) 327 - 328 - // Make sure we don't draw outside the card bounds 329 - if destRect.Max.X > bounds.Max.X { 330 - destRect.Max.X = bounds.Max.X 331 - } 332 - if destRect.Max.Y > bounds.Max.Y { 333 - destRect.Max.Y = bounds.Max.Y 334 - } 335 - 336 - draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 - 338 - return nil 339 - } 340 - 341 - // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 342 - func (c *Card) DrawImage(img image.Image) { 343 - bounds := c.Img.Bounds() 344 - targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 345 - srcBounds := img.Bounds() 346 - srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 - targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 - 349 - var scale float64 350 - if srcAspect > targetAspect { 351 - // Image is wider than target, scale by width 352 - scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 - } else { 354 - // Image is taller or equal, scale by height 355 - scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 - } 357 - 358 - newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 - newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 - 361 - // Center the image within the target rectangle 362 - offsetX := (targetRect.Dx() - newWidth) / 2 363 - offsetY := (targetRect.Dy() - newHeight) / 2 364 - 365 - scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 - draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 - } 368 - 369 - func fallbackImage() image.Image { 370 - // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 - img.Set(0, 0, color.White) 373 - return img 374 - } 375 - 376 - // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 - func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 378 - // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 379 - // this rendering process to be slowed down 380 - client := &http.Client{ 381 - Timeout: 1 * time.Second, // 1 second timeout 382 - } 383 - 384 - resp, err := client.Get(url) 385 - if err != nil { 386 - log.Printf("error when fetching external image from %s: %v", url, err) 387 - return nil, false 388 - } 389 - defer resp.Body.Close() 390 - 391 - if resp.StatusCode != http.StatusOK { 392 - log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 - return nil, false 394 - } 395 - 396 - contentType := resp.Header.Get("Content-Type") 397 - // Support content types are in-sync with the allowed custom avatar file types 398 - if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 399 - log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 400 - return nil, false 401 - } 402 - 403 - body := resp.Body 404 - bodyBytes, err := io.ReadAll(body) 405 - if err != nil { 406 - log.Printf("error when fetching external image from %s: %v", url, err) 407 - return nil, false 408 - } 409 - 410 - bodyBuffer := bytes.NewReader(bodyBytes) 411 - _, imgType, err := image.DecodeConfig(bodyBuffer) 412 - if err != nil { 413 - log.Printf("error when decoding external image from %s: %v", url, err) 414 - return nil, false 415 - } 416 - 417 - // Verify that we have a match between actual data understood in the image body and the reported Content-Type 418 - if (contentType == "image/png" && imgType != "png") || 419 - (contentType == "image/jpeg" && imgType != "jpeg") || 420 - (contentType == "image/gif" && imgType != "gif") || 421 - (contentType == "image/webp" && imgType != "webp") { 422 - log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 423 - return nil, false 424 - } 425 - 426 - _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 427 - if err != nil { 428 - log.Printf("error w/ bodyBuffer.Seek") 429 - return nil, false 430 - } 431 - img, _, err := image.Decode(bodyBuffer) 432 - if err != nil { 433 - log.Printf("error when decoding external image from %s: %v", url, err) 434 - return nil, false 435 - } 436 - 437 - return img, true 438 - } 439 - 440 - func (c *Card) DrawExternalImage(url string) { 441 - image, ok := c.fetchExternalImage(url) 442 - if !ok { 443 - image = fallbackImage() 444 - } 445 - c.DrawImage(image) 446 - } 447 - 448 - // DrawCircularExternalImage draws an external image as a circle at the specified position 449 - func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 450 - img, ok := c.fetchExternalImage(url) 451 - if !ok { 452 - img = fallbackImage() 453 - } 454 - 455 - // Create a circular mask 456 - circle := image.NewRGBA(image.Rect(0, 0, size, size)) 457 - center := size / 2 458 - radius := float64(size / 2) 459 - 460 - // Scale the source image to fit the circle 461 - srcBounds := img.Bounds() 462 - scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 463 - draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 464 - 465 - // Draw the image with circular clipping 466 - for cy := 0; cy < size; cy++ { 467 - for cx := 0; cx < size; cx++ { 468 - // Calculate distance from center 469 - dx := float64(cx - center) 470 - dy := float64(cy - center) 471 - distance := math.Sqrt(dx*dx + dy*dy) 472 - 473 - // Only draw pixels within the circle 474 - if distance <= radius { 475 - circle.Set(cx, cy, scaledImg.At(cx, cy)) 476 - } 477 - } 478 - } 479 - 480 - // Draw the circle onto the card 481 - bounds := c.Img.Bounds() 482 - destRect := image.Rect(x, y, x+size, y+size) 483 - 484 - // Make sure we don't draw outside the card bounds 485 - if destRect.Max.X > bounds.Max.X { 486 - destRect.Max.X = bounds.Max.X 487 - } 488 - if destRect.Max.Y > bounds.Max.Y { 489 - destRect.Max.Y = bounds.Max.Y 490 - } 491 - 492 - draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 493 - 494 - return nil 495 - } 496 - 497 - // DrawRect draws a rect with the given color 498 - func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 499 - draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 500 - }
+19
appview/pages/templates/repo/issues/fragments/og.html
··· 1 + {{ define "issues/fragments/og" }} 2 + {{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }} 3 + {{ $description := or .Issue.Body .RepoInfo.Description }} 4 + {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }} 6 + 7 + <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 + <meta property="og:type" content="object" /> 9 + <meta property="og:url" content="{{ $url }}" /> 10 + <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 + {{ end }}