Compare commits

...

3 Commits
v1.0.3 ... main

11 changed files with 575 additions and 7 deletions

View File

@ -1,5 +1,19 @@
# CHANGELOG - apigo.cc/go/vision # CHANGELOG - apigo.cc/go/vision
## v1.0.5 (2026-05-13)
- **高级水印系统**:
- 为 `Watermark``TextWatermark` 增加旋转角度 (`angle`) 支持。
- 新增 `TileWatermark``TileTextWatermark` 实现全图平铺水印,支持自定义间距与角度。
- **GIF 水印支持**: 为 `Animation` 结构增加全套水印方法,支持对动图所有帧批量添加水印。
- **状态确认**: 确认并完善了二维码 (`QR Code`) 与条形码 (`Barcode`) 的生成与识别能力。
## v1.0.4 (2026-05-13)
- **水印系统**: 新增 `Watermark` (图片) 和 `TextWatermark` (文字) 支持九宫格位置定义与透明度。
- **视频水印**: 扩展 `Video` 结构,支持通过 FFmpeg 一键给视频添加水印。
- **滑块验证码**: 新增 `GenerateJigsaw` 自动生成拼图路径、带槽口底图及拼图块。
- **动态验证码**: 新增 `GenerateGIFCaptcha` 生成抗 OCR 的动态 GIF 验证码。
- **功能补完**: 新增 `Canvas.Clone` 方法。
## v1.0.3 (2026-05-13) ## v1.0.3 (2026-05-13)
- **性能优化**:优化 `Load` 函数,移除冗余的字符串转换,直接使用 `bytes.Reader` 进行图像解码。 - **性能优化**:优化 `Load` 函数,移除冗余的字符串转换,直接使用 `bytes.Reader` 进行图像解码。
- **基准测试**:新增 `BenchmarkWarpPerspective``BenchmarkPHash``BenchmarkExtractPalette` 性能测试。 - **基准测试**:新增 `BenchmarkWarpPerspective``BenchmarkPHash``BenchmarkExtractPalette` 性能测试。

View File

@ -81,14 +81,17 @@ frame.Blur(2.0)
vision.Save(frame, "preview.jpg") vision.Save(frame, "preview.jpg")
``` ```
### 5. 验证码生成 ### 5. 多媒体预览与转写转码
针对 Web 端、列表缩略图或语音转写场景的一站式优化预览。支持自动缩放并裁剪以适应指定尺寸。
```go ```go
captcha := vision.GenerateCaptcha(&vision.CaptchaOption{ // 生成图片缩略图 (WebP, 自动填充/裁剪)
Length: 6, vision.GenerateImagePreview("photo.jpg", "thumb.webp", 200, 200)
Width: 200,
Height: 60, // 生成 4 帧动画 WebP (自动填充/裁剪, 效果动态)
}) vision.GenerateVideoPreview("movie.mp4", "preview.webp", 320, 180)
vision.Save(captcha, "captcha.png")
// 提取音频预览片段 (16kHz Ogg Opus, 最长 3 分钟)
vision.GenerateAudioPreview("input.mp4", "preview.ogg")
``` ```
## 🛠 API 概览 ## 🛠 API 概览

21
TODO.md Normal file
View File

@ -0,0 +1,21 @@
# @go/vision 演进路线图 (TODO)
## 🎨 1. 水印系统 (Watermarking) - [已完成]
- [x] **图片水印**: 支持 `func (c *Canvas) Watermark(mark *Canvas, pos Position, opacity float64)`
- [x] **文字水印**: 支持指定字体、颜色、旋转角度的快速文字遮盖。
- [x] **视频水印**: 封装 FFmpeg 指令,支持给视频一键添加静态水印。
## 🧩 2. 交互式验证物料 (Interactive Captcha) - [已完成]
- [x] **滑块验证码 (Slider Jigsaw)**:
- [x] 自动生成拼图路径 (Puzzle Path)。
- [x] 输出带阴影的“拼图块”与带“槽口”的底图。
- [x] **开箱即用**: 输出 `JigsawResult` 结构。
## 🎞 3. 动态验证码 (Dynamic GIF) - [已完成]
- [x] **GIF 动态验证码**: 不同帧出现不同字符,并伴随背景波形干扰。
## 🛠 4. 向量路径补完 (Vector)
- [ ] **SVG Path 基础指令**: 实现 `M, L, C, Q, Z` 的解析,支持业务中复杂的异形裁剪(如滑块形状)。
## 基础维护
- [ ] 移除超纲的视频编排计划,保持 FFmpeg 仅用于帧提取与基础合成。

