feat: 初始版本 v1.0.0

- 新增 imglib 包,支持 FilePath/URL/Base64 三种图片输入方式
- 纯白占比检测、白底居中合成、等比缩放、去白边、裁切
- 二维码生成与识别、条形码生成(Code128/EAN13/Code39)
- 中文文字图片、书籍信息水印、通用水印叠加
- 输出辅助:EncodeToBytes/EncodeToBase64/SaveToFile/SaveJPEG/SavePNG
- 字体缓存,避免重复加载
- 完整测试覆盖(23个测试用例)
This commit is contained in:
Cai1Cai1 2026-06-30 11:56:28 +08:00
commit a92f8e2c10
10 changed files with 4722 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# 编译输出
*.exe
*.out
*.dll
*.so
*.dylib
# 测试临时文件
*.test
*.test.exe
# IDE
.vs/
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

239
README.md Normal file
View File

@ -0,0 +1,239 @@
# daShangDao_utils
**版本: v1.0.0** | 图片处理工具库
提供统一的图片加载接口(支持文件路径/网络 URL/Base64 三种输入方式),包含纯白检测、缩放、裁切、去白边、二维码/条形码生成识别、中文水印等完整图片处理能力。
---
## 安装
```bash
go get github.com/你的用户名/daShangDao_utils/imglib
```
然后导入:
```go
import "github.com/你的用户名/daShangDao_utils/imglib"
```
---
## 核心类型
### ImageInput统一图片输入源
`ImageInput` 是本库的核心输入类型,支持 **三种图片加载方式**,任意设置一个即可:
```go
// 方式一:从磁盘文件加载
input := imglib.NewImageInputFromFile("/path/to/image.jpg")
// 方式二:从网络 URL 加载
input := imglib.NewImageInputFromURL("https://example.com/image.png")
// 方式三:从 Base64 字符串加载(自动处理 data:image/ 前缀)
input := imglib.NewImageInputFromBase64("iVBORw0KGgo...")
// 统一加载方法
img, format, err := input.Load()
```
---
## API 文档
### 纯白占比检测
```go
pct, err := imglib.CalculateWhitePercentage(input)
// pct: 0.0 ~ 1.0 之间的纯白像素占比
```
### 白底居中合成
```go
rgba, err := imglib.CreateWhiteBottomCenteredImage(input, width, height)
// 在 width×height 的白色背景上居中放置原图
```
### 等比高度缩放
```go
img, err := imglib.ResizeToHeight(input, targetHeight)
// 按目标高度等比例缩放Lanczos3 插值
```
### 自适应尺寸缩放
```go
rgba, err := imglib.ResizeToDimensions(input, dstWidth, dstHeight)
// 按目标宽高等比例缩放,自实现 Lanczos3 算法
```
### 去白边
```go
img, err := imglib.RemoveWhiteBorder(input)
// 自动检测白色边框并裁剪,默认阈值 240
img, err := imglib.RemoveWhiteBorderWithConfig(input, threshold, margin)
// threshold: 检测阈值(0-255)margin: 保留边距(像素)
```
### 图片裁切
```go
rgba, err := imglib.Crop(input, x, y, width, height)
// 按指定矩形区域裁切
```
### 二维码识别
```go
text, err := imglib.ScanQRCode(input)
// 返回二维码中的文本内容
text, qrImg, err := imglib.ScanQRCodeWithBounds(input)
// 返回文本内容 + 裁剪后的二维码区域图片
```
### 二维码生成
```go
img, err := imglib.GenerateQRCode(content, width, height)
// content: 二维码内容, width/height: 图片尺寸
```
### 条形码生成
```go
// 支持三种编码类型:
img, err := imglib.GenerateBarcode(imglib.Code128, "1234567890")
img, err := imglib.GenerateBarcode(imglib.EAN13, "1234567890123")
img, err := imglib.GenerateBarcode(imglib.Code39, "ABC-123")
```
### 中文文字图片
```go
img, err := imglib.CreateChineseTextImage(text, width, height, fontSize)
// 将中英文文本渲染为图片,超长自动换行并显示省略号
```
### 书籍信息水印
```go
// 先在现有 RGBA 图片上绘制书名/作者/出版社
err := imglib.DrawChineseInfo(rgba, "书名", "作者", "出版社")
```
### 通用水印
```go
result, err := imglib.ApplyWatermark(imglib.WatermarkConfig{
SourceImage: imglib.NewImageInputFromFile("source.jpg"),
WatermarkImg: imglib.NewImageInputFromBase64(b64data),
Opacity: 0.5, // 不透明度
Position: "bottom-right", // center/top-left/top-right/bottom-left/bottom-right/tile
Scale: 0.3, // 水印缩放比例
XOffset: 10,
YOffset: 10,
})
```
**`Position` 可选值**:
| 值 | 说明 |
|------|------|
| `center` | 居中 |
| `top-left` | 左上角 |
| `top-right` | 右上角 |
| `bottom-left` | 左下角 |
| `bottom-right` | 右下角 |
| `tile` | 平铺 |
### 输出辅助
```go
// 编码为字节数组
bytes, err := imglib.EncodeToBytes(img, "jpeg", 95)
// 编码为 Base64 字符串(带 data:image/ 前缀)
b64, err := imglib.EncodeToBase64(img, "png", 95)
// 保存到文件(自动根据扩展名选择格式)
err := imglib.SaveToFile(img, "output.png")
// 保存为 JPEG
err := imglib.SaveJPEG(img, "out.jpg", 95)
// 保存为 PNG
err := imglib.SavePNG(img, "out.png")
```
---
## 完整示例
```go
package main
import (
"fmt"
"github.com/你的用户名/daShangDao_utils/imglib"
)
func main() {
// 1. 从 URL 裁切图片
cropped, err := imglib.Crop(
imglib.NewImageInputFromURL("https://example.com/photo.jpg"),
100, 50, 400, 300,
)
if err != nil {
panic(err)
}
imglib.SaveJPEG(cropped, "cropped.jpg", 95)
// 2. 从 Base64 识别二维码
text, err := imglib.ScanQRCode(
imglib.NewImageInputFromBase64("iVBORw0KGgoAAAANSUhEUg..."),
)
fmt.Println("二维码内容:", text)
// 3. 从本地文件去白边
cleaned, err := imglib.RemoveWhiteBorder(
imglib.NewImageInputFromFile("scan.png"),
)
imglib.SavePNG(cleaned, "cleaned.png")
// 4. 从 URL 添加 Base64 水印
result, err := imglib.ApplyWatermark(imglib.WatermarkConfig{
SourceImage: imglib.NewImageInputFromURL("https://example.com/book.jpg"),
WatermarkImg: imglib.NewImageInputFromBase64("iVBORw0KGgo..."),
Opacity: 0.8,
Position: "center",
})
b64, _ := imglib.EncodeToBase64(result, "jpeg", 95)
fmt.Println("水印结果:", b64[:50]+"...")
// 5. 生成二维码并保存
qr, _ := imglib.GenerateQRCode("https://example.com", 300, 300)
imglib.SavePNG(qr, "qrcode.png")
}
```
---
## 版本历史
| 版本 | 日期 | 说明 |
|------|------|------|
| v1.0.0 | 2026-06-30 | 初始版本,支持三种图片输入方式,完整图片处理功能 |
---
## 许可证
MIT License

