2026-06-17 08:05:20 +08:00
|
|
|
|
package services
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"image"
|
|
|
|
|
|
"image/color"
|
2026-06-30 12:30:49 +08:00
|
|
|
|
"image/jpeg"
|
2026-06-17 08:05:20 +08:00
|
|
|
|
"image/png"
|
|
|
|
|
|
"math"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
2026-06-30 12:30:49 +08:00
|
|
|
|
"strconv"
|
2026-06-17 08:05:20 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/disintegration/imaging"
|
2026-06-23 15:33:22 +08:00
|
|
|
|
"github.com/signintech/gopdf"
|
2026-06-17 08:05:20 +08:00
|
|
|
|
_ "golang.org/x/image/webp"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// supportedFormats lists formats that imaging.Open() can handle
|
|
|
|
|
|
var supportedFormats = map[string]bool{
|
|
|
|
|
|
".jpg": true,
|
|
|
|
|
|
".jpeg": true,
|
|
|
|
|
|
".png": true,
|
|
|
|
|
|
".gif": true,
|
|
|
|
|
|
".bmp": true,
|
|
|
|
|
|
".tiff": true,
|
|
|
|
|
|
".tif": true,
|
|
|
|
|
|
".webp": true,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) checkFormat(filePath string) error {
|
|
|
|
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
|
|
|
|
if ext == ".ico" {
|
|
|
|
|
|
return fmt.Errorf("ICO 格式暂不支持,请先转换为 PNG 或 JPG 格式")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !supportedFormats[ext] {
|
|
|
|
|
|
return fmt.Errorf("不支持的图片格式: %s", ext)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ImageService struct{}
|
|
|
|
|
|
|
|
|
|
|
|
func NewImageService() *ImageService {
|
|
|
|
|
|
return &ImageService{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ImageInfo struct {
|
|
|
|
|
|
Width int `json:"width"`
|
|
|
|
|
|
Height int `json:"height"`
|
|
|
|
|
|
Format string `json:"format"`
|
|
|
|
|
|
FilePath string `json:"filePath"`
|
|
|
|
|
|
Size int64 `json:"size"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) GetImageInfo(filePath string) (*ImageInfo, error) {
|
|
|
|
|
|
file, err := os.Open(filePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("打开图片文件失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer file.Close()
|
|
|
|
|
|
|
|
|
|
|
|
config, format, err := image.DecodeConfig(file)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("解码图片配置失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fileInfo, err := os.Stat(filePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("获取文件信息失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &ImageInfo{
|
|
|
|
|
|
Width: config.Width,
|
|
|
|
|
|
Height: config.Height,
|
|
|
|
|
|
Format: format,
|
|
|
|
|
|
FilePath: filePath,
|
|
|
|
|
|
Size: fileInfo.Size(),
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) GetImageBase64(filePath string) (string, error) {
|
|
|
|
|
|
data, err := os.ReadFile(filePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
|
|
|
|
mimeType := "image/png"
|
|
|
|
|
|
switch ext {
|
|
|
|
|
|
case ".jpg", ".jpeg":
|
|
|
|
|
|
mimeType = "image/jpeg"
|
|
|
|
|
|
case ".gif":
|
|
|
|
|
|
mimeType = "image/gif"
|
|
|
|
|
|
case ".bmp":
|
|
|
|
|
|
mimeType = "image/bmp"
|
|
|
|
|
|
case ".webp":
|
|
|
|
|
|
mimeType = "image/webp"
|
|
|
|
|
|
case ".ico":
|
|
|
|
|
|
mimeType = "image/x-icon"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) ConvertFormat(inputPath, outputPath, format string, quality int) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = ChangeExtension(inputPath, "."+format)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-23 15:33:22 +08:00
|
|
|
|
outExt := strings.ToLower(filepath.Ext(outputPath))
|
|
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case outExt == ".jpg" || outExt == ".jpeg" || format == "jpeg" || format == "jpg":
|
2026-06-17 08:05:20 +08:00
|
|
|
|
if quality <= 0 || quality > 100 {
|
|
|
|
|
|
quality = 85
|
|
|
|
|
|
}
|
|
|
|
|
|
err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality))
|
2026-06-23 15:33:22 +08:00
|
|
|
|
case outExt == ".png" || format == "png":
|
2026-06-17 08:05:20 +08:00
|
|
|
|
err = imaging.Save(img, outputPath, imaging.PNGCompressionLevel(png.BestCompression))
|
2026-06-23 15:33:22 +08:00
|
|
|
|
case outExt == ".gif" || format == "gif":
|
2026-06-17 08:05:20 +08:00
|
|
|
|
err = imaging.Save(img, outputPath, imaging.GIFNumColors(256))
|
2026-06-23 15:33:22 +08:00
|
|
|
|
case outExt == ".bmp" || format == "bmp":
|
2026-06-17 08:05:20 +08:00
|
|
|
|
err = imaging.Save(img, outputPath)
|
2026-06-23 15:33:22 +08:00
|
|
|
|
case outExt == ".tiff" || outExt == ".tif" || format == "tiff" || format == "tif":
|
2026-06-17 08:05:20 +08:00
|
|
|
|
err = imaging.Save(img, outputPath)
|
2026-06-23 15:33:22 +08:00
|
|
|
|
case format == "ico" || outExt == ".ico":
|
2026-06-17 08:05:20 +08:00
|
|
|
|
err = s.ConvertToICO(inputPath, outputPath, []int{16, 32, 48, 64, 128, 256})
|
|
|
|
|
|
default:
|
2026-06-23 15:33:22 +08:00
|
|
|
|
if quality <= 0 || quality > 100 {
|
|
|
|
|
|
quality = 85
|
|
|
|
|
|
}
|
|
|
|
|
|
err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality))
|
2026-06-17 08:05:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) ConvertToICO(inputPath, outputPath string, sizes []int) error {
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = ChangeExtension(inputPath, ".ico")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
srcImg, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var images []image.Image
|
|
|
|
|
|
for _, size := range sizes {
|
|
|
|
|
|
resized := imaging.Resize(srcImg, size, size, imaging.Lanczos)
|
|
|
|
|
|
images = append(images, resized)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return encodeICO(outputPath, images)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func encodeICO(outputPath string, images []image.Image) error {
|
|
|
|
|
|
f, err := os.Create(outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var pngDataBuffers [][]byte
|
|
|
|
|
|
for _, img := range images {
|
|
|
|
|
|
buf := new(bytes.Buffer)
|
|
|
|
|
|
if err := png.Encode(buf, img); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
pngDataBuffers = append(pngDataBuffers, buf.Bytes())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
header := make([]byte, 6)
|
|
|
|
|
|
header[0] = 0
|
|
|
|
|
|
header[1] = 0
|
|
|
|
|
|
header[2] = 1
|
|
|
|
|
|
header[3] = 0
|
|
|
|
|
|
count := uint16(len(images))
|
|
|
|
|
|
header[4] = byte(count)
|
|
|
|
|
|
header[5] = byte(count >> 8)
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := f.Write(header); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
offset := uint32(6 + len(images)*16)
|
|
|
|
|
|
for i, img := range images {
|
|
|
|
|
|
bounds := img.Bounds()
|
|
|
|
|
|
w := bounds.Dx()
|
|
|
|
|
|
h := bounds.Dy()
|
|
|
|
|
|
entry := make([]byte, 16)
|
|
|
|
|
|
if w > 255 {
|
|
|
|
|
|
w = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
if h > 255 {
|
|
|
|
|
|
h = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
entry[0] = byte(w)
|
|
|
|
|
|
entry[1] = byte(h)
|
|
|
|
|
|
entry[2] = 0
|
|
|
|
|
|
entry[3] = 0
|
|
|
|
|
|
entry[4] = 1
|
|
|
|
|
|
entry[5] = 0
|
|
|
|
|
|
entry[6] = 32
|
|
|
|
|
|
entry[7] = 0
|
|
|
|
|
|
dataSize := uint32(len(pngDataBuffers[i]))
|
|
|
|
|
|
entry[8] = byte(dataSize)
|
|
|
|
|
|
entry[9] = byte(dataSize >> 8)
|
|
|
|
|
|
entry[10] = byte(dataSize >> 16)
|
|
|
|
|
|
entry[11] = byte(dataSize >> 24)
|
|
|
|
|
|
entry[12] = byte(offset)
|
|
|
|
|
|
entry[13] = byte(offset >> 8)
|
|
|
|
|
|
entry[14] = byte(offset >> 16)
|
|
|
|
|
|
entry[15] = byte(offset >> 24)
|
|
|
|
|
|
if _, err := f.Write(entry); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
offset += uint32(len(pngDataBuffers[i]))
|
|
|
|
|
|
_ = i
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, data := range pngDataBuffers {
|
|
|
|
|
|
if _, err := f.Write(data); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) CompressImage(inputPath, outputPath string, quality int, maxWidth, maxHeight int) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if quality < 1 {
|
|
|
|
|
|
quality = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if quality > 100 {
|
|
|
|
|
|
quality = 100
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_compressed")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 12:30:49 +08:00
|
|
|
|
// 记录原始文件大小
|
|
|
|
|
|
originalInfo, err := os.Stat(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("获取原文件信息失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
originalSize := originalInfo.Size()
|
|
|
|
|
|
|
2026-06-17 08:05:20 +08:00
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 12:30:49 +08:00
|
|
|
|
// 智能降采样:超大图片自动缩小到合理尺寸
|
|
|
|
|
|
bounds := img.Bounds()
|
|
|
|
|
|
width := bounds.Dx()
|
|
|
|
|
|
height := bounds.Dy()
|
|
|
|
|
|
|
|
|
|
|
|
// 如果未指定尺寸,对超大图片自动降采样
|
|
|
|
|
|
if maxWidth == 0 && maxHeight == 0 {
|
|
|
|
|
|
maxDim := 4000 // 超过4000px视为超大
|
|
|
|
|
|
if width > maxDim || height > maxDim {
|
|
|
|
|
|
ratio := float64(maxDim) / float64(max(width, height))
|
|
|
|
|
|
maxWidth = int(float64(width) * ratio)
|
|
|
|
|
|
maxHeight = int(float64(height) * ratio)
|
2026-06-17 08:05:20 +08:00
|
|
|
|
}
|
2026-06-30 12:30:49 +08:00
|
|
|
|
}
|
2026-06-17 08:05:20 +08:00
|
|
|
|
|
2026-06-30 12:30:49 +08:00
|
|
|
|
if maxWidth > 0 || maxHeight > 0 {
|
|
|
|
|
|
newWidth, newHeight := calculateResize(width, height, maxWidth, maxHeight)
|
|
|
|
|
|
if newWidth < width || newHeight < height {
|
|
|
|
|
|
img = imaging.Resize(img, newWidth, newHeight, imaging.Lanczos)
|
2026-06-17 08:05:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ext := strings.ToLower(filepath.Ext(outputPath))
|
|
|
|
|
|
|
|
|
|
|
|
switch ext {
|
|
|
|
|
|
case ".jpg", ".jpeg":
|
2026-06-30 12:30:49 +08:00
|
|
|
|
err = s.saveOptimizedJPEG(img, outputPath, quality)
|
2026-06-17 08:05:20 +08:00
|
|
|
|
case ".png":
|
2026-06-30 12:30:49 +08:00
|
|
|
|
err = s.saveOptimizedPNG(img, outputPath, quality)
|
2026-06-17 08:05:20 +08:00
|
|
|
|
case ".gif":
|
|
|
|
|
|
err = imaging.Save(img, outputPath, imaging.GIFNumColors(256))
|
2026-06-23 15:33:22 +08:00
|
|
|
|
case ".bmp":
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
case ".tiff", ".tif":
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
2026-06-17 08:05:20 +08:00
|
|
|
|
default:
|
2026-06-30 12:30:49 +08:00
|
|
|
|
err = s.saveOptimizedJPEG(img, outputPath, quality)
|
2026-06-17 08:05:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存压缩图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 12:30:49 +08:00
|
|
|
|
// 检查压缩后的文件大小
|
|
|
|
|
|
compressedInfo, err := os.Stat(outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("获取压缩文件信息失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
compressedSize := compressedInfo.Size()
|
|
|
|
|
|
|
|
|
|
|
|
// 如果压缩后文件变大,删除压缩文件并返回提示
|
|
|
|
|
|
if compressedSize >= originalSize {
|
|
|
|
|
|
os.Remove(outputPath)
|
|
|
|
|
|
return fmt.Errorf("压缩后文件变大(%s → %s),建议降低质量参数或选择JPEG格式",
|
|
|
|
|
|
formatSize(originalSize), formatSize(compressedSize))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 08:05:20 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) ResizeImage(inputPath, outputPath string, width, height int, maintainRatio bool) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if width <= 0 || height <= 0 {
|
|
|
|
|
|
return fmt.Errorf("宽度和高度必须大于 0")
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_resized")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if maintainRatio {
|
|
|
|
|
|
img = imaging.Fit(img, width, height, imaging.Lanczos)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
img = imaging.Resize(img, width, height, imaging.Lanczos)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存调整大小后的图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) RotateImage(inputPath, outputPath string, angle float64, bgColor string) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_rotated")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var fillColor color.Color
|
|
|
|
|
|
switch bgColor {
|
|
|
|
|
|
case "white":
|
|
|
|
|
|
fillColor = color.White
|
|
|
|
|
|
case "black":
|
|
|
|
|
|
fillColor = color.Black
|
|
|
|
|
|
default:
|
|
|
|
|
|
fillColor = color.Transparent
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img = imaging.Rotate(img, angle, fillColor)
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存旋转后的图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) AddGrayscale(inputPath, outputPath string) error {
|
|
|
|
|
|
return s.AdjustGrayscale(inputPath, outputPath, 100)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) AdjustGrayscale(inputPath, outputPath string, intensity int) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_grayscale")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if intensity >= 100 {
|
|
|
|
|
|
img = imaging.Grayscale(img)
|
|
|
|
|
|
} else if intensity > 0 {
|
|
|
|
|
|
gray := imaging.Grayscale(img)
|
|
|
|
|
|
img = blendImages(img, gray, float64(intensity)/100.0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存灰度图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func blendImages(src, gray image.Image, opacity float64) image.Image {
|
|
|
|
|
|
if opacity < 0 {
|
|
|
|
|
|
opacity = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
if opacity > 1 {
|
|
|
|
|
|
opacity = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
bounds := src.Bounds()
|
|
|
|
|
|
dst := image.NewRGBA(bounds)
|
|
|
|
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
|
|
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
|
|
|
|
r1, g1, b1, a1 := src.At(x, y).RGBA()
|
|
|
|
|
|
r2, g2, b2, _ := gray.At(x, y).RGBA()
|
|
|
|
|
|
r := uint8((float64(r1>>8)*(1-opacity) + float64(r2>>8)*opacity))
|
|
|
|
|
|
g := uint8((float64(g1>>8)*(1-opacity) + float64(g2>>8)*opacity))
|
|
|
|
|
|
b := uint8((float64(b1>>8)*(1-opacity) + float64(b2>>8)*opacity))
|
|
|
|
|
|
a := uint8(a1 >> 8)
|
|
|
|
|
|
dst.Set(x, y, color.RGBA{r, g, b, a})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return dst
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) AdjustBrightness(inputPath, outputPath string, factor float64) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_bright")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img = imaging.AdjustBrightness(img, factor)
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存调整亮度后的图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) AdjustContrast(inputPath, outputPath string, factor float64) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_contrast")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img = imaging.AdjustContrast(img, factor)
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存调整对比度后的图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) AdjustSaturation(inputPath, outputPath string, factor float64) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_saturation")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img = imaging.AdjustSaturation(img, factor)
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存调整饱和度后的图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) FlipH(inputPath, outputPath string) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_fliph")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img = imaging.FlipH(img)
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存水平翻转图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) FlipV(inputPath, outputPath string) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_flipv")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img = imaging.FlipV(img)
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存垂直翻转图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) SharpenImage(inputPath, outputPath string, amount float64) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_sharpen")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img = imaging.Sharpen(img, amount)
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存锐化图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) BlurImage(inputPath, outputPath string, radius float64) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_blur")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img = imaging.Blur(img, radius)
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存模糊图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) InvertImage(inputPath, outputPath string) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_invert")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img = imaging.Invert(img)
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(img, outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存反色图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) RemoveBackground(inputPath, outputPath string, threshold int, bgColor *ColorRGB) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_nobg")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if threshold <= 0 {
|
|
|
|
|
|
threshold = 30
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
srcImg, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bounds := srcImg.Bounds()
|
|
|
|
|
|
dst := image.NewRGBA(bounds)
|
|
|
|
|
|
|
|
|
|
|
|
maxDist := float64(threshold) / 100.0 * 441.67
|
|
|
|
|
|
|
|
|
|
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
|
|
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
|
|
|
|
r, g, b, _ := srcImg.At(x, y).RGBA()
|
|
|
|
|
|
r8 := uint8(r >> 8)
|
|
|
|
|
|
g8 := uint8(g >> 8)
|
|
|
|
|
|
b8 := uint8(b >> 8)
|
|
|
|
|
|
|
|
|
|
|
|
var isBg bool
|
|
|
|
|
|
if bgColor != nil {
|
|
|
|
|
|
dr := float64(int(r8) - bgColor.R)
|
|
|
|
|
|
dg := float64(int(g8) - bgColor.G)
|
|
|
|
|
|
db := float64(int(b8) - bgColor.B)
|
|
|
|
|
|
dist := math.Sqrt(dr*dr + dg*dg + db*db)
|
|
|
|
|
|
isBg = dist <= maxDist
|
|
|
|
|
|
} else {
|
|
|
|
|
|
isBg = int(r8) > 255-threshold &&
|
|
|
|
|
|
int(g8) > 255-threshold &&
|
|
|
|
|
|
int(b8) > 255-threshold
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if isBg {
|
|
|
|
|
|
dst.Set(x, y, color.RGBA{0, 0, 0, 0})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
dst.Set(x, y, srcImg.At(x, y))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err = imaging.Save(dst, outputPath, imaging.PNGCompressionLevel(png.BestCompression))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("保存去背景图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type CropShape struct {
|
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
X int `json:"x"`
|
|
|
|
|
|
Y int `json:"y"`
|
|
|
|
|
|
Width int `json:"width"`
|
|
|
|
|
|
Height int `json:"height"`
|
|
|
|
|
|
Radius int `json:"radius"`
|
|
|
|
|
|
Points []Point `json:"points"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Point struct {
|
|
|
|
|
|
X int `json:"x"`
|
|
|
|
|
|
Y int `json:"y"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ColorRGB struct {
|
|
|
|
|
|
R int `json:"r"`
|
|
|
|
|
|
G int `json:"g"`
|
|
|
|
|
|
B int `json:"b"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) CropImageShape(inputPath, outputPath string, shape CropShape) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = GetOutputPath(inputPath, "_cropped")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
srcImg, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("打开图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch shape.Type {
|
|
|
|
|
|
case "rectangle":
|
|
|
|
|
|
rect := image.Rect(shape.X, shape.Y, shape.X+shape.Width, shape.Y+shape.Height)
|
|
|
|
|
|
cropped := imaging.Crop(srcImg, rect)
|
|
|
|
|
|
return imaging.Save(cropped, outputPath)
|
|
|
|
|
|
|
|
|
|
|
|
case "circle":
|
|
|
|
|
|
cx := shape.X + shape.Width/2
|
|
|
|
|
|
cy := shape.Y + shape.Height/2
|
|
|
|
|
|
radius := shape.Width / 2
|
|
|
|
|
|
if shape.Height/2 < radius {
|
|
|
|
|
|
radius = shape.Height / 2
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
size := radius * 2
|
|
|
|
|
|
result := image.NewRGBA(image.Rect(0, 0, size, size))
|
|
|
|
|
|
for y := 0; y < size; y++ {
|
|
|
|
|
|
for x := 0; x < size; x++ {
|
|
|
|
|
|
if math.Sqrt(float64((x-radius)*(x-radius)+((y-radius)*(y-radius)))) <= float64(radius) {
|
|
|
|
|
|
result.Set(x, y, srcImg.At(cx-radius+x, cy-radius+y))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return imaging.Save(result, outputPath)
|
|
|
|
|
|
|
|
|
|
|
|
case "rounded":
|
|
|
|
|
|
rect := image.Rect(shape.X, shape.Y, shape.X+shape.Width, shape.Y+shape.Height)
|
|
|
|
|
|
cropped := imaging.Crop(srcImg, rect)
|
|
|
|
|
|
|
|
|
|
|
|
radius := shape.Radius
|
|
|
|
|
|
if radius <= 0 {
|
|
|
|
|
|
radius = 20
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
size := cropped.Bounds().Size()
|
|
|
|
|
|
result := image.NewRGBA(image.Rect(0, 0, size.X, size.Y))
|
|
|
|
|
|
for y := 0; y < size.Y; y++ {
|
|
|
|
|
|
for x := 0; x < size.X; x++ {
|
|
|
|
|
|
inCorner := false
|
|
|
|
|
|
if x < radius && y < radius {
|
|
|
|
|
|
dx := float64(radius - x)
|
|
|
|
|
|
dy := float64(radius - y)
|
|
|
|
|
|
if math.Sqrt(dx*dx+dy*dy) > float64(radius) {
|
|
|
|
|
|
inCorner = true
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if x >= size.X-radius && y < radius {
|
|
|
|
|
|
dx := float64(x - (size.X - radius - 1))
|
|
|
|
|
|
dy := float64(radius - y)
|
|
|
|
|
|
if math.Sqrt(dx*dx+dy*dy) > float64(radius) {
|
|
|
|
|
|
inCorner = true
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if x < radius && y >= size.Y-radius {
|
|
|
|
|
|
dx := float64(radius - x)
|
|
|
|
|
|
dy := float64(y - (size.Y - radius - 1))
|
|
|
|
|
|
if math.Sqrt(dx*dx+dy*dy) > float64(radius) {
|
|
|
|
|
|
inCorner = true
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if x >= size.X-radius && y >= size.Y-radius {
|
|
|
|
|
|
dx := float64(x - (size.X - radius - 1))
|
|
|
|
|
|
dy := float64(y - (size.Y - radius - 1))
|
|
|
|
|
|
if math.Sqrt(dx*dx+dy*dy) > float64(radius) {
|
|
|
|
|
|
inCorner = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !inCorner {
|
|
|
|
|
|
result.Set(x, y, cropped.At(cropped.Bounds().Min.X+x, cropped.Bounds().Min.Y+y))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return imaging.Save(result, outputPath)
|
|
|
|
|
|
|
|
|
|
|
|
case "polygon":
|
|
|
|
|
|
if len(shape.Points) < 3 {
|
|
|
|
|
|
return fmt.Errorf("多边形至少需要3个点")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
minX, minY := shape.Points[0].X, shape.Points[0].Y
|
|
|
|
|
|
maxX, maxY := shape.Points[0].X, shape.Points[0].Y
|
|
|
|
|
|
for _, p := range shape.Points {
|
|
|
|
|
|
if p.X < minX {
|
|
|
|
|
|
minX = p.X
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.Y < minY {
|
|
|
|
|
|
minY = p.Y
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.X > maxX {
|
|
|
|
|
|
maxX = p.X
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.Y > maxY {
|
|
|
|
|
|
maxY = p.Y
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cropW := maxX - minX
|
|
|
|
|
|
cropH := maxY - minY
|
|
|
|
|
|
rect := image.Rect(minX, minY, maxX, maxY)
|
|
|
|
|
|
cropped := imaging.Crop(srcImg, rect)
|
|
|
|
|
|
|
|
|
|
|
|
result := image.NewRGBA(image.Rect(0, 0, cropW, cropH))
|
|
|
|
|
|
for y := 0; y < cropH; y++ {
|
|
|
|
|
|
for x := 0; x < cropW; x++ {
|
|
|
|
|
|
if pointInPolygon(x, y, shape.Points, minX, minY) {
|
|
|
|
|
|
result.Set(x, y, cropped.At(cropped.Bounds().Min.X+x, cropped.Bounds().Min.Y+y))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return imaging.Save(result, outputPath)
|
|
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
rect := image.Rect(shape.X, shape.Y, shape.X+shape.Width, shape.Y+shape.Height)
|
|
|
|
|
|
cropped := imaging.Crop(srcImg, rect)
|
|
|
|
|
|
return imaging.Save(cropped, outputPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func pointInPolygon(px, py int, points []Point, offsetX, offsetY int) bool {
|
|
|
|
|
|
n := len(points)
|
|
|
|
|
|
inside := false
|
|
|
|
|
|
j := n - 1
|
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
|
xi, yi := points[i].X-offsetX, points[i].Y-offsetY
|
|
|
|
|
|
xj, yj := points[j].X-offsetX, points[j].Y-offsetY
|
|
|
|
|
|
if yi == yj {
|
|
|
|
|
|
j = i
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((yi > py) != (yj > py)) && (px < (xj-xi)*(py-yi)/(yj-yi)+xi) {
|
|
|
|
|
|
inside = !inside
|
|
|
|
|
|
}
|
|
|
|
|
|
j = i
|
|
|
|
|
|
}
|
|
|
|
|
|
return inside
|
|
|
|
|
|
}
|
2026-06-23 15:33:22 +08:00
|
|
|
|
|
2026-06-30 12:30:49 +08:00
|
|
|
|
// calculateResize 计算保持宽高比的resize尺寸
|
|
|
|
|
|
func calculateResize(origW, origH, maxW, maxH int) (int, int) {
|
|
|
|
|
|
if maxW == 0 && maxH == 0 {
|
|
|
|
|
|
return origW, origH
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ratioW := float64(origW) / float64(maxW)
|
|
|
|
|
|
ratioH := float64(origH) / float64(maxH)
|
|
|
|
|
|
|
|
|
|
|
|
var ratio float64
|
|
|
|
|
|
if maxW == 0 {
|
|
|
|
|
|
ratio = ratioH
|
|
|
|
|
|
} else if maxH == 0 {
|
|
|
|
|
|
ratio = ratioW
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ratio = math.Max(ratioW, ratioH)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ratio <= 1 {
|
|
|
|
|
|
return origW, origH // 无需缩小
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
newW := int(float64(origW) / ratio)
|
|
|
|
|
|
newH := int(float64(origH) / ratio)
|
|
|
|
|
|
return newW, newH
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// saveOptimizedJPEG 使用优化的JPEG编码
|
|
|
|
|
|
func (s *ImageService) saveOptimizedJPEG(img image.Image, path string, quality int) error {
|
|
|
|
|
|
f, err := os.Create(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// 使用标准JPEG编码器,imaging库已做优化
|
|
|
|
|
|
opts := &jpeg.Options{
|
|
|
|
|
|
Quality: quality,
|
|
|
|
|
|
}
|
|
|
|
|
|
return jpeg.Encode(f, img, opts)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// saveOptimizedPNG 根据质量选择最优PNG压缩级别
|
|
|
|
|
|
func (s *ImageService) saveOptimizedPNG(img image.Image, path string, quality int) error {
|
|
|
|
|
|
f, err := os.Create(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// PNG压缩级别映射:quality越高,压缩越强(但速度慢)
|
|
|
|
|
|
var level png.CompressionLevel
|
|
|
|
|
|
if quality >= 90 {
|
|
|
|
|
|
level = png.BestCompression // 最大压缩
|
|
|
|
|
|
} else if quality >= 70 {
|
|
|
|
|
|
level = png.DefaultCompression
|
|
|
|
|
|
} else {
|
|
|
|
|
|
level = png.BestSpeed // 快速压缩
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
encoder := &png.Encoder{
|
|
|
|
|
|
CompressionLevel: level,
|
|
|
|
|
|
}
|
|
|
|
|
|
return encoder.Encode(f, img)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-23 15:33:22 +08:00
|
|
|
|
func (s *ImageService) ImageToPDF(inputPath, outputPath string) error {
|
|
|
|
|
|
if err := s.checkFormat(inputPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = ChangeExtension(inputPath, ".pdf")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, err := imaging.Open(inputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("open image failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bounds := img.Bounds()
|
|
|
|
|
|
imgW := float64(bounds.Dx())
|
|
|
|
|
|
imgH := float64(bounds.Dy())
|
|
|
|
|
|
|
|
|
|
|
|
pdf := gopdf.GoPdf{}
|
|
|
|
|
|
pageW := 595.0
|
|
|
|
|
|
pageH := 842.0
|
|
|
|
|
|
|
|
|
|
|
|
scale := pageW / imgW
|
|
|
|
|
|
if imgH*scale > pageH {
|
|
|
|
|
|
scale = pageH / imgH
|
|
|
|
|
|
}
|
|
|
|
|
|
drawW := imgW * scale
|
|
|
|
|
|
drawH := imgH * scale
|
|
|
|
|
|
|
|
|
|
|
|
pdf.Start(gopdf.Config{PageSize: gopdf.Rect{W: pageW, H: pageH}})
|
|
|
|
|
|
pdf.AddPage()
|
|
|
|
|
|
|
|
|
|
|
|
if err := pdf.Image(inputPath, (pageW-drawW)/2, (pageH-drawH)/2, &gopdf.Rect{W: drawW, H: drawH}); err != nil {
|
|
|
|
|
|
return fmt.Errorf("embed image: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return pdf.WritePdf(outputPath)
|
|
|
|
|
|
}
|
2026-06-30 12:30:49 +08:00
|
|
|
|
|
|
|
|
|
|
// StitchRequest 拼接参数
|
|
|
|
|
|
type StitchRequest struct {
|
|
|
|
|
|
InputPaths []string `json:"inputPaths"`
|
|
|
|
|
|
Direction string `json:"direction"` // "vertical" or "horizontal"
|
|
|
|
|
|
Gap int `json:"gap"` // 图片间距(px)
|
|
|
|
|
|
BgColor string `json:"bgColor"` // 背景色 hex, e.g. "#ffffff"
|
|
|
|
|
|
OutputPath string `json:"outputPath"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *ImageService) StitchImages(req StitchRequest) (string, error) {
|
|
|
|
|
|
if len(req.InputPaths) < 2 {
|
|
|
|
|
|
return "", fmt.Errorf("至少需要 2 张图片才能拼接")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
direction := req.Direction
|
|
|
|
|
|
if direction == "" {
|
|
|
|
|
|
direction = "vertical"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bgColor := parseHexColor(req.BgColor, color.White)
|
|
|
|
|
|
|
|
|
|
|
|
// 加载所有图片
|
|
|
|
|
|
images := make([]image.Image, 0, len(req.InputPaths))
|
|
|
|
|
|
for _, p := range req.InputPaths {
|
|
|
|
|
|
img, err := imaging.Open(p)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("打开图片失败 %s: %v", p, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
images = append(images, img)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算拼接后尺寸
|
|
|
|
|
|
var totalW, totalH int
|
|
|
|
|
|
if direction == "vertical" {
|
|
|
|
|
|
for _, img := range images {
|
|
|
|
|
|
b := img.Bounds()
|
|
|
|
|
|
if b.Dx() > totalW {
|
|
|
|
|
|
totalW = b.Dx()
|
|
|
|
|
|
}
|
|
|
|
|
|
totalH += b.Dy()
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(images) > 1 {
|
|
|
|
|
|
totalH += req.Gap * (len(images) - 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
for _, img := range images {
|
|
|
|
|
|
b := img.Bounds()
|
|
|
|
|
|
totalW += b.Dx()
|
|
|
|
|
|
if b.Dy() > totalH {
|
|
|
|
|
|
totalH = b.Dy()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(images) > 1 {
|
|
|
|
|
|
totalW += req.Gap * (len(images) - 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建画布
|
|
|
|
|
|
canvas := image.NewRGBA(image.Rect(0, 0, totalW, totalH))
|
|
|
|
|
|
// 填充背景色
|
|
|
|
|
|
for y := 0; y < totalH; y++ {
|
|
|
|
|
|
for x := 0; x < totalW; x++ {
|
|
|
|
|
|
canvas.Set(x, y, bgColor)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 逐张绘制
|
|
|
|
|
|
offsetX, offsetY := 0, 0
|
|
|
|
|
|
for _, img := range images {
|
|
|
|
|
|
b := img.Bounds()
|
|
|
|
|
|
var dx, dy int
|
|
|
|
|
|
if direction == "vertical" {
|
|
|
|
|
|
dx = (totalW - b.Dx()) / 2
|
|
|
|
|
|
dy = offsetY
|
|
|
|
|
|
offsetY += b.Dy() + req.Gap
|
|
|
|
|
|
} else {
|
|
|
|
|
|
dx = offsetX
|
|
|
|
|
|
dy = (totalH - b.Dy()) / 2
|
|
|
|
|
|
offsetX += b.Dx() + req.Gap
|
|
|
|
|
|
}
|
|
|
|
|
|
// 绘制子图到画布
|
|
|
|
|
|
for y := 0; y < b.Dy(); y++ {
|
|
|
|
|
|
for x := 0; x < b.Dx(); x++ {
|
|
|
|
|
|
canvas.Set(dx+x, dy+y, img.At(b.Min.X+x, b.Min.Y+y))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
outputPath := req.OutputPath
|
|
|
|
|
|
if outputPath == "" {
|
|
|
|
|
|
outputPath = filepath.Join(os.TempDir(), "xk_stitch_output.png")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
f, err := os.Create(outputPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("创建输出文件失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
|
|
|
|
encoder := &png.Encoder{CompressionLevel: png.DefaultCompression}
|
|
|
|
|
|
if err := encoder.Encode(f, canvas); err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("保存拼接图片失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return outputPath, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func parseHexColor(hex string, fallback color.Color) color.Color {
|
|
|
|
|
|
hex = strings.TrimPrefix(hex, "#")
|
|
|
|
|
|
if len(hex) != 6 {
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
r, err1 := strconv.ParseUint(hex[0:2], 16, 8)
|
|
|
|
|
|
g, err2 := strconv.ParseUint(hex[2:4], 16, 8)
|
|
|
|
|
|
b, err3 := strconv.ParseUint(hex[4:6], 16, 8)
|
|
|
|
|
|
if err1 != nil || err2 != nil || err3 != nil {
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}
|
|
|
|
|
|
}
|