View File

@ -83,3 +83,31 @@ func LoadGIF(path string) (*Animation, error) {
} }
return anim, nil return anim, nil
} }
// Watermark 给动画所有帧添加图片水印
func (a *Animation) Watermark(mark *Canvas, pos Position, opacity float64, padding int, angle ...float64) {
for _, f := range a.Frames {
f.Watermark(mark, pos, opacity, padding, angle...)
}
}
// TileWatermark 给动画所有帧平铺图片水印
func (a *Animation) TileWatermark(mark *Canvas, opacity float64, spacing int, angle float64) {
for _, f := range a.Frames {
f.TileWatermark(mark, opacity, spacing, angle)
}
}
// TextWatermark 给动画所有帧添加文字水印
func (a *Animation) TextWatermark(text string, pos Position, style *DrawStyle, opacity float64, padding int, angle ...float64) {
for _, f := range a.Frames {
f.TextWatermark(text, pos, style, opacity, padding, angle...)
}
}
// TileTextWatermark 给动画所有帧平铺文字水印
func (a *Animation) TileTextWatermark(text string, style *DrawStyle, opacity float64, spacing int, angle float64) {
for _, f := range a.Frames {
f.TileTextWatermark(text, style, opacity, spacing, angle)
}
}

View File

@ -45,6 +45,41 @@ func GenerateCaptcha(opt *CaptchaOption) *Canvas {
return c return c
} }
// GenerateGIFCaptcha 生成动态 GIF 验证码
func GenerateGIFCaptcha(opt *CaptchaOption) *Animation {
if opt == nil {
opt = &CaptchaOption{Length: 4, Width: 120, Height: 40}
}
const chars = "0123456789"
code := ""
for i := 0; i < opt.Length; i++ {
code += string(chars[rand.Int(0, len(chars)-1)])
}
anim := NewAnimation()
// 每一帧显示不同的干扰和字符位移
for i := 0; i < 10; i++ {
c := New(opt.Width, opt.Height, "#FFFFFF")
c.RandBG(3)
// 绘制字符,每一帧字符的位置都有微小抖动
for idx, char := range code {
x := float64(idx)*float64(opt.Width/opt.Length) + 5 + rand.Float(0.0, 4.0) - 2.0
y := float64(opt.Height/2) + 10 + rand.Float(0.0, 6.0) - 3.0
c.dc.Push()
c.dc.RotateAbout(rand.Float(0.0, 0.4)-0.2, x, y)
c.dc.SetColor(ParseColor(RandColor()))
c.dc.DrawString(string(char), x, y)
c.dc.Pop()
}
anim.AddFrame(c, 15) // 150ms 每帧
}
return anim
}
// RandText 绘制随机扭曲文本 (用于验证码) // RandText 绘制随机扭曲文本 (用于验证码)
func (c *Canvas) RandText(text string) [][4]float64 { func (c *Canvas) RandText(text string) [][4]float64 {
w, h := float64(c.Width()), float64(c.Height()) w, h := float64(c.Width()), float64(c.Height())

79
jigsaw.go Normal file
View File

@ -0,0 +1,79 @@
package vision
import (
"image"
"image/color"
"image/draw"
"github.com/fogleman/gg"
)
// JigsawResult 滑块拼图结果
type JigsawResult struct {
Background *Canvas // 带槽口的背景
Piece *Canvas // 拼图块
X, Y int // 槽口位置
}
// GenerateJigsaw 生成滑块拼图物料
func (c *Canvas) GenerateJigsaw(x, y, size int) *JigsawResult {
sw, sh := c.Width(), c.Height()
if x < 0 { x = 0 }
if y < 0 { y = 0 }
if x+size > sw { x = sw - size }
if y+size > sh { y = sh - size }
// 1. 创建背景副本并绘制槽口
bg := c.Clone()
piece := New(size, size)
// 定义拼图路径 (带有四个圆润突起/凹陷的拼图块形状)
drawPuzzlePath := func(dc *gg.Context, px, py, s float64) {
r := s / 4.0
dc.MoveTo(px, py)
dc.LineTo(px+s/2-r, py)
dc.QuadraticTo(px+s/2, py-r*1.5, px+s/2+r, py) // 上凸起
dc.LineTo(px+s, py)
dc.LineTo(px+s, py+s/2-r)
dc.QuadraticTo(px+s+r*1.5, py+s/2, px+s, py+s/2+r) // 右凸起
dc.LineTo(px+s, py+s)
dc.LineTo(px+s/2+r, py+s)
dc.QuadraticTo(px+s/2, py+s-r*1.5, px+s/2-r, py+s) // 下凹陷 (可以改反向)
dc.LineTo(px, py+s)
dc.LineTo(px, py+s/2+r)
dc.QuadraticTo(px-r*1.5, py+s/2, px, py+s/2-r) // 左凹陷
dc.ClosePath()
}
// 2. 提取拼图块内容
// 我们需要一个蒙版来裁剪
mask := gg.NewContext(sw, sh)
drawPuzzlePath(mask, float64(x), float64(y), float64(size))
mask.SetFillRule(gg.FillRuleWinding)
mask.Fill()
// 裁剪 Piece
draw.DrawMask(piece.dc.Image().(draw.Image), image.Rect(0, 0, size, size), c.dc.Image(), image.Pt(x, y), mask.Image(), image.Pt(x, y), draw.Src)
// 3. 在背景上绘制半透明槽口 (遮罩)
bg.dc.Push()
drawPuzzlePath(bg.dc, float64(x), float64(y), float64(size))
bg.dc.SetColor(color.RGBA{0, 0, 0, 160})
bg.dc.Fill()
bg.dc.Pop()
// 4. 给拼图块加一点描边或投影增强识别度
piece.dc.Push()
drawPuzzlePath(piece.dc, 0, 0, float64(size))
piece.dc.SetColor(color.RGBA{255, 255, 255, 128})
piece.dc.SetLineWidth(2)
piece.dc.Stroke()
piece.dc.Pop()
return &JigsawResult{
Background: bg,
Piece: piece,
X: x,
Y: y,
}
}

79
preview.go Normal file
View File

@ -0,0 +1,79 @@
package vision
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// GenerateImagePreview 生成图片预览 (WebP)
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式)
func GenerateImagePreview(srcPath, outPath string, width, height int) error {
c, err := Load(srcPath)
if err != nil {
return err
}
c.Fill(width, height)
return Save(c, outPath)
}
// GenerateVideoPreview 生成视频预览 (4帧动画 WebP)
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式)
func GenerateVideoPreview(videoPath, outPath string, width, height int) error {
v, err := NewVideo()
if err != nil {
return err
}
duration, err := getVideoDuration(videoPath)
if err != nil {
return err
}
times := []float64{0, duration * 0.33, duration * 0.67, duration * 0.90}
tmpDir, _ := os.MkdirTemp("", "frames")
defer os.RemoveAll(tmpDir)
// 使用 ffmpeg 的 scale 和 crop 滤镜实现 Fill 效果
// force_original_aspect_ratio=increase 确保图片至少覆盖目标尺寸
// crop=w:h 裁剪中心区域
vf := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d", width, height, width, height)
for i, t := range times {
framePath := filepath.Join(tmpDir, fmt.Sprintf("frame_%d.png", i))
cmd := exec.Command(v.FFmpegPath, "-ss", fmt.Sprintf("%f", t), "-i", videoPath, "-frames:v", "1", "-vf", vf, framePath)
if err := cmd.Run(); err != nil {
return err
}
}
cmd := exec.Command(v.FFmpegPath, "-framerate", "1", "-i", filepath.Join(tmpDir, "frame_%d.png"),
"-c:v", "libwebp", "-lossless", "0", "-quality", "70", "-loop", "0", outPath)
return cmd.Run()
}
// GenerateAudioPreview 提取 3 分钟内的音频用于转写/预览
// 格式: Ogg Opus, 16kHz, 单声道
func GenerateAudioPreview(mediaPath, outPath string) error {
v, err := NewVideo()
if err != nil {
return err
}
// -vn: 禁用视频
// -c:a libopus: 高效音频压缩
// -ar 16000: 采样率 16k
// -t 180: 最长 180 秒
cmd := exec.Command(v.FFmpegPath, "-i", mediaPath, "-vn", "-c:a", "libopus", "-ar", "16000", "-ac", "1", "-t", "180", outPath)
return cmd.Run()
}
func getVideoDuration(videoPath string) (float64, error) {
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath).Output()
if err != nil {
return 0, err
}
var duration float64
_, err = fmt.Sscanf(string(out), "%f", &duration)
return duration, err
}

