package application import ( "bytes" "fmt" "image" "image/color" "image/draw" "image/png" "math" "os" "strconv" "strings" "sync" xfont "golang.org/x/image/font" "golang.org/x/image/font/basicfont" "golang.org/x/image/font/opentype" "golang.org/x/image/math/fixed" ) // Annotation describes a user-drawn mark relative to the selected screenshot. // Coordinates are logical pixels in the same coordinate system as the overlay. type Annotation struct { Tool string `json:"tool"` Color string `json:"color"` Points []Point `json:"points"` Text string `json:"text,omitempty"` StrokeWidth float64 `json:"strokeWidth,omitempty"` FontSize float64 `json:"fontSize,omitempty"` } type Point struct { X float64 `json:"x"` Y float64 `json:"y"` } // ApplyAnnotations decodes a PNG, draws all annotations, and re-encodes it. func ApplyAnnotations(pngBytes []byte, annotations []Annotation, scale float64) ([]byte, error) { return ApplyAnnotationsWithScale(pngBytes, annotations, scale, scale) } // ApplyAnnotationsWithScale decodes a PNG, draws all annotations using the // actual device-pixel scale of the captured image, and re-encodes it. func ApplyAnnotationsWithScale(pngBytes []byte, annotations []Annotation, scaleX, scaleY float64) ([]byte, error) { if len(annotations) == 0 { return pngBytes, nil } if scaleX <= 0 { scaleX = 1 } if scaleY <= 0 { scaleY = scaleX } strokeScale := math.Max(scaleX, scaleY) src, err := png.Decode(bytes.NewReader(pngBytes)) if err != nil { return nil, fmt.Errorf("decode annotated png: %w", err) } bounds := src.Bounds() dst := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy())) draw.Draw(dst, dst.Bounds(), src, bounds.Min, draw.Src) for _, ann := range annotations { c, err := parseHexColor(ann.Color) if err != nil { c = color.RGBA{R: 59, G: 130, B: 246, A: 255} } strokeWidth := ann.StrokeWidth if strokeWidth <= 0 { strokeWidth = 3 } width := int(math.Max(1, math.Round(strokeWidth*strokeScale))) switch ann.Tool { case "pen": drawPolyline(dst, ann.Points, scaleX, scaleY, width, c) case "rect": drawRectOutline(dst, ann.Points, scaleX, scaleY, width, c) case "ellipse": drawEllipseOutline(dst, ann.Points, scaleX, scaleY, width, c) case "text": drawTextAnnotation(dst, ann, scaleX, scaleY, c) } } var buf bytes.Buffer if err := png.Encode(&buf, dst); err != nil { return nil, fmt.Errorf("encode annotated png: %w", err) } return buf.Bytes(), nil } func parseHexColor(hex string) (color.RGBA, error) { value := strings.TrimPrefix(strings.TrimSpace(hex), "#") if len(value) != 6 { return color.RGBA{}, fmt.Errorf("invalid color %q", hex) } n, err := strconv.ParseUint(value, 16, 32) if err != nil { return color.RGBA{}, err } return color.RGBA{ R: uint8(n >> 16), G: uint8(n >> 8), B: uint8(n), A: 255, }, nil } func drawPolyline(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) { if len(points) == 1 { drawDot(img, scalePoint(points[0], scaleX, scaleY), width, c) return } for i := 1; i < len(points); i++ { drawLine(img, scalePoint(points[i-1], scaleX, scaleY), scalePoint(points[i], scaleX, scaleY), width, c) } } func drawRectOutline(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) { if len(points) < 2 { return } a := scalePoint(points[0], scaleX, scaleY) b := scalePoint(points[len(points)-1], scaleX, scaleY) x1, x2 := ordered(a.X, b.X) y1, y2 := ordered(a.Y, b.Y) drawLine(img, Point{X: x1, Y: y1}, Point{X: x2, Y: y1}, width, c) drawLine(img, Point{X: x2, Y: y1}, Point{X: x2, Y: y2}, width, c) drawLine(img, Point{X: x2, Y: y2}, Point{X: x1, Y: y2}, width, c) drawLine(img, Point{X: x1, Y: y2}, Point{X: x1, Y: y1}, width, c) } func drawEllipseOutline(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) { if len(points) < 2 { return } a := scalePoint(points[0], scaleX, scaleY) b := scalePoint(points[len(points)-1], scaleX, scaleY) x1, x2 := ordered(a.X, b.X) y1, y2 := ordered(a.Y, b.Y) rx := (x2 - x1) / 2 ry := (y2 - y1) / 2 if rx <= 0 || ry <= 0 { return } cx := x1 + rx cy := y1 + ry steps := int(math.Max(48, math.Ceil(2*math.Pi*math.Max(rx, ry)/4))) prev := Point{ X: cx + rx, Y: cy, } for i := 1; i <= steps; i++ { theta := 2 * math.Pi * float64(i) / float64(steps) next := Point{ X: cx + rx*math.Cos(theta), Y: cy + ry*math.Sin(theta), } drawLine(img, prev, next, width, c) prev = next } } func drawLine(img *image.RGBA, a, b Point, width int, c color.RGBA) { dx := b.X - a.X dy := b.Y - a.Y steps := int(math.Max(math.Abs(dx), math.Abs(dy))) if steps == 0 { drawDot(img, a, width, c) return } for i := 0; i <= steps; i++ { t := float64(i) / float64(steps) drawDot(img, Point{X: a.X + dx*t, Y: a.Y + dy*t}, width, c) } } func drawDot(img *image.RGBA, p Point, width int, c color.RGBA) { radius := float64(width) / 2 minX := int(math.Floor(p.X - radius)) maxX := int(math.Ceil(p.X + radius)) minY := int(math.Floor(p.Y - radius)) maxY := int(math.Ceil(p.Y + radius)) bounds := img.Bounds() for y := minY; y <= maxY; y++ { for x := minX; x <= maxX; x++ { if !image.Pt(x, y).In(bounds) { continue } if math.Hypot(float64(x)-p.X, float64(y)-p.Y) <= radius { img.SetRGBA(x, y, c) } } } } func scalePoint(p Point, scaleX, scaleY float64) Point { return Point{X: p.X * scaleX, Y: p.Y * scaleY} } func ordered(a, b float64) (float64, float64) { if a < b { return a, b } return b, a } func drawTextAnnotation(img *image.RGBA, ann Annotation, scaleX, scaleY float64, c color.RGBA) { if len(ann.Points) == 0 { return } text := strings.TrimSpace(ann.Text) if text == "" { return } fontSize := ann.FontSize if fontSize <= 0 { fontSize = 20 } face := annotationFontFace(fontSize * scaleY) if face == nil { face = basicfont.Face7x13 } origin := scalePoint(ann.Points[0], scaleX, scaleY) metrics := face.Metrics() lineHeight := metrics.Height if lineHeight <= 0 { lineHeight = fixed.I(int(math.Ceil(fontSize * 1.2 * scaleY))) } d := &xfont.Drawer{ Dst: img, Src: image.NewUniform(c), Face: face, } baselineY := fixed.I(int(math.Round(origin.Y))) + metrics.Ascent x := fixed.I(int(math.Round(origin.X))) for _, line := range strings.Split(text, "\n") { line = strings.TrimRight(line, "\r") if line != "" { d.Dot = fixed.Point26_6{X: x, Y: baselineY} d.DrawString(line) } baselineY += lineHeight } } var ( annotationFontOnce sync.Once annotationFont *opentype.Font ) func annotationFontFace(size float64) xfont.Face { if size <= 0 { size = 20 } annotationFontOnce.Do(func() { annotationFont = loadAnnotationFont() }) if annotationFont == nil { return basicfont.Face7x13 } face, err := opentype.NewFace(annotationFont, &opentype.FaceOptions{ Size: size, DPI: 72, Hinting: xfont.HintingFull, }) if err != nil { return basicfont.Face7x13 } return face } func loadAnnotationFont() *opentype.Font { paths := []string{ "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", "/Library/Fonts/Arial Unicode.ttf", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Helvetica.ttc", } for _, path := range paths { data, err := os.ReadFile(path) if err != nil { continue } if collection, err := opentype.ParseCollection(data); err == nil && collection.NumFonts() > 0 { if font, err := collection.Font(0); err == nil { return font } } if font, err := opentype.Parse(data); err == nil { return font } } return nil }