diff --git a/appview/issues/opengraph.go b/appview/issues/opengraph.go
new file mode 100644
--- /dev/null
+++ b/appview/issues/opengraph.go
@@ -0,0 +1,267 @@
+package issues
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "log"
+ "net/http"
+
+ "tangled.org/core/appview/models"
+ "tangled.org/core/appview/ogcard"
+)
+
+func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) {
+ width, height := ogcard.DefaultSize()
+ mainCard, err := ogcard.NewCard(width, height)
+ if err != nil {
+ return nil, err
+ }
+
+ // Split: content area (75%) and status/stats area (25%)
+ contentCard, statsArea := mainCard.Split(false, 75)
+
+ // Add padding to content
+ contentCard.SetMargin(50)
+
+ // Split content horizontally: main content (80%) and avatar area (20%)
+ mainContent, avatarArea := contentCard.Split(true, 80)
+
+ // Add margin to main content like repo card
+ mainContent.SetMargin(10)
+
+ // Use full main content area for repo name and title
+ bounds := mainContent.Img.Bounds()
+ startX := bounds.Min.X + mainContent.Margin
+ startY := bounds.Min.Y + mainContent.Margin
+
+ // Draw full repository name at top (owner/repo format)
+ var repoOwner string
+ owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
+ if err != nil {
+ repoOwner = repo.Did
+ } else {
+ repoOwner = "@" + owner.Handle.String()
+ }
+
+ fullRepoName := repoOwner + " / " + repo.Name
+ if len(fullRepoName) > 60 {
+ fullRepoName = fullRepoName[:60] + "…"
+ }
+
+ grayColor := color.RGBA{88, 96, 105, 255}
+ err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ // Draw issue title below repo name with wrapping
+ titleY := startY + 60
+ titleX := startX
+
+ // Truncate title if too long
+ issueTitle := issue.Title
+ maxTitleLength := 80
+ if len(issueTitle) > maxTitleLength {
+ issueTitle = issueTitle[:maxTitleLength] + "…"
+ }
+
+ // Create a temporary card for the title area to enable wrapping
+ titleBounds := mainContent.Img.Bounds()
+ titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
+ titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID
+
+ titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
+ titleCard := &ogcard.Card{
+ Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
+ Font: mainContent.Font,
+ Margin: 0,
+ }
+
+ // Draw wrapped title
+ lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ // Calculate where title ends (number of lines * line height)
+ lineHeight := 60 // Approximate line height for 54pt font
+ titleEndY := titleY + (len(lines) * lineHeight) + 10
+
+ // Draw issue ID in gray below the title
+ issueIdText := fmt.Sprintf("#%d", issue.IssueId)
+ err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get issue author handle (needed for avatar and metadata)
+ var authorHandle string
+ author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did)
+ if err != nil {
+ authorHandle = issue.Did
+ } else {
+ authorHandle = "@" + author.Handle.String()
+ }
+
+ // 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 for issue author
+ avatarURL := rp.pages.AvatarUrl(authorHandle, "256")
+ err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
+ if err != nil {
+ log.Printf("failed to draw avatar (non-fatal): %v", err)
+ }
+
+ // Split stats area: left side for status/comments (80%), right side for dolly (20%)
+ statusCommentsArea, dollyArea := statsArea.Split(true, 80)
+
+ // Draw status and comment count in status/comments area
+ statsBounds := statusCommentsArea.Img.Bounds()
+ statsX := statsBounds.Min.X + 60 // left padding
+ statsY := statsBounds.Min.Y
+
+ iconColor := color.RGBA{88, 96, 105, 255}
+ iconSize := 36
+ textSize := 36.0
+ labelSize := 28.0
+ iconBaselineOffset := int(textSize) / 2
+
+ // Draw status (open/closed) with colored icon and text
+ var statusIcon string
+ var statusText string
+ var statusBgColor color.RGBA
+
+ if issue.Open {
+ statusIcon = "static/icons/circle-dot.svg"
+ statusText = "open"
+ statusBgColor = color.RGBA{34, 139, 34, 255} // green
+ } else {
+ statusIcon = "static/icons/circle-dot.svg"
+ statusText = "closed"
+ statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
+ }
+
+ badgeIconSize := 36
+
+ // Draw icon with status color (no background)
+ err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
+ if err != nil {
+ log.Printf("failed to draw status icon: %v", err)
+ }
+
+ // Draw text with status color (no background)
+ textX := statsX + badgeIconSize + 12
+ badgeTextSize := 32.0
+ err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw status text: %v", err)
+ }
+
+ statusTextWidth := len(statusText) * 20
+ currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
+
+ // Draw comment count
+ err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
+ if err != nil {
+ log.Printf("failed to draw comment icon: %v", err)
+ }
+
+ currentX += iconSize + 15
+ commentText := fmt.Sprintf("%d comments", commentCount)
+ if commentCount == 1 {
+ commentText = "1 comment"
+ }
+ err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw comment text: %v", err)
+ }
+
+ // Draw dolly logo on the right side
+ 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 "opened by @author" and date at the bottom with more spacing
+ labelY := statsY + iconSize + 30
+
+ // Format the opened date
+ openedDate := issue.Created.Format("Jan 2, 2006")
+ metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
+
+ err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw metadata: %v", err)
+ }
+
+ return mainCard, nil
+}
+
+func (rp *Issues) IssueOpenGraphSummary(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
+ }
+
+ issue, ok := r.Context().Value("issue").(*models.Issue)
+ if !ok {
+ log.Println("issue not found in context")
+ http.Error(w, "issue not found", http.StatusNotFound)
+ return
+ }
+
+ // Get comment count
+ commentCount := len(issue.Comments)
+
+ // Get owner handle for avatar
+ var ownerHandle string
+ owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did)
+ if err != nil {
+ ownerHandle = f.Repo.Did
+ } else {
+ ownerHandle = "@" + owner.Handle.String()
+ }
+
+ card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle)
+ if err != nil {
+ log.Println("failed to draw issue summary card", err)
+ http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
+ return
+ }
+
+ var imageBuffer bytes.Buffer
+ err = png.Encode(&imageBuffer, card.Img)
+ if err != nil {
+ log.Println("failed to encode issue summary card", err)
+ http.Error(w, "failed to encode issue 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 issue summary card", err)
+ return
+ }
+}
diff --git a/appview/issues/router.go b/appview/issues/router.go
--- a/appview/issues/router.go
+++ b/appview/issues/router.go
@@ -16,6 +16,7 @@
r.Route("/{issue}", func(r chi.Router) {
r.Use(mw.ResolveIssue)
r.Get("/", i.RepoSingleIssue)
+ r.Get("/opengraph", i.IssueOpenGraphSummary)
// authenticated routes
r.Group(func(r chi.Router) {
diff --git a/appview/ogcard/card.go b/appview/ogcard/card.go
new file mode 100644
--- /dev/null
+++ b/appview/ogcard/card.go
@@ -0,0 +1,535 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
+// SPDX-License-Identifier: MIT
+
+package ogcard
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "io"
+ "log"
+ "math"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/goki/freetype"
+ "github.com/goki/freetype/truetype"
+ "github.com/srwiley/oksvg"
+ "github.com/srwiley/rasterx"
+ "golang.org/x/image/draw"
+ "golang.org/x/image/font"
+ "tangled.org/core/appview/pages"
+
+ _ "golang.org/x/image/webp" // for processing webp images
+)
+
+type Card struct {
+ Img *image.RGBA
+ Font *truetype.Font
+ Margin int
+ Width int
+ Height int
+}
+
+var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
+ interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
+ if err != nil {
+ return nil, err
+ }
+ return truetype.Parse(interVar)
+})
+
+// DefaultSize returns the default size for a card
+func DefaultSize() (int, int) {
+ return 1200, 630
+}
+
+// NewCard creates a new card with the given dimensions in pixels
+func NewCard(width, height int) (*Card, error) {
+ img := image.NewRGBA(image.Rect(0, 0, width, height))
+ draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
+
+ font, err := fontCache()
+ if err != nil {
+ return nil, err
+ }
+
+ return &Card{
+ Img: img,
+ Font: font,
+ Margin: 0,
+ Width: width,
+ Height: height,
+ }, nil
+}
+
+// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
+// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
+func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
+ bounds := c.Img.Bounds()
+ bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
+ if vertical {
+ mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
+ subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
+ subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
+ return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
+ &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
+ }
+ mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
+ subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
+ subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
+ return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
+ &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
+}
+
+// SetMargin sets the margins for the card
+func (c *Card) SetMargin(margin int) {
+ c.Margin = margin
+}
+
+type (
+ VAlign int64
+ HAlign int64
+)
+
+const (
+ Top VAlign = iota
+ Middle
+ Bottom
+)
+
+const (
+ Left HAlign = iota
+ Center
+ Right
+)
+
+// DrawText draws text within the card, respecting margins and alignment
+func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
+ ft := freetype.NewContext()
+ ft.SetDPI(72)
+ ft.SetFont(c.Font)
+ ft.SetFontSize(sizePt)
+ ft.SetClip(c.Img.Bounds())
+ ft.SetDst(c.Img)
+ ft.SetSrc(image.NewUniform(textColor))
+
+ face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
+ fontHeight := ft.PointToFixed(sizePt).Ceil()
+
+ bounds := c.Img.Bounds()
+ bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
+ boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
+ // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
+
+ // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
+ // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
+ // knowing the total height, which is related to how many lines we'll have.
+ lines := make([]string, 0)
+ textWords := strings.Split(text, " ")
+ currentLine := ""
+ heightTotal := 0
+
+ for {
+ if len(textWords) == 0 {
+ // Ran out of words.
+ if currentLine != "" {
+ heightTotal += fontHeight
+ lines = append(lines, currentLine)
+ }
+ break
+ }
+
+ nextWord := textWords[0]
+ proposedLine := currentLine
+ if proposedLine != "" {
+ proposedLine += " "
+ }
+ proposedLine += nextWord
+
+ proposedLineWidth := font.MeasureString(face, proposedLine)
+ if proposedLineWidth.Ceil() > boxWidth {
+ // no, proposed line is too big; we'll use the last "currentLine"
+ heightTotal += fontHeight
+ if currentLine != "" {
+ lines = append(lines, currentLine)
+ currentLine = ""
+ // leave nextWord in textWords and keep going
+ } else {
+ // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
+ // regardless as a line by itself. It will be clipped by the drawing routine.
+ lines = append(lines, nextWord)
+ textWords = textWords[1:]
+ }
+ } else {
+ // yes, it will fit
+ currentLine = proposedLine
+ textWords = textWords[1:]
+ }
+ }
+
+ textY := 0
+ switch valign {
+ case Top:
+ textY = fontHeight
+ case Bottom:
+ textY = boxHeight - heightTotal + fontHeight
+ case Middle:
+ textY = ((boxHeight - heightTotal) / 2) + fontHeight
+ }
+
+ for _, line := range lines {
+ lineWidth := font.MeasureString(face, line)
+
+ textX := 0
+ switch halign {
+ case Left:
+ textX = 0
+ case Right:
+ textX = boxWidth - lineWidth.Ceil()
+ case Center:
+ textX = (boxWidth - lineWidth.Ceil()) / 2
+ }
+
+ pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
+ _, err := ft.DrawString(line, pt)
+ if err != nil {
+ return nil, err
+ }
+
+ textY += fontHeight
+ }
+
+ return lines, nil
+}
+
+// DrawTextAt draws text at a specific position with the given alignment
+func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
+ _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
+ return err
+}
+
+// DrawTextAtWithWidth draws text at a specific position and returns the text width
+func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
+ ft := freetype.NewContext()
+ ft.SetDPI(72)
+ ft.SetFont(c.Font)
+ ft.SetFontSize(sizePt)
+ ft.SetClip(c.Img.Bounds())
+ ft.SetDst(c.Img)
+ ft.SetSrc(image.NewUniform(textColor))
+
+ face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
+ fontHeight := ft.PointToFixed(sizePt).Ceil()
+ lineWidth := font.MeasureString(face, text)
+ textWidth := lineWidth.Ceil()
+
+ // Adjust position based on alignment
+ adjustedX := x
+ adjustedY := y
+
+ switch halign {
+ case Left:
+ // x is already at the left position
+ case Right:
+ adjustedX = x - textWidth
+ case Center:
+ adjustedX = x - textWidth/2
+ }
+
+ switch valign {
+ case Top:
+ adjustedY = y + fontHeight
+ case Bottom:
+ adjustedY = y
+ case Middle:
+ adjustedY = y + fontHeight/2
+ }
+
+ pt := freetype.Pt(adjustedX, adjustedY)
+ _, err := ft.DrawString(text, pt)
+ return textWidth, err
+}
+
+// DrawBoldText draws bold text by rendering multiple times with slight offsets
+func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
+ // Draw the text multiple times with slight offsets to create bold effect
+ offsets := []struct{ dx, dy int }{
+ {0, 0}, // original
+ {1, 0}, // right
+ {0, 1}, // down
+ {1, 1}, // diagonal
+ }
+
+ var width int
+ for _, offset := range offsets {
+ w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
+ if err != nil {
+ return 0, err
+ }
+ if width == 0 {
+ width = w
+ }
+ }
+ return width, nil
+}
+
+// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
+func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
+ svgData, err := pages.Files.ReadFile(svgPath)
+ if err != nil {
+ return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
+ }
+
+ // Convert color to hex string for SVG
+ rgba, isRGBA := iconColor.(color.RGBA)
+ if !isRGBA {
+ r, g, b, a := iconColor.RGBA()
+ rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
+ }
+ colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
+
+ // Replace currentColor with our desired color in the SVG
+ svgString := string(svgData)
+ svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
+
+ // Make the stroke thicker
+ svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
+
+ // Parse SVG
+ icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
+ if err != nil {
+ return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
+ }
+
+ // Set the icon size
+ w, h := float64(size), float64(size)
+ icon.SetTarget(0, 0, w, h)
+
+ // Create a temporary RGBA image for the icon
+ iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
+
+ // Create scanner and rasterizer
+ scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
+ raster := rasterx.NewDasher(size, size, scanner)
+
+ // Draw the icon
+ icon.Draw(raster, 1.0)
+
+ // Draw the icon onto the card at the specified position
+ bounds := c.Img.Bounds()
+ destRect := image.Rect(x, y, x+size, y+size)
+
+ // Make sure we don't draw outside the card bounds
+ if destRect.Max.X > bounds.Max.X {
+ destRect.Max.X = bounds.Max.X
+ }
+ if destRect.Max.Y > bounds.Max.Y {
+ destRect.Max.Y = bounds.Max.Y
+ }
+
+ draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
+
+ return nil
+}
+
+// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
+func (c *Card) DrawImage(img image.Image) {
+ bounds := c.Img.Bounds()
+ targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
+ srcBounds := img.Bounds()
+ srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
+ targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
+
+ var scale float64
+ if srcAspect > targetAspect {
+ // Image is wider than target, scale by width
+ scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
+ } else {
+ // Image is taller or equal, scale by height
+ scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
+ }
+
+ newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
+ newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
+
+ // Center the image within the target rectangle
+ offsetX := (targetRect.Dx() - newWidth) / 2
+ offsetY := (targetRect.Dy() - newHeight) / 2
+
+ scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
+ draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
+}
+
+func fallbackImage() image.Image {
+ // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
+ img := image.NewRGBA(image.Rect(0, 0, 1, 1))
+ img.Set(0, 0, color.White)
+ return img
+}
+
+// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
+func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
+ // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
+ // this rendering process to be slowed down
+ client := &http.Client{
+ Timeout: 1 * time.Second, // 1 second timeout
+ }
+
+ resp, err := client.Get(url)
+ if err != nil {
+ log.Printf("error when fetching external image from %s: %v", url, err)
+ return nil, false
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
+ return nil, false
+ }
+
+ contentType := resp.Header.Get("Content-Type")
+
+ body := resp.Body
+ bodyBytes, err := io.ReadAll(body)
+ if err != nil {
+ log.Printf("error when fetching external image from %s: %v", url, err)
+ return nil, false
+ }
+
+ // Handle SVG separately
+ if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
+ return c.convertSVGToPNG(bodyBytes)
+ }
+
+ // Support content types are in-sync with the allowed custom avatar file types
+ if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
+ log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
+ return nil, false
+ }
+
+ bodyBuffer := bytes.NewReader(bodyBytes)
+ _, imgType, err := image.DecodeConfig(bodyBuffer)
+ if err != nil {
+ log.Printf("error when decoding external image from %s: %v", url, err)
+ return nil, false
+ }
+
+ // Verify that we have a match between actual data understood in the image body and the reported Content-Type
+ if (contentType == "image/png" && imgType != "png") ||
+ (contentType == "image/jpeg" && imgType != "jpeg") ||
+ (contentType == "image/gif" && imgType != "gif") ||
+ (contentType == "image/webp" && imgType != "webp") {
+ log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
+ return nil, false
+ }
+
+ _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
+ if err != nil {
+ log.Printf("error w/ bodyBuffer.Seek")
+ return nil, false
+ }
+ img, _, err := image.Decode(bodyBuffer)
+ if err != nil {
+ log.Printf("error when decoding external image from %s: %v", url, err)
+ return nil, false
+ }
+
+ return img, true
+}
+
+// convertSVGToPNG converts SVG data to a PNG image
+func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
+ // Parse the SVG
+ icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
+ if err != nil {
+ log.Printf("error parsing SVG: %v", err)
+ return nil, false
+ }
+
+ // Set a reasonable size for the rasterized image
+ width := 256
+ height := 256
+ icon.SetTarget(0, 0, float64(width), float64(height))
+
+ // Create an image to draw on
+ rgba := image.NewRGBA(image.Rect(0, 0, width, height))
+
+ // Fill with white background
+ draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
+
+ // Create a scanner and rasterize the SVG
+ scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds())
+ raster := rasterx.NewDasher(width, height, scanner)
+
+ icon.Draw(raster, 1.0)
+
+ return rgba, true
+}
+
+func (c *Card) DrawExternalImage(url string) {
+ image, ok := c.fetchExternalImage(url)
+ if !ok {
+ image = fallbackImage()
+ }
+ c.DrawImage(image)
+}
+
+// DrawCircularExternalImage draws an external image as a circle at the specified position
+func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
+ img, ok := c.fetchExternalImage(url)
+ if !ok {
+ img = fallbackImage()
+ }
+
+ // Create a circular mask
+ circle := image.NewRGBA(image.Rect(0, 0, size, size))
+ center := size / 2
+ radius := float64(size / 2)
+
+ // Scale the source image to fit the circle
+ srcBounds := img.Bounds()
+ scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
+ draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
+
+ // Draw the image with circular clipping
+ for cy := 0; cy < size; cy++ {
+ for cx := 0; cx < size; cx++ {
+ // Calculate distance from center
+ dx := float64(cx - center)
+ dy := float64(cy - center)
+ distance := math.Sqrt(dx*dx + dy*dy)
+
+ // Only draw pixels within the circle
+ if distance <= radius {
+ circle.Set(cx, cy, scaledImg.At(cx, cy))
+ }
+ }
+ }
+
+ // Draw the circle onto the card
+ bounds := c.Img.Bounds()
+ destRect := image.Rect(x, y, x+size, y+size)
+
+ // Make sure we don't draw outside the card bounds
+ if destRect.Max.X > bounds.Max.X {
+ destRect.Max.X = bounds.Max.X
+ }
+ if destRect.Max.Y > bounds.Max.Y {
+ destRect.Max.Y = bounds.Max.Y
+ }
+
+ draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
+
+ return nil
+}
+
+// DrawRect draws a rect with the given color
+func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
+ draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
+}
diff --git a/appview/repo/opengraph.go b/appview/repo/opengraph.go
--- a/appview/repo/opengraph.go
+++ b/appview/repo/opengraph.go
@@ -15,7 +15,7 @@
"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/appview/ogcard"
"tangled.org/core/types"
)
@@ -158,7 +158,7 @@
// 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)
+ err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw star icon: %v", err)
}
@@ -185,7 +185,7 @@
// Draw issues icon, count, and label
issueStartX := currentX
- err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
+ err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw circle-dot icon: %v", err)
}
@@ -210,7 +210,7 @@
// 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)
+ err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw git-pull-request icon: %v", err)
}
diff --git a/appview/repo/ogcard/card.go b/appview/repo/ogcard/card.go
deleted file mode 100644
--- a/appview/repo/ogcard/card.go
+++ /dev/null
@@ -1,500 +0,0 @@
-// Copyright 2024 The Forgejo Authors. All rights reserved.
-// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
-// SPDX-License-Identifier: MIT
-
-package ogcard
-
-import (
- "bytes"
- "fmt"
- "image"
- "image/color"
- "io"
- "log"
- "math"
- "net/http"
- "strings"
- "sync"
- "time"
-
- "github.com/goki/freetype"
- "github.com/goki/freetype/truetype"
- "github.com/srwiley/oksvg"
- "github.com/srwiley/rasterx"
- "golang.org/x/image/draw"
- "golang.org/x/image/font"
- "tangled.org/core/appview/pages"
-
- _ "golang.org/x/image/webp" // for processing webp images
-)
-
-type Card struct {
- Img *image.RGBA
- Font *truetype.Font
- Margin int
- Width int
- Height int
-}
-
-var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
- interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
- if err != nil {
- return nil, err
- }
- return truetype.Parse(interVar)
-})
-
-// DefaultSize returns the default size for a card
-func DefaultSize() (int, int) {
- return 1200, 630
-}
-
-// NewCard creates a new card with the given dimensions in pixels
-func NewCard(width, height int) (*Card, error) {
- img := image.NewRGBA(image.Rect(0, 0, width, height))
- draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
-
- font, err := fontCache()
- if err != nil {
- return nil, err
- }
-
- return &Card{
- Img: img,
- Font: font,
- Margin: 0,
- Width: width,
- Height: height,
- }, nil
-}
-
-// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
-// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
-func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
- bounds := c.Img.Bounds()
- bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
- if vertical {
- mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
- subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
- subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
- return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
- &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
- }
- mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
- subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
- subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
- return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
- &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
-}
-
-// SetMargin sets the margins for the card
-func (c *Card) SetMargin(margin int) {
- c.Margin = margin
-}
-
-type (
- VAlign int64
- HAlign int64
-)
-
-const (
- Top VAlign = iota
- Middle
- Bottom
-)
-
-const (
- Left HAlign = iota
- Center
- Right
-)
-
-// DrawText draws text within the card, respecting margins and alignment
-func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
- ft := freetype.NewContext()
- ft.SetDPI(72)
- ft.SetFont(c.Font)
- ft.SetFontSize(sizePt)
- ft.SetClip(c.Img.Bounds())
- ft.SetDst(c.Img)
- ft.SetSrc(image.NewUniform(textColor))
-
- face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
- fontHeight := ft.PointToFixed(sizePt).Ceil()
-
- bounds := c.Img.Bounds()
- bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
- boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
- // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
-
- // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
- // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
- // knowing the total height, which is related to how many lines we'll have.
- lines := make([]string, 0)
- textWords := strings.Split(text, " ")
- currentLine := ""
- heightTotal := 0
-
- for {
- if len(textWords) == 0 {
- // Ran out of words.
- if currentLine != "" {
- heightTotal += fontHeight
- lines = append(lines, currentLine)
- }
- break
- }
-
- nextWord := textWords[0]
- proposedLine := currentLine
- if proposedLine != "" {
- proposedLine += " "
- }
- proposedLine += nextWord
-
- proposedLineWidth := font.MeasureString(face, proposedLine)
- if proposedLineWidth.Ceil() > boxWidth {
- // no, proposed line is too big; we'll use the last "currentLine"
- heightTotal += fontHeight
- if currentLine != "" {
- lines = append(lines, currentLine)
- currentLine = ""
- // leave nextWord in textWords and keep going
- } else {
- // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
- // regardless as a line by itself. It will be clipped by the drawing routine.
- lines = append(lines, nextWord)
- textWords = textWords[1:]
- }
- } else {
- // yes, it will fit
- currentLine = proposedLine
- textWords = textWords[1:]
- }
- }
-
- textY := 0
- switch valign {
- case Top:
- textY = fontHeight
- case Bottom:
- textY = boxHeight - heightTotal + fontHeight
- case Middle:
- textY = ((boxHeight - heightTotal) / 2) + fontHeight
- }
-
- for _, line := range lines {
- lineWidth := font.MeasureString(face, line)
-
- textX := 0
- switch halign {
- case Left:
- textX = 0
- case Right:
- textX = boxWidth - lineWidth.Ceil()
- case Center:
- textX = (boxWidth - lineWidth.Ceil()) / 2
- }
-
- pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
- _, err := ft.DrawString(line, pt)
- if err != nil {
- return nil, err
- }
-
- textY += fontHeight
- }
-
- return lines, nil
-}
-
-// DrawTextAt draws text at a specific position with the given alignment
-func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
- _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
- return err
-}
-
-// DrawTextAtWithWidth draws text at a specific position and returns the text width
-func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
- ft := freetype.NewContext()
- ft.SetDPI(72)
- ft.SetFont(c.Font)
- ft.SetFontSize(sizePt)
- ft.SetClip(c.Img.Bounds())
- ft.SetDst(c.Img)
- ft.SetSrc(image.NewUniform(textColor))
-
- face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
- fontHeight := ft.PointToFixed(sizePt).Ceil()
- lineWidth := font.MeasureString(face, text)
- textWidth := lineWidth.Ceil()
-
- // Adjust position based on alignment
- adjustedX := x
- adjustedY := y
-
- switch halign {
- case Left:
- // x is already at the left position
- case Right:
- adjustedX = x - textWidth
- case Center:
- adjustedX = x - textWidth/2
- }
-
- switch valign {
- case Top:
- adjustedY = y + fontHeight
- case Bottom:
- adjustedY = y
- case Middle:
- adjustedY = y + fontHeight/2
- }
-
- pt := freetype.Pt(adjustedX, adjustedY)
- _, err := ft.DrawString(text, pt)
- return textWidth, err
-}
-
-// DrawBoldText draws bold text by rendering multiple times with slight offsets
-func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
- // Draw the text multiple times with slight offsets to create bold effect
- offsets := []struct{ dx, dy int }{
- {0, 0}, // original
- {1, 0}, // right
- {0, 1}, // down
- {1, 1}, // diagonal
- }
-
- var width int
- for _, offset := range offsets {
- w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
- if err != nil {
- return 0, err
- }
- if width == 0 {
- width = w
- }
- }
- return width, nil
-}
-
-// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
-func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
- svgData, err := pages.Files.ReadFile(svgPath)
- if err != nil {
- return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
- }
-
- // Convert color to hex string for SVG
- rgba, isRGBA := iconColor.(color.RGBA)
- if !isRGBA {
- r, g, b, a := iconColor.RGBA()
- rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
- }
- colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
-
- // Replace currentColor with our desired color in the SVG
- svgString := string(svgData)
- svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
-
- // Make the stroke thicker
- svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
-
- // Parse SVG
- icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
- if err != nil {
- return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
- }
-
- // Set the icon size
- w, h := float64(size), float64(size)
- icon.SetTarget(0, 0, w, h)
-
- // Create a temporary RGBA image for the icon
- iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
-
- // Create scanner and rasterizer
- scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
- raster := rasterx.NewDasher(size, size, scanner)
-
- // Draw the icon
- icon.Draw(raster, 1.0)
-
- // Draw the icon onto the card at the specified position
- bounds := c.Img.Bounds()
- destRect := image.Rect(x, y, x+size, y+size)
-
- // Make sure we don't draw outside the card bounds
- if destRect.Max.X > bounds.Max.X {
- destRect.Max.X = bounds.Max.X
- }
- if destRect.Max.Y > bounds.Max.Y {
- destRect.Max.Y = bounds.Max.Y
- }
-
- draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
-
- return nil
-}
-
-// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
-func (c *Card) DrawImage(img image.Image) {
- bounds := c.Img.Bounds()
- targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
- srcBounds := img.Bounds()
- srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
- targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
-
- var scale float64
- if srcAspect > targetAspect {
- // Image is wider than target, scale by width
- scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
- } else {
- // Image is taller or equal, scale by height
- scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
- }
-
- newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
- newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
-
- // Center the image within the target rectangle
- offsetX := (targetRect.Dx() - newWidth) / 2
- offsetY := (targetRect.Dy() - newHeight) / 2
-
- scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
- draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
-}
-
-func fallbackImage() image.Image {
- // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
- img := image.NewRGBA(image.Rect(0, 0, 1, 1))
- img.Set(0, 0, color.White)
- return img
-}
-
-// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
-func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
- // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
- // this rendering process to be slowed down
- client := &http.Client{
- Timeout: 1 * time.Second, // 1 second timeout
- }
-
- resp, err := client.Get(url)
- if err != nil {
- log.Printf("error when fetching external image from %s: %v", url, err)
- return nil, false
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
- return nil, false
- }
-
- contentType := resp.Header.Get("Content-Type")
- // Support content types are in-sync with the allowed custom avatar file types
- if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
- log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
- return nil, false
- }
-
- body := resp.Body
- bodyBytes, err := io.ReadAll(body)
- if err != nil {
- log.Printf("error when fetching external image from %s: %v", url, err)
- return nil, false
- }
-
- bodyBuffer := bytes.NewReader(bodyBytes)
- _, imgType, err := image.DecodeConfig(bodyBuffer)
- if err != nil {
- log.Printf("error when decoding external image from %s: %v", url, err)
- return nil, false
- }
-
- // Verify that we have a match between actual data understood in the image body and the reported Content-Type
- if (contentType == "image/png" && imgType != "png") ||
- (contentType == "image/jpeg" && imgType != "jpeg") ||
- (contentType == "image/gif" && imgType != "gif") ||
- (contentType == "image/webp" && imgType != "webp") {
- log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
- return nil, false
- }
-
- _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
- if err != nil {
- log.Printf("error w/ bodyBuffer.Seek")
- return nil, false
- }
- img, _, err := image.Decode(bodyBuffer)
- if err != nil {
- log.Printf("error when decoding external image from %s: %v", url, err)
- return nil, false
- }
-
- return img, true
-}
-
-func (c *Card) DrawExternalImage(url string) {
- image, ok := c.fetchExternalImage(url)
- if !ok {
- image = fallbackImage()
- }
- c.DrawImage(image)
-}
-
-// DrawCircularExternalImage draws an external image as a circle at the specified position
-func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
- img, ok := c.fetchExternalImage(url)
- if !ok {
- img = fallbackImage()
- }
-
- // Create a circular mask
- circle := image.NewRGBA(image.Rect(0, 0, size, size))
- center := size / 2
- radius := float64(size / 2)
-
- // Scale the source image to fit the circle
- srcBounds := img.Bounds()
- scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
- draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
-
- // Draw the image with circular clipping
- for cy := 0; cy < size; cy++ {
- for cx := 0; cx < size; cx++ {
- // Calculate distance from center
- dx := float64(cx - center)
- dy := float64(cy - center)
- distance := math.Sqrt(dx*dx + dy*dy)
-
- // Only draw pixels within the circle
- if distance <= radius {
- circle.Set(cx, cy, scaledImg.At(cx, cy))
- }
- }
- }
-
- // Draw the circle onto the card
- bounds := c.Img.Bounds()
- destRect := image.Rect(x, y, x+size, y+size)
-
- // Make sure we don't draw outside the card bounds
- if destRect.Max.X > bounds.Max.X {
- destRect.Max.X = bounds.Max.X
- }
- if destRect.Max.Y > bounds.Max.Y {
- destRect.Max.Y = bounds.Max.Y
- }
-
- draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
-
- return nil
-}
-
-// DrawRect draws a rect with the given color
-func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
- draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
-}
diff --git a/appview/pages/templates/repo/issues/fragments/og.html b/appview/pages/templates/repo/issues/fragments/og.html
new file mode 100644
--- /dev/null
+++ b/appview/pages/templates/repo/issues/fragments/og.html
@@ -0,0 +1,19 @@
+{{ define "issues/fragments/og" }}
+ {{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }}
+ {{ $description := or .Issue.Body .RepoInfo.Description }}
+ {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
+ {{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ end }}