- 新增 imglib 包,支持 FilePath/URL/Base64 三种图片输入方式 - 纯白占比检测、白底居中合成、等比缩放、去白边、裁切 - 二维码生成与识别、条形码生成(Code128/EAN13/Code39) - 中文文字图片、书籍信息水印、通用水印叠加 - 输出辅助:EncodeToBytes/EncodeToBase64/SaveToFile/SaveJPEG/SavePNG - 字体缓存,避免重复加载 - 完整测试覆盖(23个测试用例)
2423 lines
64 KiB
Go
2423 lines
64 KiB
Go
package main
|
||
|
||
// #include <stdlib.h>
|
||
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)
|
||
//}
|
||
}
|