mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-13 11:42:23 -05:00
324 lines
10 KiB
Go
324 lines
10 KiB
Go
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||
|
// SPDX-License-Identifier: MIT
|
||
|
|
||
|
package card
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"image"
|
||
|
"image/color"
|
||
|
"io"
|
||
|
"math"
|
||
|
"net/http"
|
||
|
"strings"
|
||
|
"sync"
|
||
|
"time"
|
||
|
|
||
|
_ "image/gif" // for processing gif images
|
||
|
_ "image/jpeg" // for processing jpeg images
|
||
|
_ "image/png" // for processing png images
|
||
|
|
||
|
"code.gitea.io/gitea/modules/log"
|
||
|
"code.gitea.io/gitea/modules/proxy"
|
||
|
"code.gitea.io/gitea/modules/setting"
|
||
|
|
||
|
"github.com/golang/freetype"
|
||
|
"github.com/golang/freetype/truetype"
|
||
|
"golang.org/x/image/draw"
|
||
|
"golang.org/x/image/font"
|
||
|
"golang.org/x/image/font/gofont/goregular"
|
||
|
|
||
|
_ "golang.org/x/image/webp" // for processing webp images
|
||
|
)
|
||
|
|
||
|
type Card struct {
|
||
|
Img *image.RGBA
|
||
|
Font *truetype.Font
|
||
|
Margin int
|
||
|
}
|
||
|
|
||
|
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
|
||
|
return truetype.Parse(goregular.TTF)
|
||
|
})
|
||
|
|
||
|
// 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,
|
||
|
}, 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},
|
||
|
&Card{Img: subright, Font: c.Font}
|
||
|
}
|
||
|
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},
|
||
|
&Card{Img: subbottom, Font: c.Font}
|
||
|
}
|
||
|
|
||
|
// 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
|
||
|
}
|
||
|
|
||
|
// 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
|
||
|
Transport: &http.Transport{
|
||
|
Proxy: proxy.Proxy(),
|
||
|
},
|
||
|
}
|
||
|
|
||
|
resp, err := client.Get(url)
|
||
|
if err != nil {
|
||
|
log.Warn("error when fetching external image from %s: %w", url, err)
|
||
|
return nil, false
|
||
|
}
|
||
|
defer resp.Body.Close()
|
||
|
|
||
|
if resp.StatusCode != http.StatusOK {
|
||
|
log.Warn("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.Warn("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
|
||
|
return nil, false
|
||
|
}
|
||
|
|
||
|
body := io.LimitReader(resp.Body, setting.Avatar.MaxFileSize)
|
||
|
bodyBytes, err := io.ReadAll(body)
|
||
|
if err != nil {
|
||
|
log.Warn("error when fetching external image from %s: %w", url, err)
|
||
|
return nil, false
|
||
|
}
|
||
|
if int64(len(bodyBytes)) == setting.Avatar.MaxFileSize {
|
||
|
log.Warn("while fetching external image response size hit MaxFileSize (%d) and was discarded from url %s", setting.Avatar.MaxFileSize, url)
|
||
|
return nil, false
|
||
|
}
|
||
|
|
||
|
bodyBuffer := bytes.NewReader(bodyBytes)
|
||
|
imgCfg, imgType, err := image.DecodeConfig(bodyBuffer)
|
||
|
if err != nil {
|
||
|
log.Warn("error when decoding external image from %s: %w", 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.Warn("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
|
||
|
return nil, false
|
||
|
}
|
||
|
|
||
|
// do not process image which is too large, it would consume too much memory
|
||
|
if imgCfg.Width > setting.Avatar.MaxWidth {
|
||
|
log.Warn("while fetching external image, width %d exceeds Avatar.MaxWidth %d", imgCfg.Width, setting.Avatar.MaxWidth)
|
||
|
return nil, false
|
||
|
}
|
||
|
if imgCfg.Height > setting.Avatar.MaxHeight {
|
||
|
log.Warn("while fetching external image, height %d exceeds Avatar.MaxHeight %d", imgCfg.Height, setting.Avatar.MaxHeight)
|
||
|
return nil, false
|
||
|
}
|
||
|
|
||
|
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
|
||
|
if err != nil {
|
||
|
log.Warn("error w/ bodyBuffer.Seek")
|
||
|
return nil, false
|
||
|
}
|
||
|
img, _, err := image.Decode(bodyBuffer)
|
||
|
if err != nil {
|
||
|
log.Warn("error when decoding external image from %s: %w", 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)
|
||
|
}
|