22
go.mod Normal file
View File

@ -0,0 +1,22 @@
module daShangDao_utils
go 1.25.0
require (
github.com/boombuler/barcode v1.1.0
github.com/disintegration/imaging v1.6.2
github.com/fogleman/gg v1.3.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/makiuchi-d/gozxing v0.1.1
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/valyala/fasthttp v1.72.0
golang.org/x/image v0.43.0
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)

30
go.sum Normal file
View File

@ -0,0 +1,30 @@
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

2422
image/image.go Normal file

File diff suppressed because it is too large Load Diff

63
imglib/font.go Normal file
View File

@ -0,0 +1,63 @@
package imglib
import (
"fmt"
"os"
"runtime"
"sync"
"github.com/golang/freetype/truetype"
)
var (
fontCacheOnce sync.Once
cachedFont *truetype.Font
cachedFontErr error
)
// GetFont 获取系统默认中文字体(全局缓存,仅首次加载时读取磁盘)
func GetFont() (*truetype.Font, error) {
fontCacheOnce.Do(func() {
path := getDefaultFontPath()
if path == "" {
cachedFontErr = fmt.Errorf("未找到系统字体文件")
return
}
data, err := os.ReadFile(path)
if err != nil {
cachedFontErr = fmt.Errorf("读取字体文件失败: %v", err)
return
}
cachedFont, cachedFontErr = truetype.Parse(data)
})
return cachedFont, cachedFontErr
}
func getDefaultFontPath() string {
switch runtime.GOOS {
case "windows":
paths := []string{
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsun.ttc",
"C:/Windows/Fonts/msyh.ttc",
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p
}
}
case "darwin":
return "/System/Library/Fonts/PingFang.ttc"
case "linux":
paths := []string{
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p
}
}
}
return ""
}

