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