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