daShangDao_utils/imglib/imglib.go
2026-06-30 14:01:26 +08:00

1303 lines
35 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}