1302
imglib/imglib.go Normal file

File diff suppressed because it is too large Load Diff

445
imglib/imglib_test.go Normal file
View File

@ -0,0 +1,445 @@
package imglib
import (
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
)
// createTestPNG 创建一个纯色测试 PNG 图片,返回文件路径
func createTestPNG(t *testing.T, width, height int, fillColor color.RGBA) string {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, fillColor)
}
}
dir := t.TempDir()
path := filepath.Join(dir, "test.png")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
return path
}
// createTestPNGWithPattern 创建一个带内容的测试图片
func createTestPNGWithPattern(t *testing.T) string {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 100, 50))
// 白色背景
for y := 0; y < 50; y++ {
for x := 0; x < 100; x++ {
img.Set(x, y, color.White)
}
}
// 黑色方块(非白色像素)
for y := 10; y < 30; y++ {
for x := 20; x < 40; x++ {
img.Set(x, y, color.Black)
}
}
dir := t.TempDir()
path := filepath.Join(dir, "pattern.png")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
return path
}
// ===================== ImageInput 测试 =====================
func TestImageInput_FromFile(t *testing.T) {
path := createTestPNG(t, 10, 10, color.RGBA{255, 0, 0, 255})
input := NewImageInputFromFile(path)
img, format, err := input.Load()
if err != nil {
t.Fatalf("从文件加载图片失败: %v", err)
}
if img == nil {
t.Fatal("返回的图片为 nil")
}
if format != "png" {
t.Errorf("期望格式 png得到 %s", format)
}
}
func TestImageInput_FromBase64(t *testing.T) {
path := createTestPNG(t, 5, 5, color.RGBA{0, 255, 0, 255})
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
b64 := base64Encode(data)
input := NewImageInputFromBase64(b64)
img, format, err := input.Load()
if err != nil {
t.Fatalf("从 base64 加载图片失败: %v", err)
}
if img == nil {
t.Fatal("返回的图片为 nil")
}
if format != "png" {
t.Errorf("期望格式 png得到 %s", format)
}
}
func TestImageInput_FromBase64_WithPrefix(t *testing.T) {
path := createTestPNG(t, 5, 5, color.RGBA{0, 0, 255, 255})
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
b64WithPrefix := "data:image/png;base64," + base64Encode(data)
input := NewImageInputFromBase64(b64WithPrefix)
img, format, err := input.Load()
if err != nil {
t.Fatalf("从带前缀的 base64 加载图片失败: %v", err)
}
if img == nil {
t.Fatal("返回的图片为 nil")
}
_ = format
}
func TestImageInput_Empty(t *testing.T) {
input := ImageInput{}
_, _, err := input.Load()
if err == nil {
t.Fatal("空的 ImageInput 应该返回错误")
}
}
// ===================== 纯白占比检测 =====================
func TestCalculateWhitePercentage_FullWhite(t *testing.T) {
path := createTestPNG(t, 50, 50, color.RGBA{255, 255, 255, 255})
pct, err := CalculateWhitePercentage(NewImageInputFromFile(path))
if err != nil {
t.Fatalf("CalculateWhitePercentage 失败: %v", err)
}
if pct != 1.0 {
t.Errorf("全白图片期望占比 1.0,得到 %f", pct)
}
}
func TestCalculateWhitePercentage_PartialWhite(t *testing.T) {
path := createTestPNGWithPattern(t)
pct, err := CalculateWhitePercentage(NewImageInputFromFile(path))
if err != nil {
t.Fatalf("CalculateWhitePercentage 失败: %v", err)
}
if pct >= 1.0 || pct <= 0 {
t.Errorf("部分白色图片期望占比在 0~1 之间,得到 %f", pct)
}
}
func TestCalculateWhitePercentage_AllBlack(t *testing.T) {
path := createTestPNG(t, 20, 20, color.RGBA{0, 0, 0, 255})
pct, err := CalculateWhitePercentage(NewImageInputFromFile(path))
if err != nil {
t.Fatalf("CalculateWhitePercentage 失败: %v", err)
}
if pct != 0.0 {
t.Errorf("全黑图片期望占比 0.0,得到 %f", pct)
}
}
// ===================== 白底居中合成 =====================
func TestCreateWhiteBottomCenteredImage(t *testing.T) {
path := createTestPNG(t, 20, 20, color.RGBA{255, 0, 0, 255})
result, err := CreateWhiteBottomCenteredImage(NewImageInputFromFile(path), 100, 100)
if err != nil {
t.Fatalf("CreateWhiteBottomCenteredImage 失败: %v", err)
}
bounds := result.Bounds()
if bounds.Dx() != 100 || bounds.Dy() != 100 {
t.Errorf("期望尺寸 100x100得到 %dx%d", bounds.Dx(), bounds.Dy())
}
}
// ===================== 缩放 =====================
func TestResizeToHeight(t *testing.T) {
path := createTestPNG(t, 100, 50, color.RGBA{255, 255, 255, 255})
result, err := ResizeToHeight(NewImageInputFromFile(path), 100)
if err != nil {
t.Fatalf("ResizeToHeight 失败: %v", err)
}
bounds := result.Bounds()
if bounds.Dy() != 100 {
t.Errorf("目标高度 100得到 %d", bounds.Dy())
}
// 宽度应按比例缩放: 100 * (100/50) = 200
if bounds.Dx() != 200 {
t.Errorf("期望宽度 200等比例得到 %d", bounds.Dx())
}
}
func TestResizeToDimensions(t *testing.T) {
path := createTestPNG(t, 80, 60, color.RGBA{255, 255, 255, 255})
result, err := ResizeToDimensions(NewImageInputFromFile(path), 40, 30)
if err != nil {
t.Fatalf("ResizeToDimensions 失败: %v", err)
}
bounds := result.Bounds()
if bounds.Dx() != 40 || bounds.Dy() != 30 {
t.Errorf("期望尺寸 40x30得到 %dx%d", bounds.Dx(), bounds.Dy())
}
}
// ===================== 裁切 =====================
func TestCrop(t *testing.T) {
path := createTestPNG(t, 100, 100, color.RGBA{255, 255, 255, 255})
result, err := Crop(NewImageInputFromFile(path), 10, 10, 50, 50)
if err != nil {
t.Fatalf("Crop 失败: %v", err)
}
bounds := result.Bounds()
if bounds.Dx() != 50 || bounds.Dy() != 50 {
t.Errorf("期望裁切尺寸 50x50得到 %dx%d", bounds.Dx(), bounds.Dy())
}
}
func TestCrop_OutOfBounds(t *testing.T) {
path := createTestPNG(t, 30, 30, color.RGBA{255, 255, 255, 255})
_, err := Crop(NewImageInputFromFile(path), 100, 0, 10, 10)
if err == nil {
t.Fatal("越界裁切应该返回错误")
}
}
// ===================== 去白边 =====================
func TestRemoveWhiteBorder(t *testing.T) {
path := createTestPNGWithPattern(t)
result, err := RemoveWhiteBorder(NewImageInputFromFile(path))
if err != nil {
t.Fatalf("RemoveWhiteBorder 失败: %v", err)
}
if result == nil {
t.Fatal("返回的图片为 nil")
}
}
// ===================== 二维码生成 =====================
func TestGenerateQRCode(t *testing.T) {
qr, err := GenerateQRCode("https://example.com", 100, 100)
if err != nil {
t.Fatalf("GenerateQRCode 失败: %v", err)
}
bounds := qr.Bounds()
if bounds.Dx() != 100 || bounds.Dy() != 100 {
t.Errorf("期望尺寸 100x100得到 %dx%d", bounds.Dx(), bounds.Dy())
}
}
// ===================== 条形码生成 =====================
func TestGenerateBarcode_Code128(t *testing.T) {
img, err := GenerateBarcode(Code128, "1234567890")
if err != nil {
t.Fatalf("GenerateBarcode Code128 失败: %v", err)
}
if img == nil {
t.Fatal("返回的图片为 nil")
}
}
func TestGenerateBarcode_EAN13(t *testing.T) {
img, err := GenerateBarcode(EAN13, "5901234123457")
if err != nil {
t.Fatalf("GenerateBarcode EAN13 失败: %v", err)
}
if img == nil {
t.Fatal("返回的图片为 nil")
}
}
func TestGenerateBarcode_Code39(t *testing.T) {
img, err := GenerateBarcode(Code39, "ABC-123")
if err != nil {
t.Fatalf("GenerateBarcode Code39 失败: %v", err)
}
if img == nil {
t.Fatal("返回的图片为 nil")
}
}
func TestGenerateBarcode_InvalidType(t *testing.T) {
_, err := GenerateBarcode("invalid", "test")
if err == nil {
t.Fatal("无效的条形码类型应该返回错误")
}
}
// ===================== 中文文字图片 =====================
func TestCreateChineseTextImage(t *testing.T) {
// 这个测试在无中文字体的环境中可能跳过
img, err := CreateChineseTextImage("测试中文文本", 300, 200, 24)
if err != nil {
t.Skipf("跳过中文文字图片测试(可能无中文字体): %v", err)
}
if img == nil {
t.Fatal("返回的图片为 nil")
}
bounds := img.Bounds()
if bounds.Dx() != 300 || bounds.Dy() != 200 {
t.Errorf("期望尺寸 300x200得到 %dx%d", bounds.Dx(), bounds.Dy())
}
}
// ===================== 输出辅助 =====================
func TestEncodeToBytes_PNG(t *testing.T) {
img, err := GenerateQRCode("test", 50, 50)
if err != nil {
t.Fatal(err)
}
data, err := EncodeToBytes(img, "png", 0)
if err != nil {
t.Fatalf("EncodeToBytes PNG 失败: %v", err)
}
if len(data) == 0 {
t.Fatal("编码后的数据为空")
}
}
func TestEncodeToBytes_JPEG(t *testing.T) {
img, err := GenerateQRCode("test", 50, 50)
if err != nil {
t.Fatal(err)
}
data, err := EncodeToBytes(img, "jpeg", 85)
if err != nil {
t.Fatalf("EncodeToBytes JPEG 失败: %v", err)
}
if len(data) == 0 {
t.Fatal("编码后的数据为空")
}
}
func TestEncodeToBase64_PNG(t *testing.T) {
img, err := GenerateQRCode("test", 20, 20)
if err != nil {
t.Fatal(err)
}
b64, err := EncodeToBase64(img, "png", 0)
if err != nil {
t.Fatalf("EncodeToBase64 失败: %v", err)
}
if !strings.HasPrefix(b64, "data:image/png;base64,") {
t.Errorf("期望以 'data:image/png;base64,' 开头,得到 %s", b64[:30])
}
}
func TestSaveToFile(t *testing.T) {
img, err := GenerateQRCode("test", 20, 20)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
path := filepath.Join(dir, "output.png")
if err := SaveToFile(img, path); err != nil {
t.Fatalf("SaveToFile 失败: %v", err)
}
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("保存的文件不存在")
}
}
func TestSaveJPEG(t *testing.T) {
img, _ := GenerateQRCode("test", 20, 20)
dir := t.TempDir()
path := filepath.Join(dir, "test.jpg")
if err := SaveJPEG(img, path, 90); err != nil {
t.Fatalf("SaveJPEG 失败: %v", err)
}
}
func TestSavePNG(t *testing.T) {
img, _ := GenerateQRCode("test", 20, 20)
dir := t.TempDir()
path := filepath.Join(dir, "test.png")
if err := SavePNG(img, path); err != nil {
t.Fatalf("SavePNG 失败: %v", err)
}
}
// ===================== 综合ImageInput 三种方式 =====================
func TestImageInput_ThreeInputMethods(t *testing.T) {
// 1. 从文件加载
path := createTestPNGWithPattern(t)
pctFile, err := CalculateWhitePercentage(NewImageInputFromFile(path))
if err != nil {
t.Fatalf("文件方式加载失败: %v", err)
}
// 2. 从 Base64 加载(和文件加载的结果应一致)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
pctBase64, err := CalculateWhitePercentage(NewImageInputFromBase64(base64Encode(data)))
if err != nil {
t.Fatalf("Base64方式加载失败: %v", err)
}
if pctFile != pctBase64 {
t.Errorf("文件加载(%f) 和 Base64 加载(%f) 结果不一致", pctFile, pctBase64)
}
}
// base64Encode 辅助函数
func base64Encode(data []byte) string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
var result strings.Builder
result.Grow((len(data) + 2) / 3 * 4)
for i := 0; i < len(data); i += 3 {
var b [3]byte
for j := 0; j < 3 && i+j < len(data); j++ {
b[j] = data[i+j]
}
n := 3
if i+3 > len(data) {
n = len(data) - i
}
val := uint(b[0])<<16 | uint(b[1])<<8 | uint(b[2])
result.WriteByte(alphabet[(val>>18)&0x3F])
result.WriteByte(alphabet[(val>>12)&0x3F])
if n >= 2 {
result.WriteByte(alphabet[(val>>6)&0x3F])
} else {
result.WriteByte('=')
}
if n >= 3 {
result.WriteByte(alphabet[val&0x3F])
} else {
result.WriteByte('=')
}
}
return result.String()
}

