From da8df72de3ef9e30b3c8ffc2286f385b28104e0b Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Sun, 12 Oct 2025 12:37:23 +0300 Subject: [PATCH] appview/issues: og image for issues Change-Id: kvosptqmxzkxxttqppqmoqwlrwoqzvpy Signed-off-by: Anirudh Oppiliappan --- appview/issues/opengraph.go | 267 ++++++++++++++++++ appview/issues/router.go | 1 + appview/{repo => }/ogcard/card.go | 45 ++- .../templates/repo/issues/fragments/og.html | 19 ++ appview/repo/opengraph.go | 8 +- 5 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 appview/issues/opengraph.go rename appview/{repo => }/ogcard/card.go (93%) create mode 100644 appview/pages/templates/repo/issues/fragments/og.html diff --git a/appview/issues/opengraph.go b/appview/issues/opengraph.go new file mode 100644 index 00000000..d09d547a --- /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 index dc34c67a..e0e5dba4 100644 --- a/appview/issues/router.go +++ b/appview/issues/router.go @@ -16,6 +16,7 @@ func (i *Issues) Router(mw *middleware.Middleware) http.Handler { 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/repo/ogcard/card.go b/appview/ogcard/card.go similarity index 93% rename from appview/repo/ogcard/card.go rename to appview/ogcard/card.go index 569ad4e3..6fd1692e 100644 --- a/appview/repo/ogcard/card.go +++ b/appview/ogcard/card.go @@ -394,11 +394,6 @@ func (c *Card) fetchExternalImage(url string) (image.Image, bool) { } 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) @@ -407,6 +402,17 @@ func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 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 { @@ -437,6 +443,35 @@ func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 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 { diff --git a/appview/pages/templates/repo/issues/fragments/og.html b/appview/pages/templates/repo/issues/fragments/og.html new file mode 100644 index 00000000..93f62ef6 --- /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 }} diff --git a/appview/repo/opengraph.go b/appview/repo/opengraph.go index 03b49f50..e5cc2fc0 100644 --- a/appview/repo/opengraph.go +++ b/appview/repo/opengraph.go @@ -15,7 +15,7 @@ import ( "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 @@ func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.Rep // 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 @@ func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.Rep // 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 @@ func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.Rep // 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) } -- 2.43.0