From a92f8e2c104da286433475e4ff9cfe04f13226d5 Mon Sep 17 00:00:00 2001 From: Cai1Cai1 Date: Tue, 30 Jun 2026 11:56:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E7=89=88=E6=9C=AC=20?= =?UTF-8?q?v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 imglib 包,支持 FilePath/URL/Base64 三种图片输入方式 - 纯白占比检测、白底居中合成、等比缩放、去白边、裁切 - 二维码生成与识别、条形码生成(Code128/EAN13/Code39) - 中文文字图片、书籍信息水印、通用水印叠加 - 输出辅助:EncodeToBytes/EncodeToBase64/SaveToFile/SaveJPEG/SavePNG - 字体缓存,避免重复加载 - 完整测试覆盖(23个测试用例) --- .gitignore | 21 + README.md | 239 ++++ go.mod | 22 + go.sum | 30 + image/image.go | 2422 ++++++++++++++++++++++++++++++++++++++ imglib/font.go | 63 + imglib/imglib.go | 1302 ++++++++++++++++++++ imglib/imglib_test.go | 445 +++++++ imglib/loader.go | 123 ++ imglib/watermark_test.go | 55 + 10 files changed, 4722 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 image/image.go create mode 100644 imglib/font.go create mode 100644 imglib/imglib.go create mode 100644 imglib/imglib_test.go create mode 100644 imglib/loader.go create mode 100644 imglib/watermark_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..886fdf4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# 编译输出 +*.exe +*.out +*.dll +*.so +*.dylib + +# 测试临时文件 +*.test +*.test.exe + +# IDE +.vs/ +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d2e8ab --- /dev/null +++ b/README.md @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ffe9972 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0db6da0 --- /dev/null +++ b/go.sum @@ -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= diff --git a/image/image.go b/image/image.go new file mode 100644 index 0000000..ca2833d --- /dev/null +++ b/image/image.go @@ -0,0 +1,2422 @@ +package main + +// #include +import "C" +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/boombuler/barcode" + "github.com/boombuler/barcode/code128" + "github.com/boombuler/barcode/code39" + "github.com/boombuler/barcode/ean" + "github.com/disintegration/imaging" + "github.com/fogleman/gg" + "github.com/golang/freetype" + "github.com/golang/freetype/truetype" + "github.com/makiuchi-d/gozxing" + "github.com/makiuchi-d/gozxing/qrcode" + "github.com/nfnt/resize" + "golang.org/x/image/draw" + "golang.org/x/image/math/fixed" + "image" + "image/color" + "image/jpeg" + "image/png" + "math" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + "unsafe" + + "github.com/valyala/fasthttp" +) + +// Config 配置结构体 +type Config struct { + OutputDir string // 输出目录路径 + FileName string // 文件名 + MatchDir string // 满足条件的图片目录名 + UnmatchDir string // 不满足条件的图片目录名 + EqualHeightDir string // 等高的图片目录名 + WhiteDir string // 白色底图的图片目录名 + WhiteBorderPngDir string // 去白边转PNG的图片目录名 + WhiteHeightZoomDir string // 缩放的图片目录 + CropDir string // 裁切的图片目录 + MinWhitePct float64 // 纯白占比下限(0-1) + MaxWhitePct float64 // 纯白占比上限(0-1) + Extensions []string // 支持的图片扩展名 +} + +// 检查图片 +func validateConfig(config *Config) error { + // 检查百分比范围 + if config.MinWhitePct < 0 || config.MinWhitePct > 1 { + return fmt.Errorf("纯白占比下限必须在0-1之间") + } + + if config.MaxWhitePct < 0 || config.MaxWhitePct > 1 { + return fmt.Errorf("纯白占比上限必须在0-1之间") + } + + if config.MinWhitePct > config.MaxWhitePct { + return fmt.Errorf("下限不能大于上限") + } + + return nil +} + +// 创建目录功能 +func createDirs(config *Config) error { + // 创建输出根目录 + if err := os.MkdirAll(config.OutputDir, 0755); err != nil { + return err + } + + // 创建匹配目录 + matchPath := filepath.Join(config.OutputDir, config.MatchDir) + if err := os.MkdirAll(matchPath, 0755); err != nil { + return err + } + + // 创建不匹配目录 + unmatchPath := filepath.Join(config.OutputDir, config.UnmatchDir) + if err := os.MkdirAll(unmatchPath, 0755); err != nil { + return err + } + + equalHeightPath := filepath.Join(config.OutputDir, config.EqualHeightDir) + if err := os.MkdirAll(equalHeightPath, 0755); err != nil { + return err + } + + whitePath := filepath.Join(config.OutputDir, config.WhiteDir) + if err := os.MkdirAll(whitePath, 0755); err != nil { + return err + } + + whiteBorderPngPath := filepath.Join(config.OutputDir, config.WhiteBorderPngDir) + if err := os.MkdirAll(whiteBorderPngPath, 0755); err != nil { + return err + } + + whiteHeightZoomPath := filepath.Join(config.OutputDir, config.WhiteHeightZoomDir) + if err := os.MkdirAll(whiteHeightZoomPath, 0755); err != nil { + return err + } + + cropPath := filepath.Join(config.OutputDir, config.CropDir) + if err := os.MkdirAll(cropPath, 0755); err != nil { + return err + } + return nil +} + +// 计算纯白占比 +func calculateWhitePercentage(imagePath string) (float64, error) { + // 打开图片文件 + file, err := os.Open(imagePath) + if err != nil { + return 0, err + } + defer file.Close() + + // 解码图片 + img, _, err := image.Decode(file) + if err != nil { + return 0, err + } + + bounds := img.Bounds() + totalPixels := bounds.Dx() * bounds.Dy() + whitePixels := 0 + + // 遍历每个像素,判断是否为纯白色 + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + pixel := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA) + + // 判断是否为纯白色 (R=255, G=255, B=255) + if pixel.R == 255 && pixel.G == 255 && pixel.B == 255 { + whitePixels++ + } + } + } + + return float64(whitePixels) / float64(totalPixels), nil +} + +// 复制文件到相应目录 +func copyToDestination(srcPath string, config *Config, isMatch bool) error { + filename := filepath.Base(srcPath) + + // 确定目标目录 + var destDir string + if isMatch { + destDir = filepath.Join(config.OutputDir, config.MatchDir) + } else { + destDir = filepath.Join(config.OutputDir, config.UnmatchDir) + } + + destPath := filepath.Join(destDir, filename) + + // 读取源文件 + data, err := os.ReadFile(srcPath) + if err != nil { + return err + } + + // 写入目标文件 + return os.WriteFile(destPath, data, 0644) +} + +// 保存文件 +func saveImage(outputPath string, img image.Image, format string) error { + // 创建输出文件 + outFile, err := os.Create(outputPath) + if err != nil { + return err + } + defer outFile.Close() + + // 根据原始格式保存图片 + switch format { + case "jpeg", "jpg": + // JPEG 格式可以设置质量参数 + return jpeg.Encode(outFile, img, &jpeg.Options{Quality: 95}) + case "png": + // PNG 格式通常不需要质量参数 + return png.Encode(outFile, img) + default: + // 默认使用 JPEG 格式 + return jpeg.Encode(outFile, img, &jpeg.Options{Quality: 95}) + } +} + +// 检测图片纯白占比 +func processImage(config *Config) error { + // 创建输出目录 + if err := createDirs(config); err != nil { + return fmt.Errorf("创建目录失败: %v\n", err) + } + + if err := validateConfig(config); err != nil { + return err + } + + // 计算纯白占比 + whitePct, err := calculateWhitePercentage(config.FileName) + if err != nil { + return fmt.Errorf("错误: %v\n", err) + } + // 判断是否在范围内 + isMatch := whitePct >= config.MinWhitePct && whitePct <= config.MaxWhitePct + status := "❌ 不满足" + if isMatch { + status = "✅ 满足" + } + fmt.Printf("纯白占比: %.2f%% %s\n", whitePct*100, status) + + // 复制文件到相应目录 + if err = copyToDestination(config.FileName, config, isMatch); err != nil { + return fmt.Errorf("复制失败: %v\n", err) + } + return nil +} + +// 根据原始图片生成新的白底图片 +func createWhiteBottomCenteredImage(config *Config, width, height int) (string, error) { + // 创建输出目录 + if err := createDirs(config); err != nil { + return "", fmt.Errorf("创建目录失败: %v", err) + } + // 打开图片 + file, err := os.Open(config.FileName) + if err != nil { + return "", fmt.Errorf("打开图片失败: %v", err) + } + defer file.Close() + // 解码原始图片 + img, format, err := image.Decode(file) + if err != nil { + return "", fmt.Errorf("图片解码失败: %v", err) + } + + // 创建透明背景 + dst := image.NewRGBA(image.Rect(0, 0, width, height)) + + // 设置背景颜色 + var bgColor color.Color + bgColor = color.RGBA{R: 0, G: 0, B: 0, A: 0} // 白色 + + // 填充透明背景 + draw.Draw(dst, dst.Bounds(), &image.Uniform{C: bgColor}, image.Point{}, draw.Src) + + // 计算居中位置 + srcBounds := img.Bounds() + srcWidth := srcBounds.Dx() + srcHeight := srcBounds.Dy() + + x := (width - srcWidth) / 2 + y := (height - srcHeight) / 2 + + // 将原图绘制到中央 + draw.Draw(dst, image.Rect(x, y, x+srcWidth, y+srcHeight), img, image.Point{}, draw.Over) + + filename := filepath.Base(config.FileName) + destPath := filepath.Join(config.OutputDir, config.WhiteDir, filename) + saveImage(destPath, dst, format) + + return destPath, nil +} + +// 根据高度生成等比例图片 +func resizeToHeightQuality(config *Config, targetHeight int) (string, error) { + // 创建输出目录 + if err := createDirs(config); err != nil { + return "", fmt.Errorf("创建目录失败: %v", err) + } + // 打开图片 + file, err := os.Open(config.FileName) + if err != nil { + return "", fmt.Errorf("打开图片失败: %v", err) + } + defer file.Close() + // 解码原始图片 + img, format, err := image.Decode(file) + if err != nil { + return "", fmt.Errorf("图片解码失败: %v", err) + } + + bounds := img.Bounds() + srcWidth := bounds.Dx() + srcHeight := bounds.Dy() + + // 计算等比例缩放后的宽度 + targetWidth := uint(float64(srcWidth) * float64(targetHeight) / float64(srcHeight)) + // 使用 Lanczos3 插值算法进行高质量缩放 + imageNew := resize.Resize(targetWidth, uint(targetHeight), img, resize.Lanczos3) + + // 保存图片到指定目录下 + filename := filepath.Base(config.FileName) + destPath := filepath.Join(config.OutputDir, config.EqualHeightDir, filename) + err = saveImage(destPath, imageNew, format) + if err != nil { + return "", fmt.Errorf("保存图片失败: %v", err) + } + + return destPath, nil +} + +// ImageToPNGConverter 图片去白边并转为PNG +type ImageToPNGConverter struct { + Threshold int + Margin int + BgColor color.RGBA + DetectColor *color.RGBA + KeepTransparent bool + PNGCompressLevel png.CompressionLevel + Quality int +} + +// 去掉白边并转PNG图片工具 +func removeWhiteBorderAndPNG(config *Config) (string, error) { + // 创建输出目录 + if err := createDirs(config); err != nil { + return "", fmt.Errorf("创建目录失败: %v", err) + } + // 打开图片 + file, err := os.Open(config.FileName) + if err != nil { + return "", fmt.Errorf("打开图片失败: %v", err) + } + defer file.Close() + // 解码原始图片 + img, _, err := image.Decode(file) + if err != nil { + return "", fmt.Errorf("图片解码失败: %v", err) + } + + compressLevel := 6 + + // 创建转换器 + compressionLevel := png.DefaultCompression + switch { + case compressLevel <= 0: + compressionLevel = png.NoCompression + case compressLevel >= 9: + compressionLevel = png.BestCompression + default: + // 使用默认压缩级别 + } + + converter := newImageToPNGConverter( + 240, + 0, + &color.RGBA{R: 255, G: 255, B: 255, A: 255}, + nil, + false, + compressionLevel, + 95, + ) + + toPNG := converter.convertToPNG(img, true) + + // 保存图片到指定目录下 + filename := filepath.Base(config.FileName) + // 去除扩展名 + nameWithoutExt := strings.TrimSuffix(filename, filepath.Ext(filename)) + destPath := filepath.Join(config.OutputDir, config.WhiteBorderPngDir, nameWithoutExt+".png") + saveImage(destPath, toPNG, "png") + return destPath, nil +} + +// newImageToPNGConverter 创建新的转换器 +func newImageToPNGConverter(threshold, margin int, bgColor, detectColor *color.RGBA, + keepTransparent bool, compressLevel png.CompressionLevel, quality int) *ImageToPNGConverter { + + // 默认背景色为白色 + bg := color.RGBA{R: 255, G: 255, B: 255, A: 255} + if bgColor != nil { + bg = *bgColor + } + + return &ImageToPNGConverter{ + Threshold: threshold, + Margin: margin, + BgColor: bg, + DetectColor: detectColor, + KeepTransparent: keepTransparent, + PNGCompressLevel: compressLevel, + Quality: quality, + } +} + +// ConvertToPNG 转换图片为PNG格式 +func (c *ImageToPNGConverter) convertToPNG(img image.Image, addBackground bool) image.Image { + // 先裁剪白边 + trimmed := c.trimImage(img) + + // 检查是否有alpha通道 + _, hasAlpha := trimmed.(*image.NRGBA) + if !hasAlpha { + _, hasAlpha = trimmed.(*image.RGBA) + } + + if hasAlpha { + if c.KeepTransparent { + // 保持透明 + return trimmed + } else if addBackground { + // 添加背景色 + bg := image.NewRGBA(trimmed.Bounds()) + draw.Draw(bg, bg.Bounds(), &image.Uniform{C: c.BgColor}, image.Point{}, draw.Src) + draw.Draw(bg, bg.Bounds(), trimmed, trimmed.Bounds().Min, draw.Over) + return bg + } + } else { + // 非透明图像 + if c.KeepTransparent { + // 转换为RGBA + rgba := image.NewRGBA(trimmed.Bounds()) + draw.Draw(rgba, rgba.Bounds(), trimmed, trimmed.Bounds().Min, draw.Src) + return rgba + } + return trimmed + } + + return trimmed +} + +// TrimImage 裁剪图片白边 +func (c *ImageToPNGConverter) trimImage(img image.Image) image.Image { + borders := c.findBorders(img) + + // 创建一个新的图像并裁剪 + trimmed := imaging.Crop(img, borders) + return trimmed +} + +// FindBorders 查找图片的有效边界 +func (c *ImageToPNGConverter) findBorders(img image.Image) image.Rectangle { + bounds := img.Bounds() + width := bounds.Dx() + height := bounds.Dy() + + // 检查图像是否有alpha通道 + _, hasAlpha := img.(*image.NRGBA) + if !hasAlpha { + _, hasAlpha = img.(*image.RGBA) + } + + // 初始化边界 + left := width + top := height + right := 0 + bottom := 0 + + // 查找非背景区域 + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + pixel := img.At(x, y) + if !c.isBackgroundColor(pixel, hasAlpha) { + if x < left { + left = x + } + if x > right { + right = x + } + if y < top { + top = y + } + if y > bottom { + bottom = y + } + } + } + } + + // 如果没有找到非背景区域,返回整个图像 + if left > right || top > bottom { + return bounds + } + + // 添加边距 + left = max(bounds.Min.X, left-c.Margin) + top = max(bounds.Min.Y, top-c.Margin) + right = min(bounds.Max.X, right+c.Margin+1) + bottom = min(bounds.Max.Y, bottom+c.Margin+1) + + return image.Rect(left, top, right, bottom) +} + +// IsBackgroundColor 判断像素是否为背景色 +func (c *ImageToPNGConverter) isBackgroundColor(pixel color.Color, hasAlpha bool) bool { + r, g, b, a := pixel.RGBA() + + // 转换为8位值 + r8 := uint8(r >> 8) + g8 := uint8(g >> 8) + b8 := uint8(b >> 8) + a8 := uint8(a >> 8) + + // 检查透明度 + if hasAlpha && a8 < 25 { // 透明度 > 90% + return true + } + + // 如果指定了检测颜色 + if c.DetectColor != nil { + dr, dg, db, _ := c.DetectColor.RGBA() + dr8 := uint8(dr >> 8) + dg8 := uint8(dg >> 8) + db8 := uint8(db >> 8) + + // threshold 是 int 类型,需要转换为 uint8 比较 + threshold8 := uint8(255 - c.Threshold) + return absDiff(r8, dr8) <= threshold8 && + absDiff(g8, dg8) <= threshold8 && + absDiff(b8, db8) <= threshold8 + } + + // 自动检测白色/浅色背景 + // 注意:这里的 c.Threshold 是 int,需要转换为 uint8 + threshold8 := uint8(c.Threshold) + return r8 >= threshold8 && + g8 >= threshold8 && + b8 >= threshold8 +} + +// 图片缩放 +func resizeWTToHeightQuality(config *Config, dsWidth, dsHeight int) (string, error) { + // 创建输出目录 + if err := createDirs(config); err != nil { + return "", fmt.Errorf("创建目录失败: %v", err) + } + // 打开图片 + file, err := os.Open(config.FileName) + if err != nil { + return "", fmt.Errorf("打开图片失败: %v", err) + } + defer file.Close() + // 解码原始图片 + img, format, err := image.Decode(file) + if err != nil { + return "", fmt.Errorf("图片解码失败: %v", err) + } + + // 使用Lanczos3算法缩放图片 + optimized := resizeImageOptimized(img, dsWidth, dsHeight) + + // 保存图片到指定目录下 + filename := filepath.Base(config.FileName) + destPath := filepath.Join(config.OutputDir, config.WhiteHeightZoomDir, filename) + err = saveImage(destPath, optimized, format) + if err != nil { + return "", fmt.Errorf("保存图片失败: %v", err) + } + + return destPath, nil +} + +// 使用Lanczos3算法缩放图片 +func resizeImageOptimized(src image.Image, dstWidth, dstHeight int) image.Image { + srcBounds := src.Bounds() + srcWidth := srcBounds.Dx() + srcHeight := srcBounds.Dy() + + // 创建目标图片 + dst := image.NewRGBA(image.Rect(0, 0, dstWidth, dstHeight)) + + // 计算缩放比例 + xScale := float64(srcWidth) / float64(dstWidth) + yScale := float64(srcHeight) / float64(dstHeight) + + // Lanczos3算法半径 + radius := 3.0 + + // 预计算x方向的权重 + xWeights := make([][]float64, dstWidth) + xIndices := make([][]int, dstWidth) + + for x := 0; x < dstWidth; x++ { + srcX := float64(x) * xScale + startX := int(math.Max(0, math.Floor(srcX-radius+0.5))) + endX := int(math.Min(float64(srcWidth-1), math.Floor(srcX+radius))) + + weights := make([]float64, 0, endX-startX+1) + indices := make([]int, 0, endX-startX+1) + + for sx := startX; sx <= endX; sx++ { + xDist := float64(sx) + 0.5 - srcX + weight := lanczos3(xDist) + if weight != 0 { + weights = append(weights, weight) + indices = append(indices, sx) + } + } + + xWeights[x] = weights + xIndices[x] = indices + } + + // 处理每一行 + for y := 0; y < dstHeight; y++ { + srcY := float64(y) * yScale + startY := int(math.Max(0, math.Floor(srcY-radius+0.5))) + endY := int(math.Min(float64(srcHeight-1), math.Floor(srcY+radius))) + + // 预计算y方向的权重 + yWeights := make([]float64, 0, endY-startY+1) + yIndices := make([]int, 0, endY-startY+1) + + for sy := startY; sy <= endY; sy++ { + yDist := float64(sy) + 0.5 - srcY + weight := lanczos3(yDist) + if weight != 0 { + yWeights = append(yWeights, weight) + yIndices = append(yIndices, sy) + } + } + + // 处理每一列 + for x := 0; x < dstWidth; x++ { + var rSum, gSum, bSum, aSum, weightSum float64 + + // 应用预计算的权重 + for i, sy := range yIndices { + yWeight := yWeights[i] + + for j, sx := range xIndices[x] { + xWeight := xWeights[x][j] + weight := xWeight * yWeight + + // 获取源像素颜色 + srcColor := src.At(sx+srcBounds.Min.X, sy+srcBounds.Min.Y) + r, g, b, a := srcColor.RGBA() + + // 累加加权颜色值 + rSum += float64(r>>8) * weight + gSum += float64(g>>8) * weight + bSum += float64(b>>8) * weight + aSum += float64(a>>8) * weight + weightSum += weight + } + } + + // 防止除以零 + if weightSum == 0 { + weightSum = 1 + } + + // 计算最终颜色值 + r := clamp(rSum / weightSum) + g := clamp(gSum / weightSum) + b := clamp(bSum / weightSum) + a := clamp(aSum / weightSum) + + // 设置目标像素 + dst.SetRGBA(x, y, color.RGBA{r, g, b, a}) + } + } + + return dst +} + +// 函数计算Lanczos3核函数值 +func lanczos3(x float64) float64 { + if x == 0 { + return 1.0 + } + if x < -3 || x > 3 { + return 0.0 + } + return (3 * math.Sin(math.Pi*x) * math.Sin(math.Pi*x/3)) / (math.Pi * math.Pi * x * x) +} + +// 将值限制在0-255范围内 +func clamp(v float64) uint8 { + if v < 0 { + return 0 + } + if v > 255 { + return 255 + } + return uint8(v + 0.5) +} + +// 图片裁切 +func cropImage(config *Config, x, y, width, height int) (string, error) { + // 创建输出目录 + if err := createDirs(config); err != nil { + return "", fmt.Errorf("创建目录失败: %v", err) + } + + // 打开图片 + file, err := os.Open(config.FileName) + if err != nil { + return "", fmt.Errorf("打开图片失败: %v", err) + } + defer file.Close() + // 解码原始图片 + img, format, err := image.Decode(file) + if err != nil { + return "", fmt.Errorf("图片解码失败: %v", err) + } + + fmt.Printf("输入图片: %s (%dx%d, 格式: %s)\n", + config.FileName, img.Bounds().Dx(), img.Bounds().Dy(), format) + + imgFile, err := basicCrop(img, x, y, width, height) + if err != nil { + return "", err + } + + // 保存图片到指定目录下 + filename := filepath.Base(config.FileName) + destPath := filepath.Join(config.OutputDir, config.CropDir, filename) + err = saveImage(destPath, imgFile, format) + if err != nil { + return "", fmt.Errorf("保存图片失败: %v", err) + } + return destPath, nil +} + +// basicCrop 基础裁切功能 +func basicCrop(src image.Image, x, y, width, height int) (image.Image, error) { + srcBounds := src.Bounds() + srcWidth := srcBounds.Dx() + srcHeight := srcBounds.Dy() + + // 验证裁切参数 + if x < 0 || y < 0 || width <= 0 || height <= 0 { + return nil, fmt.Errorf("invalid crop parameters: x=%d, y=%d, width=%d, height=%d", + x, y, width, height) + } + + if x >= srcWidth || y >= srcHeight { + return nil, fmt.Errorf("crop start point (%d, %d) is outside image bounds (%dx%d)", + x, y, srcWidth, srcHeight) + } + + // 调整裁切尺寸以避免超出边界 + if x+width > srcWidth { + width = srcWidth - x + } + if y+height > srcHeight { + height = srcHeight - y + } + + // 从源图像中裁切 + cropped := image.NewRGBA(image.Rect(0, 0, width, height)) + for cy := 0; cy < height; cy++ { + for cx := 0; cx < width; cx++ { + cropped.Set(cx, cy, src.At(x+cx, y+cy)) + } + } + + return cropped, nil +} + +// 识别二维码 +func scanQRCode(fileName string) (bool, string, error) { + file, err := os.Open(fileName) + if err != nil { + return false, "", fmt.Errorf("打开图片失败: %v", err) + } + defer file.Close() + + // 解码图像 + img, _, err := image.Decode(file) + if err != nil { + return false, "", fmt.Errorf("解码图片失败: %v", err) + } + + // 创建二维码读取器 + bmp, err := gozxing.NewBinaryBitmapFromImage(img) + if err != nil { + return false, "", fmt.Errorf("创建位图失败: %v", err) + } + + // 解码二维码 + reader := qrcode.NewQRCodeReader() + + result, err := reader.Decode(bmp, nil) + if err != nil { + return false, "", analyzeQRCodeError(err) + } + + // 6. 打印二维码内容 + fmt.Printf("二维码内容: %s\n", result.GetText()) + + // 7. 获取二维码位置点 + points := result.GetResultPoints() + if len(points) < 3 { + fmt.Println("未找到足够的定位点") + } + + return true, result.GetText(), nil +} + +// 识别二维码 +func scanQRCodeNew(fileName string) (bool, string, error) { + file, err := os.Open(fileName) + if err != nil { + return false, "", fmt.Errorf("打开图片失败: %v", err) + } + defer file.Close() + + // 解码图像 + img, _, err := image.Decode(file) + if err != nil { + return false, "", fmt.Errorf("解码图片失败: %v", err) + } + + // 创建二维码读取器 + bmp, err := gozxing.NewBinaryBitmapFromImage(img) + if err != nil { + return false, "", fmt.Errorf("创建位图失败: %v", err) + } + + // 解码二维码 + reader := qrcode.NewQRCodeReader() + + result, err := reader.Decode(bmp, nil) + if err != nil { + return false, "", analyzeQRCodeError(err) + } + + // 6. 打印二维码内容 + fmt.Printf("二维码内容: %s\n", result.GetText()) + + // 7. 获取二维码位置点 + points := result.GetResultPoints() + if len(points) < 3 { + fmt.Println("未找到足够的定位点") + } + + // 8. 计算二维码边界框 + minX, minY := int(points[0].GetX()), int(points[0].GetY()) + maxX, maxY := minX, minY + + for _, point := range points { + x, y := int(point.GetX()), int(point.GetY()) + if x < minX { + minX = x + } + if x > maxX { + maxX = x + } + if y < minY { + minY = y + } + if y > maxY { + maxY = y + } + } + + // 9. 添加一些边距(可选) + margin := 10 + minX -= margin + minY -= margin + maxX += margin + maxY += margin + + // 确保坐标不超出图片范围 + bounds := img.Bounds() + if minX < bounds.Min.X { + minX = bounds.Min.X + } + if minY < bounds.Min.Y { + minY = bounds.Min.Y + } + if maxX > bounds.Max.X { + maxX = bounds.Max.X + } + if maxY > bounds.Max.Y { + maxY = bounds.Max.Y + } + + // 10. 创建裁剪区域 + qrRect := image.Rect(minX, minY, maxX, maxY) + fmt.Printf("二维码位置: %v\n", qrRect) + + // 11. 创建新图片并复制二维码区域 + qrImg := image.NewRGBA(qrRect) + draw.Draw(qrImg, qrImg.Bounds(), img, qrRect.Min, draw.Src) + + return true, result.GetText(), nil +} + +// 图像预处理 +func preprocessImage(img image.Image) image.Image { + // 转为灰度图 + gray := imaging.Grayscale(img) + + // 增强对比度 + enhanced := imaging.AdjustContrast(gray, 20) + + // 高斯模糊去噪 + blurred := imaging.Blur(enhanced, 1.0) + + // 自适应二值化 + binary := adaptiveThreshold(blurred) + + return binary +} + +// 二值化处理 +func adaptiveThreshold(img image.Image) image.Image { + bounds := img.Bounds() + dst := image.NewGray(bounds) + + // 局部自适应阈值 + blockSize := 15 + c := 2.0 + + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + // 计算局部均值 + sum := 0.0 + count := 0 + + for dy := -blockSize / 2; dy <= blockSize/2; dy++ { + for dx := -blockSize / 2; dx <= blockSize/2; dx++ { + nx, ny := x+dx, y+dy + if nx >= bounds.Min.X && nx < bounds.Max.X && + ny >= bounds.Min.Y && ny < bounds.Max.Y { + r, _, _, _ := img.At(nx, ny).RGBA() + sum += float64(r >> 8) + count++ + } + } + } + + localMean := sum / float64(count) + r, _, _, _ := img.At(x, y).RGBA() + gray := float64(r >> 8) + + if gray > localMean-c { + dst.SetGray(x, y, color.Gray{Y: 255}) + } else { + dst.SetGray(x, y, color.Gray{Y: 0}) + } + } + } + return dst +} + +// 分析二维码错误类型 +func analyzeQRCodeError(err error) error { + if err == nil { + return nil + } + // 检查不同类型的错误 + switch e := err.(type) { + case gozxing.ChecksumException: + return fmt.Errorf("二维码校验失败(可能部分损坏或被遮挡): %v", e) + case gozxing.FormatException: + return fmt.Errorf("二维码格式错误(可能不是有效的二维码): %v", e) + case gozxing.NotFoundException: + return fmt.Errorf("二维码格式错误: %v", e) + default: + return fmt.Errorf("解码失败: %v", err) + } +} + +// 生成二维码 +func generateQRCode(content string, width int, height int, fileName string) (string, error) { + // 创建 QR 码写入器 + writer := qrcode.NewQRCodeWriter() + + // 设置编码参数 + hints := make(map[gozxing.EncodeHintType]interface{}) + + // 纠错级别设置 + hints[gozxing.EncodeHintType_ERROR_CORRECTION] = "H" + + // 字符集 + hints[gozxing.EncodeHintType_CHARACTER_SET] = "UTF-8" + + // 边距 + hints[gozxing.EncodeHintType_MARGIN] = 4 + + // 生成二维码 + bitMatrix, err := writer.Encode(content, gozxing.BarcodeFormat_QR_CODE, width, height, hints) + if err != nil { + return "", fmt.Errorf("生成二维码失败: %v", err) + } + + // 创建图像 + img := image.NewRGBA(image.Rect(0, 0, width, height)) + + // 白色背景 + 黑色二维码点(合并为单次遍历) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + if bitMatrix.Get(x, y) { + img.Set(x, y, color.Black) + } else { + img.Set(x, y, color.White) + } + } + } + + // 保存文件 + file, err := os.Create(fileName) + if err != nil { + return "", fmt.Errorf("保存二维码失败: %v", err) + } + defer file.Close() + + png.Encode(file, img) + return fmt.Sprintf("生成二维码成功: %v", fileName), nil +} + +// 简单的自动换行实现(按字符数,适合中英文混合) +// 返回处理后的行列表和是否还有更多内容未显示 +func simpleAutoWrap(text string, maxCharsPerLine int) ([]string, bool) { + var lines []string + var currentLine strings.Builder + charCount := 0 + + for _, r := range text { + // 换行符处理 + if r == '\n' { + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + currentLine.Reset() + charCount = 0 + } + continue + } + + // 计算字符宽度(中文算2个字符,英文算1个) + charWidth := 1 + if r >= 0x4E00 && r <= 0x9FFF { // 中文范围 + charWidth = 2 + } else if r >= 0x3000 && r <= 0x303F { // 中文标点 + charWidth = 2 + } + + // 检查是否需要换行 + if charCount+charWidth > maxCharsPerLine && currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + currentLine.Reset() + charCount = 0 + } + + currentLine.WriteRune(r) + charCount += charWidth + } + + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + } + + return lines, false // 返回false表示所有内容都已处理 +} + +// 创建带中文字体的文本图片,支持超出部分显示... +// text 文本, width, height 宽度高度, fontSize 文字大小, outputPath 输入路径 +func createChineseTextImage(text string, width, height int, fontSize float64, outputPath string) (string, error) { + // 获取字体(使用缓存避免重复加载) + f, err := getCachedFont() + if err != nil { + return "", err + } + + // 创建图片 + img := image.NewRGBA(image.Rect(0, 0, width, height)) + + // 白色背景 + white := color.RGBA{255, 255, 255, 255} + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, white) + } + } + + // 创建freetype上下文 + c := freetype.NewContext() + c.SetDPI(72) + c.SetFont(f) + c.SetFontSize(fontSize) + c.SetClip(img.Bounds()) + c.SetDst(img) + c.SetSrc(image.NewUniform(color.Black)) + + // 计算可用的文本宽度(左右各留50像素边距) + availableWidth := width - 100 + + // 根据字体大小计算每行大约可以显示多少个字符 + charsPerLine := int(float64(availableWidth) / fontSize * 1.7) + if charsPerLine < 10 { + charsPerLine = 10 + } + + // 计算最大可显示行数 + // X坐标:从左侧20像素开始 + // Y坐标:从顶部20像素 + 字体高度 + lineSpacing := int(c.PointToFixed(fontSize*1.5) >> 6) + topMargin := 50 + int(c.PointToFixed(fontSize)>>6) + bottomMargin := 50 + maxLines := (height - topMargin - bottomMargin) / lineSpacing + if maxLines <= 0 { + maxLines = 1 + } + + // 使用简单的自动换行,获取所有行 + allLines, _ := simpleAutoWrap(text, charsPerLine) + + // 检查是否有超出图片的内容 + hasMore := len(allLines) > maxLines + displayLines := allLines + if hasMore { + // 只显示前 maxLines-1 行,最后一行添加 "..." + displayLines = allLines[:maxLines-1] + + // 获取最后一行文本,并确保能显示 "..." + lastLine := allLines[maxLines-1] + lastLineChars := 0 + var truncatedLine strings.Builder + + for _, r := range lastLine { + // 计算字符宽度 + charWidth := 1 + if r >= 0x4E00 && r <= 0x9FFF { + charWidth = 2 + } else if r >= 0x3000 && r <= 0x303F { + charWidth = 2 + } + + // 检查是否还能添加字符(留出3个字符给"...") + if lastLineChars+charWidth > charsPerLine-3 { + break + } + + truncatedLine.WriteRune(r) + lastLineChars += charWidth + } + + // 添加 "..." + truncatedText := truncatedLine.String() + " ..." + displayLines = append(displayLines, truncatedText) + } else { + // 如果内容不多,直接显示所有行 + displayLines = allLines + if len(displayLines) > maxLines { + displayLines = displayLines[:maxLines] + } + } + + // 设置起始位置 + startX := 50 + startY := topMargin + + // 绘制每行文字 + pt := freetype.Pt(startX, startY) + for i, line := range displayLines { + // 确保不会超出图片底部 + if i*lineSpacing > height-bottomMargin { + break + } + + // 绘制文字 + _, err = c.DrawString(line, pt) + if err != nil { + return "", fmt.Errorf("绘制文字失败: %v", err) + } + + // 移动到下一行 + pt.Y += c.PointToFixed(fontSize * 1.5) + } + + // 保存图片 + file, err := os.Create(outputPath) + if err != nil { + return "", fmt.Errorf("创建文件失败: %v", err) + } + defer file.Close() + err = png.Encode(file, img) + if err != nil { + return "", fmt.Errorf("编码并写入失败: %v", err) + } + + return outputPath, nil +} + +// 获取系统字体路径 +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 _, path := range paths { + if _, err := os.Stat(path); err == nil { + return path + } + } + 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 _, path := range paths { + if _, err := os.Stat(path); err == nil { + return path + } + } + } + return "" +} + +// 绘制中文文本(支持自动换行) +func drawChineseText(img *image.RGBA, title, author, publisher string) error { + fontObj, err := getCachedFont() + if err != nil { + return err + } + + // 创建freetype上下文 + c := freetype.NewContext() + c.SetDPI(72) + c.SetFont(fontObj) + c.SetSrc(image.NewUniform(color.Black)) + c.SetClip(img.Bounds()) + c.SetDst(img) + + // 定义绘制区域的宽度 + maxWidth := 400 // 可根据需要调整 + + // 1. 绘制书名(使用较大字体,支持多行) + c.SetFontSize(45) + titleLines := wrapText(title, fontObj, 45, maxWidth) + // 限制书名最多显示3行 + if len(titleLines) > 3 { + titleLines = titleLines[:3] + } + titleY := 250 // 起始Y坐标 + titleLineHeight := 60 // 行高 + + for i, line := range titleLines { + lineWidth := calculateStringWidth(line, fontObj, 45) + pt := freetype.Pt((800-int(lineWidth))/2+30, titleY+i*titleLineHeight) + if _, err := c.DrawString(line, pt); err != nil { + return fmt.Errorf("绘制书名失败: %v", err) + } + } + + // 2. 绘制作者(使用中等字体,支持多行) + c.SetFontSize(28) + authorLines := wrapText(author, fontObj, 28, maxWidth) + // 限制作者最多显示2行 + if len(authorLines) > 2 { + authorLines = authorLines[:2] + } + authorY := 420 // 起始Y坐标 + authorLineHeight := 40 // 行高 + + for i, line := range authorLines { + lineWidth := calculateStringWidth(line, fontObj, 28) + pt := freetype.Pt((800-int(lineWidth))/2+30, authorY+i*authorLineHeight) + if _, err := c.DrawString(line, pt); err != nil { + return fmt.Errorf("绘制作者失败: %v", err) + } + } + + // 3. 绘制出版社(使用中等字体,支持多行) + c.SetFontSize(18) + publisherLines := wrapText(publisher, fontObj, 18, maxWidth) + // 限制出版社最多显示2行 + if len(publisherLines) > 1 { + publisherLines = publisherLines[:1] + } + publisherY := 700 // 起始Y坐标 + publisherLineHeight := 30 // 行高 + + for i, line := range publisherLines { + lineWidth := calculateStringWidth(line, fontObj, 18) + pt := freetype.Pt((800-int(lineWidth))/2+30, publisherY+i*publisherLineHeight) + if _, err := c.DrawString(line, pt); err != nil { + return fmt.Errorf("绘制出版社失败: %v", err) + } + } + + // 4. 绘制右下角文字 + err = drawBottomRightText(img, fontObj) + if err != nil { + return fmt.Errorf("绘制右下角文字失败: %v", err) + } + + return nil +} + +// 绘制右下角文字 +func drawBottomRightText(img *image.RGBA, fontObj *truetype.Font) error { + // 设置文字内容 + line1 := "此为实例图片" + line2 := "联系客服获取实图" + + // 设置字体大小 + fontSize := 8.0 + + // 计算右边界距 + rightMargin := 10 // 距离右边界的像素 + bottomMargin := 10 // 距离底部的像素 + + // 计算行高 + lineHeight := int(fontSize * 1.5) + + // 创建freetype上下文 + c := freetype.NewContext() + c.SetDPI(72) + c.SetFont(fontObj) + c.SetClip(img.Bounds()) + c.SetDst(img) + + // 计算第二行(最后一行)的位置 + line2Width := calculateStringWidth(line2, fontObj, fontSize) + x2 := img.Bounds().Dx() - line2Width - rightMargin + y2 := img.Bounds().Dy() - bottomMargin + + // 计算第一行的位置 + line1Width := calculateStringWidth(line1, fontObj, fontSize) + x1 := img.Bounds().Dx() - line1Width - rightMargin + y1 := y2 - lineHeight + + // 设置字体大小 + c.SetFontSize(fontSize) + + // 方法1:多层绘制实现描边效果 + strokeRadius := 1.0 // 描边半径 + + // 绘制描边(灰色,8个方向) + strokeColor := color.RGBA{128, 128, 128, 150} // 灰色 + c.SetSrc(image.NewUniform(strokeColor)) + + // 为第二行绘制描边 + offsets := []struct{ dx, dy float64 }{ + {-strokeRadius, -strokeRadius}, {0, -strokeRadius}, {strokeRadius, -strokeRadius}, + {-strokeRadius, 0}, {strokeRadius, 0}, + {-strokeRadius, strokeRadius}, {0, strokeRadius}, {strokeRadius, strokeRadius}, + } + + for _, offset := range offsets { + pt2 := freetype.Pt(int(float64(x2)+offset.dx), int(float64(y2)+offset.dy)) + if _, err := c.DrawString(line2, pt2); err != nil { + return fmt.Errorf("绘制第二行描边失败: %v", err) + } + + pt1 := freetype.Pt(int(float64(x1)+offset.dx), int(float64(y1)+offset.dy)) + if _, err := c.DrawString(line1, pt1); err != nil { + return fmt.Errorf("绘制第一行描边失败: %v", err) + } + } + + // 绘制白色文字(覆盖在中间) + textColor := color.RGBA{255, 255, 255, 255} // 白色 + c.SetSrc(image.NewUniform(textColor)) + + // 绘制第二行文字 + pt2 := freetype.Pt(x2, y2) + if _, err := c.DrawString(line2, pt2); err != nil { + return fmt.Errorf("绘制第二行文字失败: %v", err) + } + + // 绘制第一行文字 + pt1 := freetype.Pt(x1, y1) + if _, err := c.DrawString(line1, pt1); err != nil { + return fmt.Errorf("绘制第一行文字失败: %v", err) + } + + return nil +} + +// 文本换行函数 +func wrapText(text string, fontObj *truetype.Font, fontSize float64, maxWidth int) []string { + var lines []string + var currentLine strings.Builder + currentLine.Grow(len(text)) + var currentWidth int + + for _, ch := range text { + charWidth := calculateStringWidth(string(ch), fontObj, fontSize) + + // 如果当前字符是换行符,直接换行 + if ch == '\n' { + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + currentLine.Reset() + } + currentWidth = 0 + continue + } + + // 如果加上当前字符会超出宽度,开始新行 + if currentWidth+charWidth > maxWidth && currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + currentLine.Reset() + currentLine.WriteRune(ch) + currentWidth = charWidth + } else { + currentLine.WriteRune(ch) + currentWidth += charWidth + } + } + + // 添加最后一行 + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + } + + return lines +} + +// 计算字符串宽度 +func calculateStringWidth(text string, fontObj *truetype.Font, fontSize float64) int { + width := 0 + for _, ch := range text { + idx := fontObj.Index(ch) + horizAdvance := fontObj.HMetric(fixed.Int26_6(fontSize), idx).AdvanceWidth + width += int(horizAdvance) + } + return width +} + +// 加载PNG图片 +func loadPNG(filename string) (*image.RGBA, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + img, err := png.Decode(file) + if err != nil { + return nil, err + } + + rgba := image.NewRGBA(img.Bounds()) + draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) + + return rgba, nil +} + +// 保存为PNG文件 +func savePNG(img image.Image, filename string) error { + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + + return png.Encode(file, img) +} + +const ( + CODE128 = "code128" + EAN13 = "ean13" + CODE39 = "code39" +) + +// 根据类型生成条形码 +func generateBarcode(barcodeType, content, filename string) (string, error) { + switch barcodeType { + case CODE128: + return generateCode128(content, filename) + case EAN13: + return generateEAN13(content, filename) + case CODE39: + return generateCode39(content, filename) + } + return "", fmt.Errorf("条形码类型不存在: %s", barcodeType) +} + +// 生成Code128条形码 +func generateCode128(content, filename string) (string, error) { + // 创建条形码 + code, err := code128.Encode(content) + if err != nil { + return "", fmt.Errorf("创建条形码失败: %v", err) + } + + // 缩放条形码尺寸 + scaledCode, err := barcode.Scale(code, 250, 85) + if err != nil { + return "", fmt.Errorf("缩放条形码尺寸失败: %v", err) + } + + // 创建带文字的图像 + imgWidth := 250 + imgHeight := 110 // 条形码高度 + 文字区域高度 + dc := gg.NewContext(imgWidth, imgHeight) + + // 设置白色背景 + dc.SetColor(color.White) + dc.Clear() + + dc.DrawImage(scaledCode, 0, 10) + + fontPath := getDefaultFontPath() + if fontPath == "" { + return "", fmt.Errorf("未找到系统字体文件,请手动指定字体路径") + } + + // 设置文字属性 + if err := dc.LoadFontFace("/System/Library/Fonts/Helvetica.ttc", 14); err != nil { + // 如果字体文件不存在,使用默认字体 + dc.LoadFontFace("Arial.ttf", 14) + } + + dc.SetColor(color.Black) + + // 在条形码下方居中绘制文字0 + textY := float64(85 + 20) // 条形码高度100 + 20像素间距 + textWidth, _ := dc.MeasureString(content) + textX := (float64(imgWidth) - textWidth) / 2 + dc.DrawString(content, textX, textY) + + // 保存图像 + err = dc.SavePNG(filename) + if err != nil { + return "", fmt.Errorf("保存图像失败: %v", err) + } + return filename, nil +} + +// 生成EAN13条形码 +func generateEAN13(content, filename string) (string, error) { + eanCode, err := ean.Encode(content) + if err != nil { + return "", fmt.Errorf("创建条形码失败: %v", err) + } + + scaledEanCode, err := barcode.Scale(eanCode, 250, 85) + if err != nil { + return "", fmt.Errorf("缩放条形码尺寸失败: %v", err) + } + + // 创建带文字的图像 + imgWidth := 250 + imgHeight := 110 // 条形码高度 + 文字区域高度 + dc := gg.NewContext(imgWidth, imgHeight) + + // 设置白色背景 + dc.SetColor(color.White) + dc.Clear() + + dc.DrawImage(scaledEanCode, 0, 10) + + fontPath := getDefaultFontPath() + if fontPath == "" { + return "", fmt.Errorf("未找到系统字体文件,请手动指定字体路径") + } + + // 设置文字属性 + if err := dc.LoadFontFace("/System/Library/Fonts/Helvetica.ttc", 14); err != nil { + // 如果字体文件不存在,使用默认字体 + dc.LoadFontFace("Arial.ttf", 14) + } + + dc.SetColor(color.Black) + + // 在条形码下方居中绘制文字0 + textY := float64(85 + 20) // 条形码高度100 + 20像素间距 + textWidth, _ := dc.MeasureString(content) + textX := (float64(imgWidth) - textWidth) / 2 + dc.DrawString(content, textX, textY) + + // 保存图像 + err = dc.SavePNG(filename) + if err != nil { + return "", fmt.Errorf("保存图像失败: %v", err) + } + return filename, nil +} + +// 生成Code39 +func generateCode39(content, filename string) (string, error) { + code, err := code39.Encode(content, true, true) + if err != nil { + return "", fmt.Errorf("创建条形码失败: %v", err) + } + + scaled, err := barcode.Scale(code, 250, 85) + if err != nil { + return "", fmt.Errorf("缩放条形码尺寸失败: %v", err) + } + + // 创建带文字的图像 + imgWidth := 250 + imgHeight := 110 // 条形码高度 + 文字区域高度 + dc := gg.NewContext(imgWidth, imgHeight) + + // 设置白色背景 + dc.SetColor(color.White) + dc.Clear() + + dc.DrawImage(scaled, 0, 10) + + fontPath := getDefaultFontPath() + if fontPath == "" { + return "", fmt.Errorf("未找到系统字体文件,请手动指定字体路径") + } + + // 设置文字属性 + if err := dc.LoadFontFace("/System/Library/Fonts/Helvetica.ttc", 14); err != nil { + // 如果字体文件不存在,使用默认字体 + dc.LoadFontFace("Arial.ttf", 14) + } + + dc.SetColor(color.Black) + + // 在条形码下方居中绘制文字0 + textY := float64(85 + 20) // 条形码高度100 + 20像素间距 + textWidth, _ := dc.MeasureString(content) + textX := (float64(imgWidth) - textWidth) / 2 + dc.DrawString(content, textX, textY) + + // 保存图像 + err = dc.SavePNG(filename) + if err != nil { + return "", fmt.Errorf("保存图像失败: %v", err) + } + return filename, nil +} + +// =================== 辅助函数 ======================= + +// 辅助函数 +func absDiff(a, b uint8) uint8 { + if a > b { + return a - b + } + return b - a +} + +// =========================== 添加水印 =============================== + +// WatermarkConfig 水印配置结构体 +type WatermarkConfig struct { + SourceImageURL string // 源图片URL地址 + WatermarkURL string // 水印图片URL地址 + WatermarkBase64 string // 水印图片base64编码字符串(新增,优先使用) + Opacity float64 // 不透明度 (0.0-1.0) + Position string // 位置: center, top-left, top-right, bottom-left, bottom-right, tile + TileSpacing int // 平铺时的间距 + Scale float64 // 水印缩放比例 (0.0-1.0) + Rotation float64 // 旋转角度 (度数) + XOffset int // X轴偏移量AddWatermarkFromURLEx + YOffset int // Y轴偏移量 + Timeout int // 下载超时时间(秒),默认30秒 + OutputFormat string // 输出格式: "jpeg", "png", "auto"(默认auto,根据源图片格式) + JPEGQuality int // JPEG质量 (1-100),默认95 + TargetWidth int // 目标宽度(0表示不缩放) + TargetHeight int // 目标高度(0表示不缩放) + ResizeMode string // 缩放模式: "fit"(适应,保持比例,可能有黑边), "fill"(填充,裁剪), "stretch"(拉伸) +} + +// 字体缓存,避免重复读取/解析字体文件(5-15MB) +var ( + fontCacheOnce sync.Once + cachedFont *truetype.Font + cachedFontErr error +) + +func getCachedFont() (*truetype.Font, error) { + fontCacheOnce.Do(func() { + fontPath := getDefaultFontPath() + if fontPath == "" { + cachedFontErr = fmt.Errorf("未找到系统字体文件,请手动指定字体路径") + return + } + fontBytes, err := os.ReadFile(fontPath) + if err != nil { + cachedFontErr = fmt.Errorf("读取字体文件失败: %v", err) + return + } + cachedFont, cachedFontErr = truetype.Parse(fontBytes) + }) + return cachedFont, cachedFontErr +} + +// 将客户端声明为全局变量或缓存 +var httpClient = &fasthttp.Client{ + MaxConnsPerHost: 100, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, +} + +// 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) + + // 设置请求URL和方法 + req.SetRequestURI(url) + req.Header.SetMethod("GET") + + // 设置请求头 + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6") + req.Header.Set("Cache-Control", "max-age=0") + req.Header.Set("If-Modified-Since", "Mon, 09 Mar 2026 07:43:59 GMT") + req.Header.Set("Priority", "u=0, i") + req.Header.Set("Sec-Ch-Ua", `"Not:A-Brand";v="99", "Microsoft Edge";v="145", "Chromium";v="145"`) + req.Header.Set("Sec-Ch-Ua-Mobile", "?0") + req.Header.Set("Sec-Ch-Ua-Platform", "Windows") + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-Site", "none") + req.Header.Set("Sec-Fetch-User", "?1") + req.Header.Set("Upgrade-Insecure-Requests", "1") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0") + + // 发送请求 + err := httpClient.Do(req, resp) + if err != nil { + return nil, "", fmt.Errorf("请求失败: %v\n", err) + } + + // 复制body数据,避免引用问题 + body := make([]byte, len(resp.Body())) + copy(body, resp.Body()) + + // 直接从响应体解码图片 + img, format, err := image.Decode(bytes.NewReader(body)) + if err != nil { + return nil, "", fmt.Errorf("解码图片失败: %v", err) + } + + return img, format, nil +} + +// 添加新的辅助函数:从base64字符串加载图片 +func loadImageFromBase64(base64Str string) (image.Image, string, error) { + // 移除可能的 data:image/xxx;base64, 前缀 + base64Str = strings.TrimPrefix(base64Str, "data:image/jpeg;base64,") + base64Str = strings.TrimPrefix(base64Str, "data:image/jpg;base64,") + base64Str = strings.TrimPrefix(base64Str, "data:image/png;base64,") + base64Str = strings.TrimPrefix(base64Str, "data:image/gif;base64,") + base64Str = strings.TrimPrefix(base64Str, "data:image/webp;base64,") + + // 解码base64 + imgData, err := base64.StdEncoding.DecodeString(base64Str) + if err != nil { + return nil, "", fmt.Errorf("解码base64失败: %v", err) + } + + // 检测图片格式 + format := detectImageFormat(imgData) + + // 解码图片 + img, _, err := image.Decode(bytes.NewReader(imgData)) + if err != nil { + return nil, "", fmt.Errorf("解码base64图片失败: %v", err) + } + + return img, format, nil +} + +// 检测图片格式的辅助函数 +func detectImageFormat(data []byte) string { + if len(data) < 12 { + return "unknown" + } + + // PNG: 137 80 78 71 13 10 26 10 + if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + return "png" + } + + // JPEG: 255 216 255 + if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return "jpg" + } + + // GIF: 71 73 70 + if len(data) >= 3 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 { + return "gif" + } + + // WEBP: 82 73 70 70 + if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 { + return "webp" + } + + return "unknown" +} + +// 判断HTTP状态码是否值得重试 +func isRetryableStatus(statusCode int) bool { + // 5xx服务器错误或429限流可以重试 + return statusCode >= 500 || statusCode == 429 +} + +// AddWatermarkFromURL 从URL加载图片添加水印并返回字节集 +func AddWatermarkFromURL(config WatermarkConfig) ([]byte, string, error) { + // 加载源图片 + srcImg, srcFormat, err := loadImageFromURL(config.SourceImageURL, config.Timeout) + if err != nil { + return nil, "", fmt.Errorf("加载源图片失败: %v", err) + } + + // 等比缩放原图到 TargetWidth x TargetHeight 范围内 + if config.TargetWidth > 0 || config.TargetHeight > 0 { + srcW := srcImg.Bounds().Dx() + srcH := srcImg.Bounds().Dy() + var newW, newH uint + if config.TargetWidth > 0 && config.TargetHeight > 0 { + scaleX := float64(config.TargetWidth) / float64(srcW) + scaleY := float64(config.TargetHeight) / float64(srcH) + scale := math.Min(scaleX, scaleY) + newW = uint(float64(srcW) * scale) + newH = uint(float64(srcH) * scale) + } else if config.TargetWidth > 0 { + newW = uint(config.TargetWidth) + newH = uint(float64(srcH) * float64(config.TargetWidth) / float64(srcW)) + } else { + newH = uint(config.TargetHeight) + newW = uint(float64(srcW) * float64(config.TargetHeight) / float64(srcH)) + } + srcImg = resize.Resize(newW, newH, srcImg, resize.Lanczos3) + } + + // 加载水印图片(支持URL或base64) + var watermarkImg image.Image + //var watermarkFormat string // 没用了 + + // 优先使用 base64 + if config.WatermarkBase64 != "" { + watermarkImg, _, err = loadImageFromBase64(config.WatermarkBase64) + if err != nil { + return nil, "", fmt.Errorf("从base64加载水印图片失败: %v", err) + } + } else if config.WatermarkURL != "" { + // 检查是否为base64格式的URL(以data:image开头) + if strings.HasPrefix(config.WatermarkURL, "data:image/") { + watermarkImg, _, err = loadImageFromBase64(config.WatermarkURL) + if err != nil { + return nil, "", fmt.Errorf("从base64 URL加载水印图片失败: %v", err) + } + } else { + // 普通URL + watermarkImg, _, err = loadImageFromURL(config.WatermarkURL, config.Timeout) + if err != nil { + return nil, "", fmt.Errorf("从URL加载水印图片失败: %v", err) + } + } + } else { + return nil, "", fmt.Errorf("必须提供水印图片(WatermarkURL或WatermarkBase64)") + } + + // 创建目标图片(RGBA格式以支持透明) + // 画布尺寸 = 水印尺寸 + watermarkBounds := watermarkImg.Bounds() + dst := image.NewRGBA(watermarkBounds) + + // 先绘制源图片(居中) + srcBounds := srcImg.Bounds() + x := (watermarkBounds.Dx() - srcBounds.Dx()) / 2 + y := (watermarkBounds.Dy() - srcBounds.Dy()) / 2 + draw.Draw(dst, image.Rect(x, y, x+srcBounds.Dx(), y+srcBounds.Dy()), srcImg, image.Point{}, draw.Src) + + // 处理水印 + err = applyWatermark(dst, watermarkImg, config) + if err != nil { + return nil, "", fmt.Errorf("应用水印失败: %v", err) + } + + // 按目标尺寸缩放 + finalImg := resizeOutputImage(dst, config) + + // 确定输出格式 + outputFormat := config.OutputFormat + if outputFormat == "" || outputFormat == "auto" { + outputFormat = srcFormat + } + + // 将图片编码为字节集 + imgBytes, err := encodeImageToBytes(finalImg, outputFormat, config.JPEGQuality) + if err != nil { + return nil, "", fmt.Errorf("编码图片失败: %v", err) + } + + return imgBytes, outputFormat, nil +} + +// encodeImageToBytes 将图片编码为字节集 +func encodeImageToBytes(img image.Image, format string, quality int) ([]byte, error) { + var buf bytes.Buffer + + switch strings.ToLower(format) { + case "jpeg", "jpg": + // 设置默认质量 + if quality <= 0 || quality > 100 { + quality = 95 + } + err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}) + if err != nil { + return nil, fmt.Errorf("JPEG编码失败: %v", err) + } + case "png": + err := png.Encode(&buf, img) + if err != nil { + return nil, fmt.Errorf("PNG编码失败: %v", err) + } + default: + // 默认使用PNG + err := png.Encode(&buf, img) + if err != nil { + return nil, fmt.Errorf("PNG编码失败: %v", err) + } + } + + return buf.Bytes(), nil +} + +// applyWatermark 应用水印到图片 +func applyWatermark(dst *image.RGBA, watermark image.Image, config WatermarkConfig) error { + // 缩放水印 + watermark = scaleWatermark(watermark, config.Scale) + + // 根据位置绘制水印 + switch config.Position { + case "tile": + drawTileWatermark(dst, watermark, config) + default: + drawSingleWatermark(dst, watermark, config) + } + + return nil +} + +// scaleWatermark 缩放水印 +func scaleWatermark(watermark image.Image, scale float64) image.Image { + if scale <= 0 || scale >= 1 { + return watermark + } + + bounds := watermark.Bounds() + newWidth := uint(float64(bounds.Dx()) * scale) + newHeight := uint(float64(bounds.Dy()) * scale) + + if newWidth > 0 && newHeight > 0 { + return resize.Resize(newWidth, newHeight, watermark, resize.Lanczos3) + } + return watermark +} + +// drawSingleWatermark 绘制单个水印 +func drawSingleWatermark(dst *image.RGBA, watermark image.Image, config WatermarkConfig) { + bounds := dst.Bounds() + watermarkBounds := watermark.Bounds() + watermarkWidth := watermarkBounds.Dx() + watermarkHeight := watermarkBounds.Dy() + + // 计算位置 + var x, y int + + switch config.Position { + case "center": + x = (bounds.Dx()-watermarkWidth)/2 + config.XOffset + y = (bounds.Dy()-watermarkHeight)/2 + config.YOffset + case "top-left": + x = config.XOffset + y = config.YOffset + case "top-right": + x = bounds.Dx() - watermarkWidth - config.XOffset + y = config.YOffset + case "bottom-left": + x = config.XOffset + y = bounds.Dy() - watermarkHeight - config.YOffset + case "bottom-right": + x = bounds.Dx() - watermarkWidth - config.XOffset + y = bounds.Dy() - watermarkHeight - config.YOffset + default: // 默认居中 + x = (bounds.Dx()-watermarkWidth)/2 + config.XOffset + y = (bounds.Dy()-watermarkHeight)/2 + config.YOffset + } + + // 确保不超出边界 + x = max(0, min(x, bounds.Dx()-watermarkWidth)) + y = max(0, min(y, bounds.Dy()-watermarkHeight)) + + // 绘制水印 + drawWatermarkWithOpacity(dst, watermark, x, y, config.Opacity) +} + +// drawTileWatermark 平铺水印 +func drawTileWatermark(dst *image.RGBA, watermark image.Image, config WatermarkConfig) { + bounds := dst.Bounds() + watermarkBounds := watermark.Bounds() + watermarkWidth := watermarkBounds.Dx() + watermarkHeight := watermarkBounds.Dy() + + spacing := config.TileSpacing + if spacing < 0 { + spacing = 0 + } + + stepX := watermarkWidth + spacing + stepY := watermarkHeight + spacing + + // 计算起始偏移,使水印均匀分布 + startX := (bounds.Dx() % stepX) / 2 + startY := (bounds.Dy() % stepY) / 2 + + for y := startY; y < bounds.Dy(); y += stepY { + for x := startX; x < bounds.Dx(); x += stepX { + drawWatermarkWithOpacity(dst, watermark, x, y, config.Opacity) + } + } +} + +// drawWatermarkWithOpacity 绘制带透明度的水印 +func drawWatermarkWithOpacity(dst *image.RGBA, watermark image.Image, x, y int, opacity float64) { + if opacity < 0 { + opacity = 0 + } + if opacity > 1 { + opacity = 1 + } + + watermarkBounds := watermark.Bounds() + for wy := 0; wy < watermarkBounds.Dy(); wy++ { + for wx := 0; wx < watermarkBounds.Dx(); wx++ { + // 计算目标位置 + dx := x + wx + dy := y + wy + + // 确保在目标图片范围内 + if dx < 0 || dx >= dst.Bounds().Dx() || dy < 0 || dy >= dst.Bounds().Dy() { + continue + } + + // 获取水印像素 + wColor := watermark.At(wx, wy) + wr, wg, wb, wa := wColor.RGBA() + + // 如果有透明度,考虑水印本身的透明度 + if wa > 0 { + // 转换为8位 + wr8 := uint8(wr >> 8) + wg8 := uint8(wg >> 8) + wb8 := uint8(wb >> 8) + wa8 := uint8(wa >> 8) + + // 获取目标像素 + dstColor := dst.At(dx, dy) + dr, dg, db, _ := dstColor.RGBA() + dr8 := uint8(dr >> 8) + dg8 := uint8(dg >> 8) + db8 := uint8(db >> 8) + + // 混合颜色(考虑水印透明度和设置的不透明度) + alpha := float64(wa8) / 255.0 * opacity + + r := uint8(float64(dr8)*(1-alpha) + float64(wr8)*alpha) + g := uint8(float64(dg8)*(1-alpha) + float64(wg8)*alpha) + b := uint8(float64(db8)*(1-alpha) + float64(wb8)*alpha) + + dst.Set(dx, dy, color.RGBA{r, g, b, 255}) + } + } + } +} + +// resizeOutputImage 根据配置缩放输出图片 +func resizeOutputImage(img image.Image, config WatermarkConfig) image.Image { + if config.TargetWidth <= 0 && config.TargetHeight <= 0 { + return img + } + + srcBounds := img.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + // 如果只设了一个维度,另一个按比例算 + targetW := config.TargetWidth + targetH := config.TargetHeight + if targetW <= 0 { + targetW = int(float64(targetH) * float64(srcW) / float64(srcH)) + } + if targetH <= 0 { + targetH = int(float64(targetW) * float64(srcH) / float64(srcW)) + } + + switch config.ResizeMode { + case "fit": + return resizeFit(img, srcW, srcH, targetW, targetH) + case "fill": + return resizeFill(img, srcW, srcH, targetW, targetH) + case "stretch": + fallthrough + default: + return resize.Resize(uint(targetW), uint(targetH), img, resize.Lanczos3) + } +} + +// resizeFit 等比缩放适应,多出部分填白 +func resizeFit(img image.Image, srcW, srcH, targetW, targetH int) image.Image { + scaleX := float64(targetW) / float64(srcW) + scaleY := float64(targetH) / float64(srcH) + scale := math.Min(scaleX, scaleY) + + newW := int(float64(srcW) * scale) + newH := int(float64(srcH) * scale) + + scaled := resize.Resize(uint(newW), uint(newH), img, resize.Lanczos3) + + // 居中放到白色画布上 + dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH)) + draw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src) + + x := (targetW - newW) / 2 + y := (targetH - newH) / 2 + draw.Draw(dst, image.Rect(x, y, x+newW, y+newH), scaled, image.Point{}, draw.Over) + + return dst +} + +// resizeFill 等比缩放覆盖,溢出部分居中裁剪 +func resizeFill(img image.Image, srcW, srcH, targetW, targetH int) image.Image { + scaleX := float64(targetW) / float64(srcW) + scaleY := float64(targetH) / float64(srcH) + scale := math.Max(scaleX, scaleY) + + newW := int(float64(srcW) * scale) + newH := int(float64(srcH) * scale) + + scaled := resize.Resize(uint(newW), uint(newH), img, resize.Lanczos3) + + // 居中裁剪 + cropX := (newW - targetW) / 2 + cropY := (newH - targetH) / 2 + + dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH)) + draw.Draw(dst, dst.Bounds(), scaled, image.Point{X: cropX, Y: cropY}, draw.Src) + + return dst +} + +// =================== C 导出函数 ======================= +// 检测图片纯白占比 +// +//export ProcessImage +func ProcessImage(jsonConfig *C.char) *C.char { + configStr := C.GoString(jsonConfig) + var config *Config + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return C.CString(fmt.Sprintf("解析 config 失败: %v", err)) + } + if err := processImage(config); err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + return C.CString("成功") +} + +// 根据原始图片生成新的白底图片 +// +//export CreateWhiteBottomCenteredImage +func CreateWhiteBottomCenteredImage(jsonConfig *C.char, width, height C.int) *C.char { + configStr := C.GoString(jsonConfig) + widthInt := int(width) + heightInt := int(height) + var config *Config + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return C.CString(fmt.Sprintf("解析 config 失败: %v", err)) + } + fileName, err := createWhiteBottomCenteredImage(config, widthInt, heightInt) + if err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + return C.CString(fileName) +} + +// 根据高度生成等比例图片 +// +//export ResizeToHeightQuality +func ResizeToHeightQuality(jsonConfig *C.char, targetHeight C.int) *C.char { + configStr := C.GoString(jsonConfig) + targetHeightInt := int(targetHeight) + var config *Config + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return C.CString(fmt.Sprintf("解析 config 失败: %v", err)) + } + fileName, err := resizeToHeightQuality(config, targetHeightInt) + if err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + return C.CString(fileName) +} + +// 去掉白边并转PNG图片工具 +// +//export RemoveWhiteBorderAndPNG +func RemoveWhiteBorderAndPNG(jsonConfig *C.char) *C.char { + configStr := C.GoString(jsonConfig) + var config *Config + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return C.CString(fmt.Sprintf("解析 config 失败: %v", err)) + } + fileName, err := removeWhiteBorderAndPNG(config) + if err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + return C.CString(fileName) +} + +// ResizeWTToHeightQuality 图片缩放 +// +//export ResizeWTToHeightQuality +func ResizeWTToHeightQuality(jsonConfig *C.char, dsWidth, dsHeight C.int) *C.char { + configStr := C.GoString(jsonConfig) + dsWidthStr := int(dsWidth) + dsHeightStr := int(dsHeight) + var config *Config + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return C.CString(fmt.Sprintf("解析 config 失败: %v", err)) + } + fileName, err := resizeWTToHeightQuality(config, dsWidthStr, dsHeightStr) + if err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + return C.CString(fileName) +} + +// CropImage 图片裁切 +// +//export CropImage +func CropImage(jsonConfig *C.char, x, y, width, height C.int) *C.char { + configStr := C.GoString(jsonConfig) + xInt := int(x) + yInt := int(y) + widthInt := int(width) + heightInt := int(height) + var config *Config + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return C.CString(fmt.Sprintf("解析 config 失败: %v", err)) + } + fileName, err := cropImage(config, xInt, yInt, widthInt, heightInt) + if err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + return C.CString(fileName) +} + +// CreateChineseTextImage 创建带中文字体的文本图片,支持超出部分显示... +// +//export CreateChineseTextImage +func CreateChineseTextImage(text *C.char, width, height C.int, fontSize *C.char, outputPath *C.char) *C.char { + textStr := C.GoString(text) + widthInt := int(width) + heightInt := int(height) + fontSizeStr := C.GoString(fontSize) + float, err := strconv.ParseFloat(fontSizeStr, 64) + if err != nil { + return C.CString(fmt.Sprintf("转换float64类型失败: %v", err)) + } + outputPathStr := C.GoString(outputPath) + textImage, err := createChineseTextImage(textStr, widthInt, heightInt, float, outputPathStr) + if err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + return C.CString(textImage) +} + +// DrawChineseInfo 绘制书名,作者,出版社信息 +// +//export DrawChineseInfo +func DrawChineseInfo(filePath, title, author, publisher, outputPath *C.char) *C.char { + filePathStr := C.GoString(filePath) + titleStr := C.GoString(title) + authorStr := C.GoString(author) + publishertr := C.GoString(publisher) + outputPathStr := C.GoString(outputPath) + + img, err := loadPNG(filePathStr) + if err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + err = drawChineseText(img, titleStr, authorStr, publishertr) + if err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + err = savePNG(img, outputPathStr) + if err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + return C.CString(fmt.Sprintf("图片保存成功,路径: %s", outputPathStr)) +} + +// GenerateBarcode 根据类型生成条形码 +// +//export GenerateBarcode +func GenerateBarcode(barcodeType, content, filename *C.char) *C.char { + barcodeTypeStr := C.GoString(barcodeType) + contentStr := C.GoString(content) + filenameStr := C.GoString(filename) + + barcodeFilename, err := generateBarcode(barcodeTypeStr, contentStr, filenameStr) + if err != nil { + return C.CString(fmt.Sprintf("%v", err)) + } + return C.CString(barcodeFilename) +} + +// AddWatermarkFromURLEx 从URL添加水印并返回字节集(C导出函数) +// +//export AddWatermarkFromURLEx +func AddWatermarkFromURLEx(jsonConfig *C.char) *C.char { + configStr := C.GoString(jsonConfig) + + var config WatermarkConfig + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return C.CString(fmt.Sprintf("ERROR:解析水印配置失败: %v", err)) + } + + imgBytes, format, err := AddWatermarkFromURL(config) + if err != nil { + return C.CString(fmt.Sprintf("ERROR:%v", err)) + } + + // 构建带MIME前缀的Base64数据 + var base64Data string + switch format { + case "jpeg", "jpg": + base64Data = "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(imgBytes) + case "png": + base64Data = "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes) + case "gif": + base64Data = "data:image/gif;base64," + base64.StdEncoding.EncodeToString(imgBytes) + default: + base64Data = "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(imgBytes) + } + + // 构建返回结果:格式 + 字节集大小 + 字节集数据 + // 使用Base64编码字节集以便在JSON中传输 + result := struct { + Success bool `json:"success"` + Format string `json:"format"` + Data string `json:"data"` // Base64编码的图片数据 + Size int `json:"size"` + }{ + Success: true, + Format: format, + Data: base64Data, + Size: len(imgBytes), + } + + resultBytes, _ := json.Marshal(result) + return C.CString(string(resultBytes)) +} + +// FreeCString 导出函数:释放C字符串内存 +// +//export FreeCString +func FreeCString(str *C.char) { + C.free(unsafe.Pointer(str)) +} + +// main 函数是必需的,即使为空 +func main() { + + //// 图片URL + //surl := "https://img.pddpic.com/open-gw/2026-05-13/2f79386387d1b72f4081a7b661a9849a.png" + // + //// 1. 下载图片 + //resp, err := http.Get(surl) + //if err != nil { + // panic(err) + //} + //defer resp.Body.Close() + // + //// 2. 读取图片二进制数据 + //imgData, err := io.ReadAll(resp.Body) + //if err != nil { + // panic(err) + //} + // + //// 3. 转换为Base64字符串 + //base64Str := base64.StdEncoding.EncodeToString(imgData) + // + //watermarkConfig := WatermarkConfig{ + // SourceImageURL: "http://booklibimg.kfzimg.com/data/book_lib_img_v2/isbn/1/b044/b044aae81122166798a30699e0f007d2_0_1_300_300.jpg", + // WatermarkBase64: base64Str, + // Position: "center", + // Opacity: 1.0, + // Scale: 1.0, + // TileSpacing: 50, + // Timeout: 30, + // OutputFormat: "jpeg", + // JPEGQuality: 95, + // TargetWidth: 800, + // TargetHeight: 800, + // ResizeMode: "fit", + //} + //url, s, err := AddWatermarkFromURL(watermarkConfig) + //if err != nil { + // fmt.Println(err) + //} else { + // fmt.Println("url:", url, "s:", s) + //} +} diff --git a/imglib/font.go b/imglib/font.go new file mode 100644 index 0000000..0157998 --- /dev/null +++ b/imglib/font.go @@ -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 "" +} diff --git a/imglib/imglib.go b/imglib/imglib.go new file mode 100644 index 0000000..dccabe4 --- /dev/null +++ b/imglib/imglib.go @@ -0,0 +1,1302 @@ +package imglib + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/color" + "image/jpeg" + "image/png" + "math" + "os" + "path/filepath" + "strings" + + "github.com/boombuler/barcode" + "github.com/boombuler/barcode/code128" + "github.com/boombuler/barcode/code39" + "github.com/boombuler/barcode/ean" + "github.com/disintegration/imaging" + "github.com/fogleman/gg" + "github.com/golang/freetype" + "github.com/golang/freetype/truetype" + "github.com/makiuchi-d/gozxing" + "github.com/makiuchi-d/gozxing/qrcode" + "github.com/nfnt/resize" + "golang.org/x/image/draw" + "golang.org/x/image/math/fixed" +) + +// ======================= 配置结构体 ======================= + +// Config 白色占比检测配置 +type Config struct { + OutputDir string + FileName string + MatchDir string + UnmatchDir string + EqualHeightDir string + WhiteDir string + WhiteBorderPngDir string + WhiteHeightZoomDir string + CropDir string + MinWhitePct float64 + MaxWhitePct float64 + Extensions []string +} + +// ======================= 纯白占比检测 ======================= + +// CalculateWhitePercentage 计算图片中纯白色像素占比 +func CalculateWhitePercentage(input ImageInput) (float64, error) { + img, _, err := input.Load() + if err != nil { + return 0, err + } + return calculateWhitePercentageFromImage(img) +} + +func calculateWhitePercentageFromImage(img image.Image) (float64, error) { + bounds := img.Bounds() + totalPixels := bounds.Dx() * bounds.Dy() + if totalPixels == 0 { + return 0, nil + } + whitePixels := 0 + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, _ := img.At(x, y).RGBA() + if r == 0xFFFF && g == 0xFFFF && b == 0xFFFF { + whitePixels++ + } + } + } + return float64(whitePixels) / float64(totalPixels), nil +} + +// ======================= 白底居中合成 ======================= + +// CreateWhiteBottomCenteredImage 将图片居中放置到指定尺寸的白色背景上 +func CreateWhiteBottomCenteredImage(input ImageInput, width, height int) (*image.RGBA, error) { + img, _, err := input.Load() + if err != nil { + return nil, err + } + dst := image.NewRGBA(image.Rect(0, 0, width, height)) + bgColor := color.RGBA{R: 255, G: 255, B: 255, A: 255} + draw.Draw(dst, dst.Bounds(), &image.Uniform{C: bgColor}, image.Point{}, draw.Src) + + srcBounds := img.Bounds() + x := (width - srcBounds.Dx()) / 2 + y := (height - srcBounds.Dy()) / 2 + draw.Draw(dst, image.Rect(x, y, x+srcBounds.Dx(), y+srcBounds.Dy()), img, image.Point{}, draw.Over) + return dst, nil +} + +// ======================= 等比例高度缩放 ======================= + +// ResizeToHeight 按目标高度等比例缩放图片(Lanczos3) +func ResizeToHeight(input ImageInput, targetHeight int) (image.Image, error) { + img, _, err := input.Load() + if err != nil { + return nil, err + } + bounds := img.Bounds() + ratio := float64(targetHeight) / float64(bounds.Dy()) + targetWidth := uint(float64(bounds.Dx()) * ratio) + return resize.Resize(targetWidth, uint(targetHeight), img, resize.Lanczos3), nil +} + +// ======================= 去白边 ======================= + +// RemoveWhiteBorder 去掉图片白色边框并返回 PNG 格式图片 +func RemoveWhiteBorder(input ImageInput) (image.Image, error) { + img, _, err := input.Load() + if err != nil { + return nil, err + } + converter := newImageToPNGConverter(240, 0, &color.RGBA{R: 255, G: 255, B: 255, A: 255}, nil, false, png.DefaultCompression, 95) + return converter.convertToPNG(img, true), nil +} + +// RemoveWhiteBorderWithConfig 带自定义配置的去白边 +func RemoveWhiteBorderWithConfig(input ImageInput, threshold, margin int) (image.Image, error) { + img, _, err := input.Load() + if err != nil { + return nil, err + } + converter := newImageToPNGConverter(threshold, margin, &color.RGBA{R: 255, G: 255, B: 255, A: 255}, nil, false, png.DefaultCompression, 95) + return converter.convertToPNG(img, true), nil +} + +// ======================= 自适应尺寸缩放 ======================= + +// ResizeToDimensions 按目标宽高等比例缩放图片(自实现 Lanczos3) +func ResizeToDimensions(input ImageInput, dstWidth, dstHeight int) (*image.RGBA, error) { + img, _, err := input.Load() + if err != nil { + return nil, err + } + return resizeImageOptimized(img, dstWidth, dstHeight), nil +} + +// ======================= 图片裁切 ======================= + +// Crop 按指定区域裁切图片 +func Crop(input ImageInput, x, y, width, height int) (*image.RGBA, error) { + img, _, err := input.Load() + if err != nil { + return nil, err + } + return basicCrop(img, x, y, width, height) +} + +func basicCrop(src image.Image, x, y, width, height int) (*image.RGBA, error) { + srcBounds := src.Bounds() + srcWidth := srcBounds.Dx() + srcHeight := srcBounds.Dy() + + if x < 0 || y < 0 || width <= 0 || height <= 0 { + return nil, fmt.Errorf("invalid crop parameters: x=%d, y=%d, width=%d, height=%d", x, y, width, height) + } + if x >= srcWidth || y >= srcHeight { + return nil, fmt.Errorf("crop start point (%d, %d) is outside image bounds (%dx%d)", x, y, srcWidth, srcHeight) + } + if x+width > srcWidth { + width = srcWidth - x + } + if y+height > srcHeight { + height = srcHeight - y + } + + cropped := image.NewRGBA(image.Rect(0, 0, width, height)) + for cy := 0; cy < height; cy++ { + for cx := 0; cx < width; cx++ { + cropped.Set(cx, cy, src.At(x+cx, y+cy)) + } + } + return cropped, nil +} + +// ======================= Lanczos3 缩放算法 ======================= + +func resizeImageOptimized(src image.Image, dstWidth, dstHeight int) *image.RGBA { + srcBounds := src.Bounds() + srcWidth := srcBounds.Dx() + srcHeight := srcBounds.Dy() + + dst := image.NewRGBA(image.Rect(0, 0, dstWidth, dstHeight)) + xScale := float64(srcWidth) / float64(dstWidth) + yScale := float64(srcHeight) / float64(dstHeight) + radius := 3.0 + + xWeights := make([][]float64, dstWidth) + xIndices := make([][]int, dstWidth) + + for x := 0; x < dstWidth; x++ { + srcX := float64(x) * xScale + startX := int(math.Max(0, math.Floor(srcX-radius+0.5))) + endX := int(math.Min(float64(srcWidth-1), math.Floor(srcX+radius))) + + weights := make([]float64, 0, endX-startX+1) + indices := make([]int, 0, endX-startX+1) + for sx := startX; sx <= endX; sx++ { + xDist := float64(sx) + 0.5 - srcX + weight := lanczos3(xDist) + if weight != 0 { + weights = append(weights, weight) + indices = append(indices, sx) + } + } + xWeights[x] = weights + xIndices[x] = indices + } + + for y := 0; y < dstHeight; y++ { + srcY := float64(y) * yScale + startY := int(math.Max(0, math.Floor(srcY-radius+0.5))) + endY := int(math.Min(float64(srcHeight-1), math.Floor(srcY+radius))) + + yWeights := make([]float64, 0, endY-startY+1) + yIndices := make([]int, 0, endY-startY+1) + for sy := startY; sy <= endY; sy++ { + yDist := float64(sy) + 0.5 - srcY + weight := lanczos3(yDist) + if weight != 0 { + yWeights = append(yWeights, weight) + yIndices = append(yIndices, sy) + } + } + + for x := 0; x < dstWidth; x++ { + var rSum, gSum, bSum, aSum, weightSum float64 + for i, sy := range yIndices { + yWeight := yWeights[i] + for j, sx := range xIndices[x] { + xWeight := xWeights[x][j] + weight := xWeight * yWeight + + srcColor := src.At(sx+srcBounds.Min.X, sy+srcBounds.Min.Y) + r, g, b, a := srcColor.RGBA() + rSum += float64(r>>8) * weight + gSum += float64(g>>8) * weight + bSum += float64(b>>8) * weight + aSum += float64(a>>8) * weight + weightSum += weight + } + } + if weightSum == 0 { + weightSum = 1 + } + r := clamp(rSum / weightSum) + g := clamp(gSum / weightSum) + b := clamp(bSum / weightSum) + a := clamp(aSum / weightSum) + dst.SetRGBA(x, y, color.RGBA{r, g, b, a}) + } + } + return dst +} + +func lanczos3(x float64) float64 { + if x == 0 { + return 1.0 + } + if x < -3 || x > 3 { + return 0.0 + } + return (3 * math.Sin(math.Pi*x) * math.Sin(math.Pi*x/3)) / (math.Pi * math.Pi * x * x) +} + +func clamp(v float64) uint8 { + if v < 0 { + return 0 + } + if v > 255 { + return 255 + } + return uint8(v + 0.5) +} + +// ======================= 去白边辅助 ======================= + +type ImageToPNGConverter struct { + Threshold int + Margin int + BgColor color.RGBA + DetectColor *color.RGBA + KeepTransparent bool + PNGCompressLevel png.CompressionLevel + Quality int +} + +func newImageToPNGConverter(threshold, margin int, bgColor, detectColor *color.RGBA, + keepTransparent bool, compressLevel png.CompressionLevel, quality int) *ImageToPNGConverter { + bg := color.RGBA{R: 255, G: 255, B: 255, A: 255} + if bgColor != nil { + bg = *bgColor + } + return &ImageToPNGConverter{ + Threshold: threshold, + Margin: margin, + BgColor: bg, + DetectColor: detectColor, + KeepTransparent: keepTransparent, + PNGCompressLevel: compressLevel, + Quality: quality, + } +} + +func (c *ImageToPNGConverter) convertToPNG(img image.Image, addBackground bool) image.Image { + trimmed := c.trimImage(img) + _, hasAlpha := trimmed.(*image.NRGBA) + if !hasAlpha { + _, hasAlpha = trimmed.(*image.RGBA) + } + + if hasAlpha { + if c.KeepTransparent { + return trimmed + } else if addBackground { + bg := image.NewRGBA(trimmed.Bounds()) + draw.Draw(bg, bg.Bounds(), &image.Uniform{C: c.BgColor}, image.Point{}, draw.Src) + draw.Draw(bg, bg.Bounds(), trimmed, trimmed.Bounds().Min, draw.Over) + return bg + } + } else { + if c.KeepTransparent { + rgba := image.NewRGBA(trimmed.Bounds()) + draw.Draw(rgba, rgba.Bounds(), trimmed, trimmed.Bounds().Min, draw.Src) + return rgba + } + return trimmed + } + return trimmed +} + +func (c *ImageToPNGConverter) trimImage(img image.Image) image.Image { + return imaging.Crop(img, c.findBorders(img)) +} + +func (c *ImageToPNGConverter) findBorders(img image.Image) image.Rectangle { + bounds := img.Bounds() + width := bounds.Dx() + height := bounds.Dy() + + _, hasAlpha := img.(*image.NRGBA) + if !hasAlpha { + _, hasAlpha = img.(*image.RGBA) + } + + left := width + top := height + right := 0 + bottom := 0 + + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + pixel := img.At(x, y) + if !c.isBackgroundColor(pixel, hasAlpha) { + if x < left { + left = x + } + if x > right { + right = x + } + if y < top { + top = y + } + if y > bottom { + bottom = y + } + } + } + } + + if left > right || top > bottom { + return bounds + } + + left = max(bounds.Min.X, left-c.Margin) + top = max(bounds.Min.Y, top-c.Margin) + right = min(bounds.Max.X, right+c.Margin+1) + bottom = min(bounds.Max.Y, bottom+c.Margin+1) + + return image.Rect(left, top, right, bottom) +} + +func (c *ImageToPNGConverter) isBackgroundColor(pixel color.Color, hasAlpha bool) bool { + r, g, b, a := pixel.RGBA() + r8 := uint8(r >> 8) + g8 := uint8(g >> 8) + b8 := uint8(b >> 8) + a8 := uint8(a >> 8) + + if hasAlpha && a8 < 25 { + return true + } + + if c.DetectColor != nil { + dr, dg, db, _ := c.DetectColor.RGBA() + dr8 := uint8(dr >> 8) + dg8 := uint8(dg >> 8) + db8 := uint8(db >> 8) + threshold8 := uint8(255 - c.Threshold) + return absDiff(r8, dr8) <= threshold8 && + absDiff(g8, dg8) <= threshold8 && + absDiff(b8, db8) <= threshold8 + } + + threshold8 := uint8(c.Threshold) + return r8 >= threshold8 && + g8 >= threshold8 && + b8 >= threshold8 +} + +func absDiff(a, b uint8) uint8 { + if a > b { + return a - b + } + return b - a +} + +// ======================= 二维码识别 ======================= + +// ScanQRCode 识别图片中的二维码 +func ScanQRCode(input ImageInput) (string, error) { + img, _, err := input.Load() + if err != nil { + return "", err + } + return scanQRCodeFromImage(img) +} + +func scanQRCodeFromImage(img image.Image) (string, error) { + bmp, err := gozxing.NewBinaryBitmapFromImage(img) + if err != nil { + return "", fmt.Errorf("创建位图失败: %v", err) + } + reader := qrcode.NewQRCodeReader() + result, err := reader.Decode(bmp, nil) + if err != nil { + return "", analyzeQRCodeError(err) + } + return result.GetText(), nil +} + +func analyzeQRCodeError(err error) error { + if err == nil { + return nil + } + switch e := err.(type) { + case gozxing.ChecksumException: + return fmt.Errorf("二维码校验失败(可能部分损坏或被遮挡): %v", e) + case gozxing.FormatException: + return fmt.Errorf("二维码格式错误: %v", e) + case gozxing.NotFoundException: + return fmt.Errorf("未找到二维码: %v", e) + default: + return fmt.Errorf("解码失败: %v", err) + } +} + +// ScanQRCodeWithBounds 识别二维码并返回裁剪后的二维码区域图片 +func ScanQRCodeWithBounds(input ImageInput) (string, *image.RGBA, error) { + img, _, err := input.Load() + if err != nil { + return "", nil, err + } + + bmp, err := gozxing.NewBinaryBitmapFromImage(img) + if err != nil { + return "", nil, fmt.Errorf("创建位图失败: %v", err) + } + reader := qrcode.NewQRCodeReader() + result, err := reader.Decode(bmp, nil) + if err != nil { + return "", nil, analyzeQRCodeError(err) + } + + text := result.GetText() + points := result.GetResultPoints() + if len(points) < 3 { + return text, nil, nil + } + + minX, minY := int(points[0].GetX()), int(points[0].GetY()) + maxX, maxY := minX, minY + for _, point := range points { + x, y := int(point.GetX()), int(point.GetY()) + if x < minX { + minX = x + } + if x > maxX { + maxX = x + } + if y < minY { + minY = y + } + if y > maxY { + maxY = y + } + } + + margin := 10 + minX -= margin + minY -= margin + maxX += margin + maxY += margin + + bounds := img.Bounds() + if minX < bounds.Min.X { + minX = bounds.Min.X + } + if minY < bounds.Min.Y { + minY = bounds.Min.Y + } + if maxX > bounds.Max.X { + maxX = bounds.Max.X + } + if maxY > bounds.Max.Y { + maxY = bounds.Max.Y + } + + qrRect := image.Rect(minX, minY, maxX, maxY) + qrImg := image.NewRGBA(qrRect) + draw.Draw(qrImg, qrImg.Bounds(), img, qrRect.Min, draw.Src) + + return text, qrImg, nil +} + +// ======================= 二维码生成 ======================= + +// GenerateQRCode 生成二维码图片 +func GenerateQRCode(content string, width, height int) (*image.RGBA, error) { + writer := qrcode.NewQRCodeWriter() + hints := make(map[gozxing.EncodeHintType]interface{}) + hints[gozxing.EncodeHintType_ERROR_CORRECTION] = "H" + hints[gozxing.EncodeHintType_CHARACTER_SET] = "UTF-8" + hints[gozxing.EncodeHintType_MARGIN] = 4 + + bitMatrix, err := writer.Encode(content, gozxing.BarcodeFormat_QR_CODE, width, height, hints) + if err != nil { + return nil, fmt.Errorf("生成二维码失败: %v", err) + } + + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + if bitMatrix.Get(x, y) { + img.Set(x, y, color.Black) + } else { + img.Set(x, y, color.White) + } + } + } + return img, nil +} + +// ======================= 条形码生成 ======================= + +const ( + Code128 = "code128" + EAN13 = "ean13" + Code39 = "code39" +) + +// GenerateBarcode 生成条形码图片(底部含文字标签) +func GenerateBarcode(barcodeType, content string) (image.Image, error) { + switch barcodeType { + case Code128: + return generateCode128(content) + case EAN13: + return generateEAN13(content) + case Code39: + return generateCode39(content) + } + return nil, fmt.Errorf("不支持的条形码类型: %s", barcodeType) +} + +func generateCode128(content string) (image.Image, error) { + code, err := code128.Encode(content) + if err != nil { + return nil, fmt.Errorf("创建条形码失败: %v", err) + } + scaledCode, err := barcode.Scale(code, 250, 85) + if err != nil { + return nil, fmt.Errorf("缩放条形码失败: %v", err) + } + return addBarcodeLabel(scaledCode, content), nil +} + +func generateEAN13(content string) (image.Image, error) { + code, err := ean.Encode(content) + if err != nil { + return nil, fmt.Errorf("创建条形码失败: %v", err) + } + scaledCode, err := barcode.Scale(code, 250, 85) + if err != nil { + return nil, fmt.Errorf("缩放条形码失败: %v", err) + } + return addBarcodeLabel(scaledCode, content), nil +} + +func generateCode39(content string) (image.Image, error) { + code, err := code39.Encode(content, true, true) + if err != nil { + return nil, fmt.Errorf("创建条形码失败: %v", err) + } + scaledCode, err := barcode.Scale(code, 250, 85) + if err != nil { + return nil, fmt.Errorf("缩放条形码失败: %v", err) + } + return addBarcodeLabel(scaledCode, content), nil +} + +func addBarcodeLabel(barcodeImg image.Image, label string) image.Image { + imgWidth := 250 + imgHeight := 110 + dc := gg.NewContext(imgWidth, imgHeight) + dc.SetColor(color.White) + dc.Clear() + dc.DrawImage(barcodeImg, 0, 10) + + if err := dc.LoadFontFace("Arial.ttf", 14); err != nil { + _ = dc.LoadFontFace("/System/Library/Fonts/Helvetica.ttc", 14) + } + + dc.SetColor(color.Black) + textY := 105.0 + textWidth, _ := dc.MeasureString(label) + textX := (float64(imgWidth) - textWidth) / 2 + dc.DrawString(label, textX, textY) + return dc.Image() +} + +// ======================= 中文文字图片 ======================= + +// CreateChineseTextImage 将中文文本渲染为图片,超出部分显示省略号 +func CreateChineseTextImage(text string, width, height int, fontSize float64) (*image.RGBA, error) { + f, err := GetFont() + if err != nil { + return nil, err + } + + img := image.NewRGBA(image.Rect(0, 0, width, height)) + white := color.RGBA{255, 255, 255, 255} + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, white) + } + } + + c := freetype.NewContext() + c.SetDPI(72) + c.SetFont(f) + c.SetFontSize(fontSize) + c.SetClip(img.Bounds()) + c.SetDst(img) + c.SetSrc(image.NewUniform(color.Black)) + + availableWidth := width - 100 + charsPerLine := int(float64(availableWidth) / fontSize * 1.7) + if charsPerLine < 10 { + charsPerLine = 10 + } + + lineSpacing := int(c.PointToFixed(fontSize*1.5) >> 6) + topMargin := 50 + int(c.PointToFixed(fontSize)>>6) + bottomMargin := 50 + maxLines := (height - topMargin - bottomMargin) / lineSpacing + if maxLines <= 0 { + maxLines = 1 + } + + allLines, _ := simpleAutoWrap(text, charsPerLine) + hasMore := len(allLines) > maxLines + displayLines := allLines + if hasMore { + displayLines = allLines[:maxLines-1] + lastLine := allLines[maxLines-1] + lastLineChars := 0 + var truncatedLine strings.Builder + for _, r := range lastLine { + charWidth := 1 + if r >= 0x4E00 && r <= 0x9FFF { + charWidth = 2 + } else if r >= 0x3000 && r <= 0x303F { + charWidth = 2 + } + if lastLineChars+charWidth > charsPerLine-3 { + break + } + truncatedLine.WriteRune(r) + lastLineChars += charWidth + } + displayLines = append(displayLines, truncatedLine.String()+" ...") + } else { + displayLines = allLines + if len(displayLines) > maxLines { + displayLines = displayLines[:maxLines] + } + } + + startX := 50 + startY := topMargin + pt := freetype.Pt(startX, startY) + for i, line := range displayLines { + if i*lineSpacing > height-bottomMargin { + break + } + if _, err := c.DrawString(line, pt); err != nil { + return nil, fmt.Errorf("绘制文字失败: %v", err) + } + pt.Y += c.PointToFixed(fontSize * 1.5) + } + return img, nil +} + +// DrawChineseInfo 在现有 RGBA 图片上绘制书名、作者、出版社信息 +func DrawChineseInfo(img *image.RGBA, title, author, publisher string) error { + fontObj, err := GetFont() + if err != nil { + return err + } + + c := freetype.NewContext() + c.SetDPI(72) + c.SetFont(fontObj) + c.SetSrc(image.NewUniform(color.Black)) + c.SetClip(img.Bounds()) + c.SetDst(img) + + maxWidth := 400 + + c.SetFontSize(45) + titleLines := wrapText(title, fontObj, 45, maxWidth) + if len(titleLines) > 3 { + titleLines = titleLines[:3] + } + titleY := 250 + titleLineHeight := 60 + for i, line := range titleLines { + lineWidth := calculateStringWidth(line, fontObj, 45) + pt := freetype.Pt((800-int(lineWidth))/2+30, titleY+i*titleLineHeight) + if _, err := c.DrawString(line, pt); err != nil { + return fmt.Errorf("绘制书名失败: %v", err) + } + } + + c.SetFontSize(28) + authorLines := wrapText(author, fontObj, 28, maxWidth) + if len(authorLines) > 2 { + authorLines = authorLines[:2] + } + authorY := 420 + authorLineHeight := 40 + for i, line := range authorLines { + lineWidth := calculateStringWidth(line, fontObj, 28) + pt := freetype.Pt((800-int(lineWidth))/2+30, authorY+i*authorLineHeight) + if _, err := c.DrawString(line, pt); err != nil { + return fmt.Errorf("绘制作者失败: %v", err) + } + } + + c.SetFontSize(18) + publisherLines := wrapText(publisher, fontObj, 18, maxWidth) + if len(publisherLines) > 1 { + publisherLines = publisherLines[:1] + } + publisherY := 700 + publisherLineHeight := 30 + for i, line := range publisherLines { + lineWidth := calculateStringWidth(line, fontObj, 18) + pt := freetype.Pt((800-int(lineWidth))/2+30, publisherY+i*publisherLineHeight) + if _, err := c.DrawString(line, pt); err != nil { + return fmt.Errorf("绘制出版社失败: %v", err) + } + } + + return drawBottomRightText(img, fontObj) +} + +func drawBottomRightText(img *image.RGBA, fontObj *truetype.Font) error { + line1 := "此为实例图片" + line2 := "联系客服获取实图" + fontSize := 8.0 + rightMargin := 10 + bottomMargin := 10 + lineHeight := int(fontSize * 1.5) + + c := freetype.NewContext() + c.SetDPI(72) + c.SetFont(fontObj) + c.SetClip(img.Bounds()) + c.SetDst(img) + c.SetFontSize(fontSize) + + line2Width := calculateStringWidth(line2, fontObj, fontSize) + x2 := img.Bounds().Dx() - line2Width - rightMargin + y2 := img.Bounds().Dy() - bottomMargin + + line1Width := calculateStringWidth(line1, fontObj, fontSize) + x1 := img.Bounds().Dx() - line1Width - rightMargin + y1 := y2 - lineHeight + + strokeRadius := 1.0 + strokeColor := color.RGBA{128, 128, 128, 150} + c.SetSrc(image.NewUniform(strokeColor)) + + offsets := []struct{ dx, dy float64 }{ + {-strokeRadius, -strokeRadius}, {0, -strokeRadius}, {strokeRadius, -strokeRadius}, + {-strokeRadius, 0}, {strokeRadius, 0}, + {-strokeRadius, strokeRadius}, {0, strokeRadius}, {strokeRadius, strokeRadius}, + } + for _, offset := range offsets { + pt2 := freetype.Pt(int(float64(x2)+offset.dx), int(float64(y2)+offset.dy)) + if _, err := c.DrawString(line2, pt2); err != nil { + return err + } + pt1 := freetype.Pt(int(float64(x1)+offset.dx), int(float64(y1)+offset.dy)) + if _, err := c.DrawString(line1, pt1); err != nil { + return err + } + } + + textColor := color.RGBA{255, 255, 255, 255} + c.SetSrc(image.NewUniform(textColor)) + pt2 := freetype.Pt(x2, y2) + if _, err := c.DrawString(line2, pt2); err != nil { + return err + } + pt1 := freetype.Pt(x1, y1) + if _, err := c.DrawString(line1, pt1); err != nil { + return err + } + return nil +} + +func simpleAutoWrap(text string, maxCharsPerLine int) ([]string, bool) { + var lines []string + var currentLine strings.Builder + charCount := 0 + + for _, r := range text { + if r == '\n' { + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + currentLine.Reset() + charCount = 0 + } + continue + } + charWidth := 1 + if r >= 0x4E00 && r <= 0x9FFF { + charWidth = 2 + } else if r >= 0x3000 && r <= 0x303F { + charWidth = 2 + } + if charCount+charWidth > maxCharsPerLine && currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + currentLine.Reset() + charCount = 0 + } + currentLine.WriteRune(r) + charCount += charWidth + } + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + } + return lines, false +} + +func wrapText(text string, fontObj *truetype.Font, fontSize float64, maxWidth int) []string { + var lines []string + var currentLine strings.Builder + currentLine.Grow(len(text)) + var currentWidth int + + for _, ch := range text { + charWidth := calculateStringWidth(string(ch), fontObj, fontSize) + if ch == '\n' { + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + currentLine.Reset() + } + currentWidth = 0 + continue + } + if currentWidth+charWidth > maxWidth && currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + currentLine.Reset() + currentLine.WriteRune(ch) + currentWidth = charWidth + } else { + currentLine.WriteRune(ch) + currentWidth += charWidth + } + } + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + } + return lines +} + +func calculateStringWidth(text string, fontObj *truetype.Font, fontSize float64) int { + width := 0 + for _, ch := range text { + idx := fontObj.Index(ch) + advance := fontObj.HMetric(fixed.Int26_6(fontSize), idx).AdvanceWidth + width += int(advance) + } + return width +} + +// ======================= 水印处理 ======================= + +// WatermarkConfig 水印配置 +type WatermarkConfig struct { + SourceImage ImageInput // 源图片输入 + WatermarkImg ImageInput // 水印图片输入 + Opacity float64 // 不透明度 (0.0-1.0) + Position string // 位置: center, top-left, top-right, bottom-left, bottom-right, tile + TileSpacing int // 平铺间距 + Scale float64 // 水印缩放比例 (0.0-1.0) + Rotation float64 // 旋转角度(预留) + XOffset int // X轴偏移 + YOffset int // Y轴偏移 + Timeout int // 下载超时(秒) + OutputFormat string // 输出格式: "jpeg", "png", "auto" + JPEGQuality int // JPEG质量 (1-100) + TargetWidth int // 目标宽度 + TargetHeight int // 目标高度 + ResizeMode string // 缩放模式: "fit", "fill", "stretch" +} + +// ApplyWatermark 对图片应用水印 +func ApplyWatermark(config WatermarkConfig) (*image.RGBA, error) { + srcImg, _, err := config.SourceImage.Load() + if err != nil { + return nil, fmt.Errorf("加载源图片失败: %v", err) + } + + watermarkImg, _, err := config.WatermarkImg.Load() + if err != nil { + return nil, fmt.Errorf("加载水印图片失败: %v", err) + } + + watermarkBounds := watermarkImg.Bounds() + dst := image.NewRGBA(watermarkBounds) + + srcBounds := srcImg.Bounds() + x := (watermarkBounds.Dx() - srcBounds.Dx()) / 2 + y := (watermarkBounds.Dy() - srcBounds.Dy()) / 2 + draw.Draw(dst, image.Rect(x, y, x+srcBounds.Dx(), y+srcBounds.Dy()), srcImg, image.Point{}, draw.Src) + + if err := applyWatermarkToImage(dst, watermarkImg, config); err != nil { + return nil, err + } + return dst, nil +} + +func applyWatermarkToImage(dst *image.RGBA, watermark image.Image, config WatermarkConfig) error { + watermark = scaleWatermarkImage(watermark, config.Scale) + switch config.Position { + case "tile": + drawTileWatermark(dst, watermark, config) + default: + drawSingleWatermark(dst, watermark, config) + } + return nil +} + +func scaleWatermarkImage(watermark image.Image, scale float64) image.Image { + if scale <= 0 || scale >= 1 { + return watermark + } + bounds := watermark.Bounds() + newWidth := uint(float64(bounds.Dx()) * scale) + newHeight := uint(float64(bounds.Dy()) * scale) + if newWidth > 0 && newHeight > 0 { + return resize.Resize(newWidth, newHeight, watermark, resize.Lanczos3) + } + return watermark +} + +func drawSingleWatermark(dst *image.RGBA, watermark image.Image, config WatermarkConfig) { + bounds := dst.Bounds() + wb := watermark.Bounds() + ww := wb.Dx() + wh := wb.Dy() + + var x, y int + switch config.Position { + case "center": + x = (bounds.Dx()-ww)/2 + config.XOffset + y = (bounds.Dy()-wh)/2 + config.YOffset + case "top-left": + x = config.XOffset + y = config.YOffset + case "top-right": + x = bounds.Dx() - ww - config.XOffset + y = config.YOffset + case "bottom-left": + x = config.XOffset + y = bounds.Dy() - wh - config.YOffset + case "bottom-right": + x = bounds.Dx() - ww - config.XOffset + y = bounds.Dy() - wh - config.YOffset + default: + x = (bounds.Dx()-ww)/2 + config.XOffset + y = (bounds.Dy()-wh)/2 + config.YOffset + } + x = max(0, min(x, bounds.Dx()-ww)) + y = max(0, min(y, bounds.Dy()-wh)) + + drawWatermarkWithOpacity(dst, watermark, x, y, config.Opacity) +} + +func drawTileWatermark(dst *image.RGBA, watermark image.Image, config WatermarkConfig) { + bounds := dst.Bounds() + wb := watermark.Bounds() + ww := wb.Dx() + wh := wb.Dy() + + spacing := config.TileSpacing + if spacing < 0 { + spacing = 0 + } + + stepX := ww + spacing + stepY := wh + spacing + startX := (bounds.Dx() % stepX) / 2 + startY := (bounds.Dy() % stepY) / 2 + + for y := startY; y < bounds.Dy(); y += stepY { + for x := startX; x < bounds.Dx(); x += stepX { + drawWatermarkWithOpacity(dst, watermark, x, y, config.Opacity) + } + } +} + +func drawWatermarkWithOpacity(dst *image.RGBA, watermark image.Image, x, y int, opacity float64) { + if opacity < 0 { + opacity = 0 + } + if opacity > 1 { + opacity = 1 + } + + wb := watermark.Bounds() + for wy := 0; wy < wb.Dy(); wy++ { + for wx := 0; wx < wb.Dx(); wx++ { + dx := x + wx + dy := y + wy + if dx < 0 || dx >= dst.Bounds().Dx() || dy < 0 || dy >= dst.Bounds().Dy() { + continue + } + + wColor := watermark.At(wx, wy) + wr, wg, wb, wa := wColor.RGBA() + if wa > 0 { + wr8 := uint8(wr >> 8) + wg8 := uint8(wg >> 8) + wb8 := uint8(wb >> 8) + wa8 := uint8(wa >> 8) + + dstColor := dst.At(dx, dy) + dr, dg, db, _ := dstColor.RGBA() + dr8 := uint8(dr >> 8) + dg8 := uint8(dg >> 8) + db8 := uint8(db >> 8) + + alpha := float64(wa8) / 255.0 * opacity + r := uint8(float64(dr8)*(1-alpha) + float64(wr8)*alpha) + g := uint8(float64(dg8)*(1-alpha) + float64(wg8)*alpha) + b := uint8(float64(db8)*(1-alpha) + float64(wb8)*alpha) + dst.Set(dx, dy, color.RGBA{r, g, b, 255}) + } + } + } +} + +// ======================= 输出辅助 ======================= + +// EncodeToBytes 将图片编码为字节数组 +func EncodeToBytes(img image.Image, format string, quality int) ([]byte, error) { + var buf bytes.Buffer + switch strings.ToLower(format) { + case "jpeg", "jpg": + if quality <= 0 || quality > 100 { + quality = 95 + } + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil { + return nil, fmt.Errorf("JPEG编码失败: %v", err) + } + case "png": + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("PNG编码失败: %v", err) + } + default: + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("PNG编码失败: %v", err) + } + } + return buf.Bytes(), nil +} + +// EncodeToBase64 将图片编码为带前缀的 Base64 字符串 +func EncodeToBase64(img image.Image, format string, quality int) (string, error) { + data, err := EncodeToBytes(img, format, quality) + if err != nil { + return "", err + } + var prefix string + switch strings.ToLower(format) { + case "jpeg", "jpg": + prefix = "data:image/jpeg;base64," + case "png": + prefix = "data:image/png;base64," + default: + prefix = "data:image/jpeg;base64," + } + return prefix + base64.StdEncoding.EncodeToString(data), nil +} + +// SaveToFile 将图片保存到文件 +func SaveToFile(img image.Image, path string) error { + outFile, err := os.Create(path) + if err != nil { + return err + } + defer outFile.Close() + + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".jpg", ".jpeg": + return jpeg.Encode(outFile, img, &jpeg.Options{Quality: 95}) + case ".png": + return png.Encode(outFile, img) + default: + return png.Encode(outFile, img) + } +} + +// SaveJPEG 将图片保存为 JPEG 文件 +func SaveJPEG(img image.Image, path string, quality int) error { + outFile, err := os.Create(path) + if err != nil { + return err + } + defer outFile.Close() + if quality <= 0 || quality > 100 { + quality = 95 + } + return jpeg.Encode(outFile, img, &jpeg.Options{Quality: quality}) +} + +// SavePNG 将图片保存为 PNG 文件 +func SavePNG(img image.Image, path string) error { + outFile, err := os.Create(path) + if err != nil { + return err + } + defer outFile.Close() + return png.Encode(outFile, img) +} + +// ======================= JSON 辅助(兼容原有 C 接口) ======================= + +// WatermarkConfigJSON 兼容原有 WatermarkConfig JSON 配置 +type WatermarkConfigJSON struct { + SourceImageURL string `json:"source_image_url"` + WatermarkURL string `json:"watermark_url"` + WatermarkBase64 string `json:"watermark_base64"` + Opacity float64 `json:"opacity"` + Position string `json:"position"` + TileSpacing int `json:"tile_spacing"` + Scale float64 `json:"scale"` + Rotation float64 `json:"rotation"` + XOffset int `json:"x_offset"` + YOffset int `json:"y_offset"` + Timeout int `json:"timeout"` + OutputFormat string `json:"output_format"` + JPEGQuality int `json:"jpeg_quality"` + TargetWidth int `json:"target_width"` + TargetHeight int `json:"target_height"` + ResizeMode string `json:"resize_mode"` +} + +// ApplyWatermarkFromJSON 从 JSON 配置应用水印(兼容原有接口) +func ApplyWatermarkFromJSON(jsonStr string) ([]byte, string, error) { + var cfg WatermarkConfigJSON + if err := json.Unmarshal([]byte(jsonStr), &cfg); err != nil { + return nil, "", fmt.Errorf("解析配置失败: %v", err) + } + + wmCfg := WatermarkConfig{ + SourceImage: ImageInput{URL: cfg.SourceImageURL}, + WatermarkImg: ImageInput{URL: cfg.WatermarkURL, Base64: cfg.WatermarkBase64}, + Opacity: cfg.Opacity, + Position: cfg.Position, + TileSpacing: cfg.TileSpacing, + Scale: cfg.Scale, + Rotation: cfg.Rotation, + XOffset: cfg.XOffset, + YOffset: cfg.YOffset, + Timeout: cfg.Timeout, + OutputFormat: cfg.OutputFormat, + JPEGQuality: cfg.JPEGQuality, + TargetWidth: cfg.TargetWidth, + TargetHeight: cfg.TargetHeight, + ResizeMode: cfg.ResizeMode, + } + + dst, err := ApplyWatermark(wmCfg) + if err != nil { + return nil, "", err + } + + // 按目标尺寸缩放 + finalImg := resizeOutputImage(dst, wmCfg) + + outputFormat := cfg.OutputFormat + if outputFormat == "" || outputFormat == "auto" { + outputFormat = "jpeg" + } + + imgBytes, err := EncodeToBytes(finalImg, outputFormat, cfg.JPEGQuality) + if err != nil { + return nil, "", err + } + return imgBytes, outputFormat, nil +} + +func resizeOutputImage(img image.Image, config WatermarkConfig) image.Image { + if config.TargetWidth <= 0 && config.TargetHeight <= 0 { + return img + } + + srcBounds := img.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + targetW := config.TargetWidth + targetH := config.TargetHeight + if targetW <= 0 { + targetW = int(float64(targetH) * float64(srcW) / float64(srcH)) + } + if targetH <= 0 { + targetH = int(float64(targetW) * float64(srcH) / float64(srcW)) + } + + switch config.ResizeMode { + case "fit": + return resizeFit(img, srcW, srcH, targetW, targetH) + case "fill": + return resizeFill(img, srcW, srcH, targetW, targetH) + case "stretch": + return resize.Resize(uint(targetW), uint(targetH), img, resize.Lanczos3) + default: + return resize.Resize(uint(targetW), uint(targetH), img, resize.Lanczos3) + } +} + +func resizeFit(img image.Image, srcW, srcH, targetW, targetH int) image.Image { + scaleX := float64(targetW) / float64(srcW) + scaleY := float64(targetH) / float64(srcH) + scale := math.Min(scaleX, scaleY) + + newW := int(float64(srcW) * scale) + newH := int(float64(srcH) * scale) + + scaled := resize.Resize(uint(newW), uint(newH), img, resize.Lanczos3) + + dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH)) + draw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src) + + x := (targetW - newW) / 2 + y := (targetH - newH) / 2 + draw.Draw(dst, image.Rect(x, y, x+newW, y+newH), scaled, image.Point{}, draw.Over) + return dst +} + +func resizeFill(img image.Image, srcW, srcH, targetW, targetH int) image.Image { + scaleX := float64(targetW) / float64(srcW) + scaleY := float64(targetH) / float64(srcH) + scale := math.Max(scaleX, scaleY) + + newW := int(float64(srcW) * scale) + newH := int(float64(srcH) * scale) + + scaled := resize.Resize(uint(newW), uint(newH), img, resize.Lanczos3) + + cropX := (newW - targetW) / 2 + cropY := (newH - targetH) / 2 + + dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH)) + draw.Draw(dst, dst.Bounds(), scaled, image.Point{X: cropX, Y: cropY}, draw.Src) + return dst +} diff --git a/imglib/imglib_test.go b/imglib/imglib_test.go new file mode 100644 index 0000000..c5ee32d --- /dev/null +++ b/imglib/imglib_test.go @@ -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() +} diff --git a/imglib/loader.go b/imglib/loader.go new file mode 100644 index 0000000..9ae1360 --- /dev/null +++ b/imglib/loader.go @@ -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 +} diff --git a/imglib/watermark_test.go b/imglib/watermark_test.go new file mode 100644 index 0000000..a3a84aa --- /dev/null +++ b/imglib/watermark_test.go @@ -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) +}