123
imglib/loader.go Normal file
View File

@ -0,0 +1,123 @@
package imglib
import (
"bytes"
"encoding/base64"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"os"
"strings"
"time"
"github.com/valyala/fasthttp"
)
// ImageInput 统一图片输入源,支持三种加载方式
type ImageInput struct {
FilePath string // 磁盘文件路径
URL string // 网络 URLhttp/https
Base64 string // Base64 编码的图片数据(可带 data:image/xxx;base64, 前缀)
}
// NewImageInputFromFile 从文件路径创建输入源
func NewImageInputFromFile(path string) ImageInput {
return ImageInput{FilePath: path}
}
// NewImageInputFromURL 从网络 URL 创建输入源
func NewImageInputFromURL(url string) ImageInput {
return ImageInput{URL: url}
}
// NewImageInputFromBase64 从 Base64 字符串创建输入源
func NewImageInputFromBase64(b64 string) ImageInput {
return ImageInput{Base64: b64}
}
// Load 加载图片,返回解码后的 image.Image 和格式名称
func (in ImageInput) Load() (image.Image, string, error) {
switch {
case in.Base64 != "":
return loadImageFromBase64(in.Base64)
case in.URL != "":
return loadImageFromURL(in.URL, 120)
case in.FilePath != "":
return loadImageFromFile(in.FilePath)
default:
return nil, "", fmt.Errorf("ImageInput 未设置任何输入源FilePath/URL/Base64 均为空)")
}
}
// loadImageFromFile 从磁盘文件加载图片
func loadImageFromFile(path string) (image.Image, string, error) {
file, err := os.Open(path)
if err != nil {
return nil, "", fmt.Errorf("打开图片文件失败: %v", err)
}
defer file.Close()
img, format, err := image.Decode(file)
if err != nil {
return nil, "", fmt.Errorf("解码图片失败: %v", err)
}
return img, format, nil
}
// loadImageFromURL 从网络 URL 加载图片
func loadImageFromURL(url string, timeout int) (image.Image, string, error) {
if timeout <= 0 {
timeout = 120
}
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(url)
req.Header.SetMethod("GET")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
client := &fasthttp.Client{
ReadTimeout: time.Duration(timeout) * time.Second,
WriteTimeout: time.Duration(timeout) * time.Second,
}
err := client.Do(req, resp)
if err != nil {
return nil, "", fmt.Errorf("请求失败: %v", err)
}
body := resp.Body()
imgData := make([]byte, len(body))
copy(imgData, body)
img, format, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
return nil, "", fmt.Errorf("解码图片失败: %v", err)
}
return img, format, nil
}
// loadImageFromBase64 从 Base64 字符串加载图片
func loadImageFromBase64(b64 string) (image.Image, string, error) {
b64 = strings.TrimPrefix(b64, "data:image/jpeg;base64,")
b64 = strings.TrimPrefix(b64, "data:image/jpg;base64,")
b64 = strings.TrimPrefix(b64, "data:image/png;base64,")
b64 = strings.TrimPrefix(b64, "data:image/gif;base64,")
b64 = strings.TrimPrefix(b64, "data:image/webp;base64,")
imgData, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, "", fmt.Errorf("解码 base64 失败: %v", err)
}
img, format, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
return nil, "", fmt.Errorf("解码 base64 图片失败: %v", err)
}
return img, format, nil
}

