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 }}