fix: align text annotations in captures

This commit is contained in:
2026-07-08 14:46:57 +08:00
parent f90612976f
commit 7b290d9300
7 changed files with 533 additions and 130 deletions
+41 -26
View File
@@ -22,10 +22,11 @@ import (
// 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"`
Tool string `json:"tool"`
Color string `json:"color"`
Points []Point `json:"points"`
Text string `json:"text,omitempty"`
FontSize float64 `json:"fontSize,omitempty"`
}
type Point struct {
@@ -35,12 +36,22 @@ type Point struct {
// 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 scale <= 0 {
scale = 1
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 {
@@ -55,16 +66,16 @@ func ApplyAnnotations(pngBytes []byte, annotations []Annotation, scale float64)
if err != nil {
c = color.RGBA{R: 59, G: 130, B: 246, A: 255}
}
width := int(math.Max(2, math.Round(3*scale)))
width := int(math.Max(2, math.Round(3*strokeScale)))
switch ann.Tool {
case "pen":
drawPolyline(dst, ann.Points, scale, width, c)
drawPolyline(dst, ann.Points, scaleX, scaleY, width, c)
case "rect":
drawRectOutline(dst, ann.Points, scale, width, c)
drawRectOutline(dst, ann.Points, scaleX, scaleY, width, c)
case "ellipse":
drawEllipseOutline(dst, ann.Points, scale, width, c)
drawEllipseOutline(dst, ann.Points, scaleX, scaleY, width, c)
case "text":
drawTextAnnotation(dst, ann, scale, c)
drawTextAnnotation(dst, ann, scaleX, scaleY, c)
}
}
@@ -92,22 +103,22 @@ func parseHexColor(hex string) (color.RGBA, error) {
}, nil
}
func drawPolyline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) {
func drawPolyline(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) {
if len(points) == 1 {
drawDot(img, scalePoint(points[0], scale), width, c)
drawDot(img, scalePoint(points[0], scaleX, scaleY), width, c)
return
}
for i := 1; i < len(points); i++ {
drawLine(img, scalePoint(points[i-1], scale), scalePoint(points[i], scale), width, c)
drawLine(img, scalePoint(points[i-1], scaleX, scaleY), scalePoint(points[i], scaleX, scaleY), width, c)
}
}
func drawRectOutline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) {
func drawRectOutline(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) {
if len(points) < 2 {
return
}
a := scalePoint(points[0], scale)
b := scalePoint(points[len(points)-1], scale)
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)
@@ -116,12 +127,12 @@ func drawRectOutline(img *image.RGBA, points []Point, scale float64, width int,
drawLine(img, Point{X: x1, Y: y2}, Point{X: x1, Y: y1}, width, c)
}
func drawEllipseOutline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) {
func drawEllipseOutline(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) {
if len(points) < 2 {
return
}
a := scalePoint(points[0], scale)
b := scalePoint(points[len(points)-1], scale)
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
@@ -180,8 +191,8 @@ func drawDot(img *image.RGBA, p Point, width int, c color.RGBA) {
}
}
func scalePoint(p Point, scale float64) Point {
return Point{X: p.X * scale, Y: p.Y * scale}
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) {
@@ -191,7 +202,7 @@ func ordered(a, b float64) (float64, float64) {
return b, a
}
func drawTextAnnotation(img *image.RGBA, ann Annotation, scale float64, c color.RGBA) {
func drawTextAnnotation(img *image.RGBA, ann Annotation, scaleX, scaleY float64, c color.RGBA) {
if len(ann.Points) == 0 {
return
}
@@ -199,16 +210,20 @@ func drawTextAnnotation(img *image.RGBA, ann Annotation, scale float64, c color.
if text == "" {
return
}
face := annotationFontFace(20 * scale)
fontSize := ann.FontSize
if fontSize <= 0 {
fontSize = 20
}
face := annotationFontFace(fontSize * scaleY)
if face == nil {
face = basicfont.Face7x13
}
origin := scalePoint(ann.Points[0], scale)
origin := scalePoint(ann.Points[0], scaleX, scaleY)
metrics := face.Metrics()
lineHeight := metrics.Height
if lineHeight <= 0 {
lineHeight = fixed.I(int(math.Ceil(24 * scale)))
lineHeight = fixed.I(int(math.Ceil(fontSize * 1.2 * scaleY)))
}
d := &xfont.Drawer{
Dst: img,
+47
View File
@@ -93,3 +93,50 @@ func TestApplyAnnotationsDrawsTextIntoPNG(t *testing.T) {
t.Fatalf("expected red text pixels near annotation point")
}
}
func TestApplyAnnotationsWithScaleDrawsTextAtDevicePosition(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 220, 120))
draw.Draw(src, src.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, src); err != nil {
t.Fatalf("encode source: %v", err)
}
out, err := ApplyAnnotationsWithScale(buf.Bytes(), []Annotation{
{
Tool: "text",
Color: "#ef4444",
Points: []Point{{X: 50, Y: 20}},
Text: "T",
FontSize: 20,
},
}, 2, 2)
if err != nil {
t.Fatalf("apply annotations: %v", err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode output: %v", err)
}
minX := 999
found := false
for y := 0; y < img.Bounds().Dy(); y++ {
for x := 0; x < img.Bounds().Dx(); x++ {
got := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA)
if got.R > 180 && got.G < 180 && got.B < 180 {
found = true
if x < minX {
minX = x
}
}
}
}
if !found {
t.Fatalf("expected red text pixels")
}
if minX < 90 {
t.Fatalf("expected text to be drawn near scaled x=100, min red x=%d", minX)
}
}