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 }