362 lines
9.1 KiB
Go
362 lines
9.1 KiB
Go
package main
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"image"
|
||
|
|
"image/color"
|
||
|
|
"image/draw"
|
||
|
|
"math"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"runtime"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
|
||
|
|
"fyne.io/fyne/v2"
|
||
|
|
"golang.org/x/image/font"
|
||
|
|
"golang.org/x/image/font/basicfont"
|
||
|
|
"golang.org/x/image/font/opentype"
|
||
|
|
"golang.org/x/image/math/fixed"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
minTextSizePercent = 0.5
|
||
|
|
maxTextSizePercent = 12.0
|
||
|
|
textSizePercentStep = 0.1
|
||
|
|
)
|
||
|
|
|
||
|
|
type SystemFont struct {
|
||
|
|
Name string
|
||
|
|
Path string
|
||
|
|
}
|
||
|
|
|
||
|
|
type TextPlacementRequest struct {
|
||
|
|
Content string
|
||
|
|
FontName string
|
||
|
|
FontPath string
|
||
|
|
SizePercent float64
|
||
|
|
ColorName string
|
||
|
|
}
|
||
|
|
|
||
|
|
type PlacedText struct {
|
||
|
|
TextPlacementRequest
|
||
|
|
Position image.Point
|
||
|
|
}
|
||
|
|
|
||
|
|
type PendingTextPlacement struct {
|
||
|
|
TextPlacementRequest
|
||
|
|
Position image.Point
|
||
|
|
Visible bool
|
||
|
|
Preview *image.RGBA
|
||
|
|
}
|
||
|
|
|
||
|
|
type TextLayer struct {
|
||
|
|
Width int
|
||
|
|
Height int
|
||
|
|
Items []PlacedText
|
||
|
|
Image *image.RGBA
|
||
|
|
Mask *PixelMask
|
||
|
|
}
|
||
|
|
|
||
|
|
var (
|
||
|
|
systemFontsOnce sync.Once
|
||
|
|
cachedFonts []SystemFont
|
||
|
|
parsedFontMu sync.Mutex
|
||
|
|
parsedFontByPath = map[string]*opentype.Font{}
|
||
|
|
)
|
||
|
|
|
||
|
|
func loadSystemFonts() []SystemFont {
|
||
|
|
systemFontsOnce.Do(func() {
|
||
|
|
seen := map[string]bool{}
|
||
|
|
for _, root := range fontSearchRoots() {
|
||
|
|
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||
|
|
if err != nil || d == nil || d.IsDir() {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
ext := strings.ToLower(filepath.Ext(path))
|
||
|
|
if ext != ".ttf" && ext != ".otf" && ext != ".ttc" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||
|
|
key := strings.ToLower(name + "|" + path)
|
||
|
|
if seen[key] {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
seen[key] = true
|
||
|
|
cachedFonts = append(cachedFonts, SystemFont{Name: name, Path: path})
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
}
|
||
|
|
sort.Slice(cachedFonts, func(i, j int) bool {
|
||
|
|
return strings.ToLower(cachedFonts[i].Name) < strings.ToLower(cachedFonts[j].Name)
|
||
|
|
})
|
||
|
|
if len(cachedFonts) == 0 {
|
||
|
|
cachedFonts = []SystemFont{{Name: "Default", Path: ""}}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
out := make([]SystemFont, len(cachedFonts))
|
||
|
|
copy(out, cachedFonts)
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func fontSearchRoots() []string {
|
||
|
|
home, _ := os.UserHomeDir()
|
||
|
|
switch runtime.GOOS {
|
||
|
|
case "windows":
|
||
|
|
winDir := os.Getenv("WINDIR")
|
||
|
|
if winDir == "" {
|
||
|
|
winDir = `C:\\Windows`
|
||
|
|
}
|
||
|
|
return []string{filepath.Join(winDir, "Fonts")}
|
||
|
|
case "darwin":
|
||
|
|
roots := []string{"/System/Library/Fonts", "/Library/Fonts"}
|
||
|
|
if home != "" {
|
||
|
|
roots = append(roots, filepath.Join(home, "Library", "Fonts"))
|
||
|
|
}
|
||
|
|
return roots
|
||
|
|
default:
|
||
|
|
roots := []string{"/usr/share/fonts", "/usr/local/share/fonts"}
|
||
|
|
if home != "" {
|
||
|
|
roots = append(roots, filepath.Join(home, ".fonts"), filepath.Join(home, ".local", "share", "fonts"))
|
||
|
|
}
|
||
|
|
return roots
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func newTextLayer(width, height int) *TextLayer {
|
||
|
|
return &TextLayer{
|
||
|
|
Width: width,
|
||
|
|
Height: height,
|
||
|
|
Image: image.NewRGBA(image.Rect(0, 0, width, height)),
|
||
|
|
Mask: NewPixelMask(width, height),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (l *TextLayer) Clear(width, height int) {
|
||
|
|
l.Width = width
|
||
|
|
l.Height = height
|
||
|
|
l.Items = nil
|
||
|
|
l.Image = image.NewRGBA(image.Rect(0, 0, width, height))
|
||
|
|
l.Mask = NewPixelMask(width, height)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (l *TextLayer) Add(item PlacedText) error {
|
||
|
|
if l == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
l.Items = append(l.Items, item)
|
||
|
|
return l.rebuild()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (l *TextLayer) rebuild() error {
|
||
|
|
if l == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
l.Image = image.NewRGBA(image.Rect(0, 0, l.Width, l.Height))
|
||
|
|
l.Mask = NewPixelMask(l.Width, l.Height)
|
||
|
|
for _, item := range l.Items {
|
||
|
|
if err := drawPlacedText(l.Image, l.Mask, item, 255, l.Width, l.Height); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func composeCanvasWithText(base image.Image, layer *TextLayer, pending *PendingTextPlacement) image.Image {
|
||
|
|
if base == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
bounds := base.Bounds()
|
||
|
|
out := cloneToRGBA(base, bounds.Dx(), bounds.Dy())
|
||
|
|
if layer != nil && layer.Image != nil {
|
||
|
|
draw.Draw(out, out.Bounds(), layer.Image, image.Point{}, draw.Over)
|
||
|
|
}
|
||
|
|
if pending != nil && pending.Visible && pending.Preview == nil && strings.TrimSpace(pending.Content) != "" {
|
||
|
|
_ = drawPlacedText(out, nil, PlacedText{TextPlacementRequest: pending.TextPlacementRequest, Position: pending.Position}, 128, bounds.Dx(), bounds.Dy())
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func renderTextSprite(req TextPlacementRequest, alpha uint8, width, height int) (*image.RGBA, error) {
|
||
|
|
if strings.TrimSpace(req.Content) == "" {
|
||
|
|
return image.NewRGBA(image.Rect(0, 0, 1, 1)), nil
|
||
|
|
}
|
||
|
|
face, err := loadFontFace(req.FontPath, textSizePixels(req.SizePercent, width, height))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
metrics := face.Metrics()
|
||
|
|
spriteW := max(1, font.MeasureString(face, req.Content).Ceil())
|
||
|
|
spriteH := max(1, (metrics.Ascent + metrics.Descent).Ceil())
|
||
|
|
sprite := image.NewRGBA(image.Rect(0, 0, spriteW, spriteH))
|
||
|
|
col := textColorByName(req.ColorName)
|
||
|
|
col.A = alpha
|
||
|
|
d := &font.Drawer{
|
||
|
|
Dst: sprite,
|
||
|
|
Src: image.NewUniform(col),
|
||
|
|
Face: face,
|
||
|
|
Dot: fixed.P(0, metrics.Ascent.Ceil()),
|
||
|
|
}
|
||
|
|
d.DrawString(req.Content)
|
||
|
|
return sprite, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func drawPlacedText(dst *image.RGBA, mask *PixelMask, item PlacedText, alpha uint8, width, height int) error {
|
||
|
|
if dst == nil || strings.TrimSpace(item.Content) == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
face, err := loadFontFace(item.FontPath, textSizePixels(item.SizePercent, width, height))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
temp := image.NewRGBA(dst.Bounds())
|
||
|
|
col := textColorByName(item.ColorName)
|
||
|
|
col.A = alpha
|
||
|
|
d := &font.Drawer{
|
||
|
|
Dst: temp,
|
||
|
|
Src: image.NewUniform(col),
|
||
|
|
Face: face,
|
||
|
|
Dot: fixed.P(item.Position.X, item.Position.Y+face.Metrics().Ascent.Ceil()),
|
||
|
|
}
|
||
|
|
d.DrawString(item.Content)
|
||
|
|
draw.Draw(dst, dst.Bounds(), temp, image.Point{}, draw.Over)
|
||
|
|
|
||
|
|
if mask != nil {
|
||
|
|
for y := 0; y < temp.Bounds().Dy(); y++ {
|
||
|
|
for x := 0; x < temp.Bounds().Dx(); x++ {
|
||
|
|
_, _, _, a := temp.At(x, y).RGBA()
|
||
|
|
if a != 0 {
|
||
|
|
mask.SetXY(x, y)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func textSizePixels(sizePercent float64, width, height int) float64 {
|
||
|
|
sizePercent = clamp(sizePercent, minTextSizePercent, maxTextSizePercent)
|
||
|
|
avgDim := averageImageDimension(width, height)
|
||
|
|
if avgDim < 1 {
|
||
|
|
avgDim = 1
|
||
|
|
}
|
||
|
|
sizePx := (sizePercent / 100.0) * avgDim
|
||
|
|
if sizePx < 1 {
|
||
|
|
sizePx = 1
|
||
|
|
}
|
||
|
|
return sizePx
|
||
|
|
}
|
||
|
|
|
||
|
|
func loadFontFace(path string, size float64) (font.Face, error) {
|
||
|
|
if size < 1 {
|
||
|
|
size = 1
|
||
|
|
}
|
||
|
|
if path == "" {
|
||
|
|
return basicfont.Face7x13, nil
|
||
|
|
}
|
||
|
|
parsedFontMu.Lock()
|
||
|
|
parsed := parsedFontByPath[path]
|
||
|
|
parsedFontMu.Unlock()
|
||
|
|
if parsed == nil {
|
||
|
|
data, err := os.ReadFile(path)
|
||
|
|
if err != nil {
|
||
|
|
return basicfont.Face7x13, nil
|
||
|
|
}
|
||
|
|
fontFile, err := opentype.Parse(data)
|
||
|
|
if err != nil {
|
||
|
|
return basicfont.Face7x13, nil
|
||
|
|
}
|
||
|
|
parsedFontMu.Lock()
|
||
|
|
parsedFontByPath[path] = fontFile
|
||
|
|
parsed = fontFile
|
||
|
|
parsedFontMu.Unlock()
|
||
|
|
}
|
||
|
|
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
|
||
|
|
if err != nil {
|
||
|
|
return basicfont.Face7x13, nil
|
||
|
|
}
|
||
|
|
return face, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func textColorByName(name string) color.RGBA {
|
||
|
|
switch strings.ToLower(name) {
|
||
|
|
case "white":
|
||
|
|
return color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||
|
|
case "grey", "gray":
|
||
|
|
return color.RGBA{R: 140, G: 140, B: 140, A: 255}
|
||
|
|
default:
|
||
|
|
return color.RGBA{R: 0, G: 0, B: 0, A: 255}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func mapCanvasPositionToImagePoint(pos fyne.Position, widgetSize fyne.Size, img image.Image) (image.Point, bool) {
|
||
|
|
if img == nil {
|
||
|
|
return image.Point{}, false
|
||
|
|
}
|
||
|
|
offsetX, offsetY, drawW, drawH, ok := containedImageRect(widgetSize, img)
|
||
|
|
if !ok {
|
||
|
|
return image.Point{}, false
|
||
|
|
}
|
||
|
|
if pos.X < offsetX || pos.Y < offsetY || pos.X > offsetX+drawW || pos.Y > offsetY+drawH {
|
||
|
|
return image.Point{}, false
|
||
|
|
}
|
||
|
|
relX := (pos.X - offsetX) / drawW
|
||
|
|
relY := (pos.Y - offsetY) / drawH
|
||
|
|
bounds := img.Bounds()
|
||
|
|
x := int(math.Round(float64(relX * float32(bounds.Dx()))))
|
||
|
|
y := int(math.Round(float64(relY * float32(bounds.Dy()))))
|
||
|
|
if x < 0 {
|
||
|
|
x = 0
|
||
|
|
}
|
||
|
|
if y < 0 {
|
||
|
|
y = 0
|
||
|
|
}
|
||
|
|
if x >= bounds.Dx() {
|
||
|
|
x = bounds.Dx() - 1
|
||
|
|
}
|
||
|
|
if y >= bounds.Dy() {
|
||
|
|
y = bounds.Dy() - 1
|
||
|
|
}
|
||
|
|
return image.Point{X: x, Y: y}, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func mapImagePointToCanvasRect(point image.Point, sprite image.Image, widgetSize fyne.Size, base image.Image) (fyne.Position, fyne.Size, bool) {
|
||
|
|
offsetX, offsetY, drawW, drawH, ok := containedImageRect(widgetSize, base)
|
||
|
|
if !ok || sprite == nil {
|
||
|
|
return fyne.Position{}, fyne.Size{}, false
|
||
|
|
}
|
||
|
|
baseBounds := base.Bounds()
|
||
|
|
scaleX := drawW / float32(baseBounds.Dx())
|
||
|
|
scaleY := drawH / float32(baseBounds.Dy())
|
||
|
|
pos := fyne.NewPos(offsetX+float32(point.X)*scaleX, offsetY+float32(point.Y)*scaleY)
|
||
|
|
size := fyne.NewSize(float32(sprite.Bounds().Dx())*scaleX, float32(sprite.Bounds().Dy())*scaleY)
|
||
|
|
return pos, size, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func containedImageRect(widgetSize fyne.Size, img image.Image) (float32, float32, float32, float32, bool) {
|
||
|
|
if img == nil {
|
||
|
|
return 0, 0, 0, 0, false
|
||
|
|
}
|
||
|
|
bounds := img.Bounds()
|
||
|
|
imgW := float32(bounds.Dx())
|
||
|
|
imgH := float32(bounds.Dy())
|
||
|
|
if imgW <= 0 || imgH <= 0 || widgetSize.Width <= 0 || widgetSize.Height <= 0 {
|
||
|
|
return 0, 0, 0, 0, false
|
||
|
|
}
|
||
|
|
imgAspect := imgW / imgH
|
||
|
|
widgetAspect := widgetSize.Width / widgetSize.Height
|
||
|
|
drawW := widgetSize.Width
|
||
|
|
drawH := widgetSize.Height
|
||
|
|
offsetX := float32(0)
|
||
|
|
offsetY := float32(0)
|
||
|
|
if widgetAspect > imgAspect {
|
||
|
|
drawW = widgetSize.Height * imgAspect
|
||
|
|
offsetX = (widgetSize.Width - drawW) * 0.5
|
||
|
|
} else {
|
||
|
|
drawH = widgetSize.Width / imgAspect
|
||
|
|
offsetY = (widgetSize.Height - drawH) * 0.5
|
||
|
|
}
|
||
|
|
return offsetX, offsetY, drawW, drawH, true
|
||
|
|
}
|