423 lines
12 KiB
Go
423 lines
12 KiB
Go
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 "line":
|
|
drawStraightLine(dst, ann.Points, scaleX, scaleY, width, c)
|
|
case "arrow":
|
|
drawArrow(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)
|
|
case "mosaic-brush":
|
|
applyMosaicBrush(dst, ann.Points, scaleX, scaleY, width)
|
|
case "mosaic-rect":
|
|
applyMosaicRect(dst, ann.Points, scaleX, scaleY)
|
|
}
|
|
}
|
|
|
|
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 drawStraightLine(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) {
|
|
if len(points) < 2 {
|
|
return
|
|
}
|
|
drawLine(img, scalePoint(points[0], scaleX, scaleY), scalePoint(points[len(points)-1], scaleX, scaleY), width, c)
|
|
}
|
|
|
|
func drawArrow(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) {
|
|
if len(points) < 2 {
|
|
return
|
|
}
|
|
start := scalePoint(points[0], scaleX, scaleY)
|
|
end := scalePoint(points[len(points)-1], scaleX, scaleY)
|
|
dx, dy := end.X-start.X, end.Y-start.Y
|
|
length := math.Hypot(dx, dy)
|
|
if length < 1 {
|
|
return
|
|
}
|
|
drawLine(img, start, end, width, c)
|
|
headLength := math.Min(length*0.45, math.Max(10*math.Max(scaleX, scaleY), float64(width)*4))
|
|
angle := math.Atan2(dy, dx)
|
|
spread := math.Pi / 6
|
|
left := Point{X: end.X - headLength*math.Cos(angle-spread), Y: end.Y - headLength*math.Sin(angle-spread)}
|
|
right := Point{X: end.X - headLength*math.Cos(angle+spread), Y: end.Y - headLength*math.Sin(angle+spread)}
|
|
drawLine(img, end, left, width, c)
|
|
drawLine(img, end, right, width, c)
|
|
}
|
|
|
|
const mosaicBlockSize = 10
|
|
|
|
func applyMosaicBrush(img *image.RGBA, points []Point, scaleX, scaleY float64, width int) {
|
|
if len(points) == 0 {
|
|
return
|
|
}
|
|
mask := image.NewAlpha(img.Bounds())
|
|
maskColor := color.RGBA{A: 255}
|
|
if len(points) == 1 {
|
|
drawDotMask(mask, scalePoint(points[0], scaleX, scaleY), width, maskColor)
|
|
} else {
|
|
for i := 1; i < len(points); i++ {
|
|
drawMaskLine(mask, scalePoint(points[i-1], scaleX, scaleY), scalePoint(points[i], scaleX, scaleY), width, maskColor)
|
|
}
|
|
}
|
|
applyPixelation(img, img.Bounds(), mask)
|
|
}
|
|
|
|
func applyMosaicRect(img *image.RGBA, points []Point, scaleX, scaleY float64) {
|
|
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)
|
|
r := image.Rect(int(math.Floor(x1)), int(math.Floor(y1)), int(math.Ceil(x2)), int(math.Ceil(y2))).Intersect(img.Bounds())
|
|
applyPixelation(img, r, nil)
|
|
}
|
|
|
|
func applyPixelation(img *image.RGBA, region image.Rectangle, mask *image.Alpha) {
|
|
if region.Empty() {
|
|
return
|
|
}
|
|
for y := region.Min.Y; y < region.Max.Y; y += mosaicBlockSize {
|
|
for x := region.Min.X; x < region.Max.X; x += mosaicBlockSize {
|
|
block := image.Rect(x, y, min(x+mosaicBlockSize, region.Max.X), min(y+mosaicBlockSize, region.Max.Y))
|
|
var r, g, b, a, count uint64
|
|
for py := block.Min.Y; py < block.Max.Y; py++ {
|
|
for px := block.Min.X; px < block.Max.X; px++ {
|
|
c := img.RGBAAt(px, py)
|
|
r += uint64(c.R)
|
|
g += uint64(c.G)
|
|
b += uint64(c.B)
|
|
a += uint64(c.A)
|
|
count++
|
|
}
|
|
}
|
|
if count == 0 {
|
|
continue
|
|
}
|
|
avg := color.RGBA{uint8(r / count), uint8(g / count), uint8(b / count), uint8(a / count)}
|
|
for py := block.Min.Y; py < block.Max.Y; py++ {
|
|
for px := block.Min.X; px < block.Max.X; px++ {
|
|
if mask == nil || mask.AlphaAt(px, py).A > 0 {
|
|
img.SetRGBA(px, py, avg)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func drawMaskLine(mask *image.Alpha, a, b Point, width int, c color.RGBA) {
|
|
dx, dy := b.X-a.X, b.Y-a.Y
|
|
steps := int(math.Max(math.Abs(dx), math.Abs(dy)))
|
|
if steps == 0 {
|
|
drawDotMask(mask, a, width, c)
|
|
return
|
|
}
|
|
for i := 0; i <= steps; i++ {
|
|
t := float64(i) / float64(steps)
|
|
drawDotMask(mask, Point{X: a.X + dx*t, Y: a.Y + dy*t}, width, c)
|
|
}
|
|
}
|
|
|
|
func drawDotMask(mask *image.Alpha, p Point, width int, c color.RGBA) {
|
|
radius := float64(width) / 2
|
|
for y := int(math.Floor(p.Y - radius)); y <= int(math.Ceil(p.Y+radius)); y++ {
|
|
for x := int(math.Floor(p.X - radius)); x <= int(math.Ceil(p.X+radius)); x++ {
|
|
if image.Pt(x, y).In(mask.Bounds()) && math.Hypot(float64(x)-p.X, float64(y)-p.Y) <= radius {
|
|
mask.SetAlpha(x, y, color.Alpha{A: c.A})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|