Monorepo for Tangled
at master 640 lines 20 kB view raw
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}