55
imglib/watermark_test.go Normal file
View File

@ -0,0 +1,55 @@
package imglib
import (
"image"
"image/color"
"testing"
"github.com/golang/freetype"
"golang.org/x/image/draw"
)
// TestAddChineseWatermark 给桌面 kbk.png 添加中文文字水印
func TestAddChineseWatermark(t *testing.T) {
srcPath := `C:\Users\Administrator\Desktop\kbk.png`
srcImg, _, err := NewImageInputFromFile(srcPath).Load()
if err != nil {
t.Fatalf("加载源图片失败: %v", err)
}
bounds := srcImg.Bounds()
dst := image.NewRGBA(bounds)
draw.Draw(dst, bounds, srcImg, bounds.Min, draw.Src)
font, err := GetFont()
if err != nil {
t.Skipf("获取字体失败: %v", err)
}
c := freetype.NewContext()
c.SetDPI(72)
c.SetFont(font)
c.SetClip(dst.Bounds())
c.SetDst(dst)
fontSize := 36.0
c.SetFontSize(fontSize)
text := "这是一个水印"
x := bounds.Dx() - 280
y := bounds.Dy() - 60
c.SetSrc(image.NewUniform(color.RGBA{0, 0, 0, 180}))
pt := freetype.Pt(x, y)
if _, err := c.DrawString(text, pt); err != nil {
t.Fatalf("绘制水印失败: %v", err)
}
outPath := "d:\\source\\daShangDao_utils\\kbk_watermarked.png"
err = SavePNG(dst, outPath)
if err != nil {
t.Fatalf("保存图片失败: %v", err)
}
t.Logf("水印图片已保存到: %s", outPath)
}