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}