feat: 初始版本 v1.0.0
- 新增 imglib 包,支持 FilePath/URL/Base64 三种图片输入方式 - 纯白占比检测、白底居中合成、等比缩放、去白边、裁切 - 二维码生成与识别、条形码生成(Code128/EAN13/Code39) - 中文文字图片、书籍信息水印、通用水印叠加 - 输出辅助:EncodeToBytes/EncodeToBase64/SaveToFile/SaveJPEG/SavePNG - 字体缓存,避免重复加载 - 完整测试覆盖(23个测试用例)
This commit is contained in:
commit
a92f8e2c10
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
239
README.md
Normal 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
22
go.mod
Normal 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
30
go.sum
Normal 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
2422
image/image.go
Normal file
File diff suppressed because it is too large
Load Diff
63
imglib/font.go
Normal file
63
imglib/font.go
Normal 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
1302
imglib/imglib.go
Normal file
File diff suppressed because it is too large
Load Diff
445
imglib/imglib_test.go
Normal file
445
imglib/imglib_test.go
Normal 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
123
imglib/loader.go
Normal 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 // 网络 URL(http/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
55
imglib/watermark_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user