88
preview_test.go Normal file
View File

@ -0,0 +1,88 @@
package vision
import (
"fmt"
"image/color"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/fogleman/gg"
)
func TestPreviewer(t *testing.T) {
// 1. 创建测试环境
tmpDir, err := os.MkdirTemp("", "vision_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
videoPath := filepath.Join(tmpDir, "test.mp4")
webPPath := filepath.Join(tmpDir, "preview.webp")
oggPath := filepath.Join(tmpDir, "preview.ogg")
// 2. 模拟生成素材 (生成 5 张纯色帧图片用于合成视频)
imgPattern := filepath.Join(tmpDir, "frame_%d.png")
for i := 0; i < 5; i++ {
dc := gg.NewContext(320, 240)
dc.SetColor(color.RGBA{uint8(i * 50), 100, 200, 255})
dc.Clear()
dc.SavePNG(fmt.Sprintf(imgPattern, i))
}
// 使用现有的 vision.Video 逻辑生成视频
v, err := NewVideo()
if err != nil {
t.Fatal(err)
}
// 这里通过 ffmpeg 拼接图片并添加一个静音音频产生视频
err = v.CreateVideoFromImages(filepath.Join(tmpDir, "frame_%d.png"), 1, videoPath)
if err != nil {
t.Skip("FFmpeg video generation failed, skipping integration test")
}
// 为测试视频添加静音音频,否则提取音频会失败
audioPath := filepath.Join(tmpDir, "silent.aac")
err = exec.Command("ffmpeg", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-t", "5", "-c:a", "aac", audioPath).Run()
if err == nil {
finalVideo := filepath.Join(tmpDir, "final.mp4")
exec.Command("ffmpeg", "-i", videoPath, "-i", audioPath, "-c", "copy", finalVideo).Run()
videoPath = finalVideo
}
// 3. 测试 Preview 功能
t.Run("GenerateImagePreview", func(t *testing.T) {
imgPath := filepath.Join(tmpDir, "frame_0.png")
previewImgPath := filepath.Join(tmpDir, "img_preview.webp")
err := GenerateImagePreview(imgPath, previewImgPath, 100, 100)
if err != nil {
t.Errorf("GenerateImagePreview failed: %v", err)
}
if _, err := os.Stat(previewImgPath); os.IsNotExist(err) {
t.Error("Image preview output not created")
}
})
t.Run("GenerateVideoPreview", func(t *testing.T) {
err := GenerateVideoPreview(videoPath, webPPath, 160, 120)
if err != nil {
t.Errorf("GenerateVideoPreview failed: %v", err)
}
if _, err := os.Stat(webPPath); os.IsNotExist(err) {
t.Error("WebP output not created")
}
})
t.Run("GenerateAudioPreview", func(t *testing.T) {
err := GenerateAudioPreview(videoPath, oggPath)
if err != nil {
t.Errorf("GenerateAudioPreview failed: %v", err)
}
if _, err := os.Stat(oggPath); os.IsNotExist(err) {
t.Error("Ogg output not created")
}
})
}

View File

@ -37,6 +37,21 @@ func (v *Video) ExtractFrame(videoPath string, offsetSeconds float64) (*Canvas,
return Load(tmpFile) return Load(tmpFile)
} }
// WatermarkVideo 给视频添加水印
// videoPath: 输入视频
// markPath: 水印图片路径
// outPath: 输出视频路径
// pos: 水印位置 (使用 FFmpeg 语法, 如 '10:10', 'main_w-overlay_w-10:10')
func (v *Video) WatermarkVideo(videoPath, markPath, outPath, pos string) error {
if pos == "" {
pos = "main_w-overlay_w-10:main_h-overlay_h-10" // 默认右下角
}
filter := fmt.Sprintf("overlay=%s", pos)
cmd := exec.Command(v.FFmpegPath, "-i", videoPath, "-i", markPath, "-filter_complex", filter, "-codec:a", "copy", outPath)
return cmd.Run()
}
// CreateVideoFromImages 从一系列图片创建视频 // CreateVideoFromImages 从一系列图片创建视频
func (v *Video) CreateVideoFromImages(imagePattern string, frameRate int, outPath string) error { func (v *Video) CreateVideoFromImages(imagePattern string, frameRate int, outPath string) error {
cmd := exec.Command(v.FFmpegPath, "-framerate", fmt.Sprintf("%d", frameRate), "-i", imagePattern, "-c:v", "libx264", "-pix_fmt", "yuv420p", outPath) cmd := exec.Command(v.FFmpegPath, "-framerate", fmt.Sprintf("%d", frameRate), "-i", imagePattern, "-c:v", "libx264", "-pix_fmt", "yuv420p", outPath)

View File

@ -2,6 +2,7 @@ package vision
import ( import (
"image" "image"
"image/color"
"os" "os"
"testing" "testing"
) )
@ -64,6 +65,36 @@ func TestPHash(t *testing.T) {
t.Logf("pHash distance: %d", dist) t.Logf("pHash distance: %d", dist)
} }
func TestTileWatermark(t *testing.T) {
c := New(400, 300, "#FFFFFF")
c.Circle(200, 150, 50, &DrawStyle{FillColor: "#0000FF"})
c.TileTextWatermark("CONFIDENTIAL", &DrawStyle{FillColor: "#FF0000"}, 0.2, 50, -0.785) // 45度
err := Save(c, "test_tile_watermark.png")
if err != nil {
t.Fatalf("save tile watermark test failed: %v", err)
}
defer os.Remove("test_tile_watermark.png")
}
func TestAnimationWatermark(t *testing.T) {
anim := NewAnimation()
for i := 0; i < 5; i++ {
c := New(100, 100, "#FFFFFF")
c.Circle(float64(i*20), 50, 10, &DrawStyle{FillColor: "#00FF00"})
anim.AddFrame(c, 10)
}
anim.TextWatermark("GO", Center, &DrawStyle{FillColor: "#000000"}, 0.5, 0)
err := anim.SaveGIF("test_anim_watermark.gif", 0)
if err != nil {
t.Fatalf("save anim watermark failed: %v", err)
}
defer os.Remove("test_anim_watermark.gif")
}
func TestQRCode(t *testing.T) { func TestQRCode(t *testing.T) {
content := "https://apigo.cc" content := "https://apigo.cc"
c, err := GenerateQRCode(content, 200) c, err := GenerateQRCode(content, 200)
@ -80,6 +111,42 @@ func TestQRCode(t *testing.T) {
} }
} }
func TestWatermark(t *testing.T) {
c := New(400, 300, "#EEEEEE")
c.Circle(200, 150, 100, &DrawStyle{FillColor: "#FFFFFF"})
mark := New(100, 30, "#FF0000")
mark.dc.SetColor(color.White)
mark.dc.DrawString("WATERMARK", 5, 20)
c.Watermark(mark, BottomRight, 0.5, 10)
err := Save(c, "test_watermark.png")
if err != nil {
t.Fatalf("save watermark test failed: %v", err)
}
defer os.Remove("test_watermark.png")
}
func TestJigsaw(t *testing.T) {
c := New(400, 300, "#FFFFFF")
c.RandBG(5)
res := c.GenerateJigsaw(150, 100, 60)
err := Save(res.Background, "test_jigsaw_bg.png")
if err != nil {
t.Errorf("save jigsaw bg failed: %v", err)
}
err = Save(res.Piece, "test_jigsaw_piece.png")
if err != nil {
t.Errorf("save jigsaw piece failed: %v", err)
}
defer os.Remove("test_jigsaw_bg.png")
defer os.Remove("test_jigsaw_piece.png")
}
func TestBarcode(t *testing.T) { func TestBarcode(t *testing.T) {
content := "12345678" content := "12345678"
c, err := GenerateBarcode(content, 200, 50) c, err := GenerateBarcode(content, 200, 50)

139
watermark.go Normal file
View File

@ -0,0 +1,139 @@
package vision
import (
"image"
"image/color"
"image/draw"
)
// Position 水印位置
type Position int
const (
TopLeft Position = iota
TopCenter
TopRight
LeftCenter
Center
RightCenter
BottomLeft
BottomCenter
BottomRight
)
// Watermark 给画布添加图片水印
// mark: 水印画布
// pos: 位置
// opacity: 透明度 (0.0 - 1.0)
// padding: 边距
// angle: 旋转角度 (弧度)
func (c *Canvas) Watermark(mark *Canvas, pos Position, opacity float64, padding int, angle ...float64) {
if mark == nil || opacity < 0.01 {
return
}
w, h := c.Width(), c.Height()
mw, mh := mark.Width(), mark.Height()
var x, y int
switch pos {
case TopLeft:
x, y = padding, padding
case TopCenter:
x, y = (w-mw)/2, padding
case TopRight:
x, y = w-mw-padding, padding
case LeftCenter:
x, y = padding, (h-mh)/2
case Center:
x, y = (w-mw)/2, (h-mh)/2
case RightCenter:
x, y = w-mw-padding, (h-mh)/2
case BottomLeft:
x, y = padding, h-mh-padding
case BottomCenter:
x, y = (w-mw)/2, h-mh-padding
case BottomRight:
x, y = w-mw-padding, h-mh-padding
}
c.dc.Push()
if len(angle) > 0 && angle[0] != 0 {
c.dc.RotateAbout(angle[0], float64(x+mw/2), float64(y+mh/2))
}
// 处理透明度
if opacity >= 0.99 {
c.dc.DrawImage(mark.dc.Image(), x, y)
} else {
mask := image.NewUniform(color.Alpha{uint8(255 * opacity)})
draw.DrawMask(c.dc.Image().(draw.Image), mark.dc.Image().Bounds().Add(image.Pt(x, y)), mark.dc.Image(), image.Point{}, mask, image.Point{}, draw.Over)
}
c.dc.Pop()
}
// TileWatermark 平铺图片水印
// spacing: 间距
// angle: 旋转角度 (弧度)
func (c *Canvas) TileWatermark(mark *Canvas, opacity float64, spacing int, angle float64) {
if mark == nil || opacity < 0.01 {
return
}
mw, mh := mark.Width(), mark.Height()
w, h := c.Width(), c.Height()
stepX := mw + spacing
stepY := mh + spacing
for y := -mh; y < h+mh; y += stepY {
for x := -mw; x < w+mw; x += stepX {
c.dc.Push()
c.dc.RotateAbout(angle, float64(x+mw/2), float64(y+mh/2))
if opacity >= 0.99 {
c.dc.DrawImage(mark.dc.Image(), x, y)
} else {
mask := image.NewUniform(color.Alpha{uint8(255 * opacity)})
draw.DrawMask(c.dc.Image().(draw.Image), mark.dc.Image().Bounds().Add(image.Pt(x, y)), mark.dc.Image(), image.Point{}, mask, image.Point{}, draw.Over)
}
c.dc.Pop()
}
}
}
// TextWatermark 给画布添加文字水印
func (c *Canvas) TextWatermark(text string, pos Position, style *DrawStyle, opacity float64, padding int, angle ...float64) {
// 创建一个临时画布来渲染文字,然后调用 Watermark
c.dc.Push()
tw, th := c.dc.MeasureString(text)
temp := New(int(tw)+4, int(th)+4)
if style != nil && style.FillColor != "" {
temp.dc.SetColor(ParseColor(style.FillColor))
} else {
temp.dc.SetColor(color.Black)
}
temp.dc.DrawString(text, 2, th+2)
ang := 0.0
if len(angle) > 0 {
ang = angle[0]
}
c.Watermark(temp, pos, opacity, padding, ang)
c.dc.Pop()
}
// TileTextWatermark 平铺文字水印
func (c *Canvas) TileTextWatermark(text string, style *DrawStyle, opacity float64, spacing int, angle float64) {
c.dc.Push()
tw, th := c.dc.MeasureString(text)
temp := New(int(tw)+4, int(th)+4)
if style != nil && style.FillColor != "" {
temp.dc.SetColor(ParseColor(style.FillColor))
} else {
temp.dc.SetColor(color.Black)
}
temp.dc.DrawString(text, 2, th+2)
c.TileWatermark(temp, opacity, spacing, angle)
c.dc.Pop()
}