feat: add line and arrow annotations

This commit is contained in:
2026-07-12 21:09:23 +08:00
parent b82108db38
commit 50551de105
4 changed files with 220 additions and 23 deletions
+32
View File
@@ -75,6 +75,10 @@ func ApplyAnnotationsWithScale(pngBytes []byte, annotations []Annotation, scaleX
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":
@@ -95,6 +99,34 @@ func ApplyAnnotationsWithScale(pngBytes []byte, annotations []Annotation, scaleX
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) {
+49
View File
@@ -229,3 +229,52 @@ func TestApplyAnnotationsPixelatesOnlyMosaicBrushStroke(t *testing.T) {
t.Fatalf("expected pixels outside brush path to remain unchanged")
}
}
func TestApplyAnnotationsDrawsStraightLine(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 60, 40))
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 := ApplyAnnotations(buf.Bytes(), []Annotation{{
Tool: "line", Color: "#ef4444", StrokeWidth: 4,
Points: []Point{{X: 5, Y: 20}, {X: 50, Y: 20}},
}}, 1)
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)
}
got := color.RGBAModel.Convert(img.At(30, 20)).(color.RGBA)
if got.R < 180 || got.G > 180 || got.B > 180 {
t.Fatalf("expected red line at center, got %#v", got)
}
}
func TestApplyAnnotationsDrawsArrowHead(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 80, 60))
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 := ApplyAnnotations(buf.Bytes(), []Annotation{{
Tool: "arrow", Color: "#3b82f6", StrokeWidth: 3,
Points: []Point{{X: 10, Y: 30}, {X: 60, Y: 30}},
}}, 1)
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)
}
shaft := color.RGBAModel.Convert(img.At(35, 30)).(color.RGBA)
head := color.RGBAModel.Convert(img.At(52, 25)).(color.RGBA)
if shaft.B < 180 || shaft.R > 140 || head.B < 180 || head.R > 140 {
t.Fatalf("expected blue arrow shaft and head, got shaft=%#v head=%#v", shaft, head)
}
}