Initial commit

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 10:47:18 +08:00
co-authored by Claude Opus 4.7
commit 79ac89b458
708 changed files with 151634 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
package imgutil
import (
"bytes"
"crypto/sha1"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
_ "image/png"
"net/url"
"path"
"path/filepath"
"strings"
"unicode"
)
const (
// CoverResizeLimitKB 与 media utils.Resize 一致:超过该大小才缩放
CoverResizeLimitKB = 60
// CoverMaxWidth 与 media Thumbnail 一致
CoverMaxWidth = 750
// CoverMaxHeight 与 media Thumbnail 一致
CoverMaxHeight = 1560
// CoverJPEGQuality 压缩后统一输出 JPEG
CoverJPEGQuality = 75
)
// IsGIF 按文件头判断是否为 GIF,不依赖扩展名。
func IsGIF(data []byte) bool {
return len(data) >= 3 && bytes.Equal(data[:3], []byte{0x47, 0x49, 0x46})
}
// NeedCompress 原图是否超过 media Resize 阈值,未超过则无需压缩重传。
// GIF 保持原格式和动效,不参与压缩。
func NeedCompress(data []byte) bool {
if IsGIF(data) {
return false
}
return int64(len(data))/1024 > CoverResizeLimitKB
}
// CompressCover 按 media Resize(60KB, 750×1560) 压缩封面。
// GIF 原样返回;其它格式未超过阈值时原样返回,超过则缩放后统一输出 JPEG。
func CompressCover(data []byte, fileName string) ([]byte, string, error) {
if len(data) == 0 {
return nil, "", fmt.Errorf("图片内容为空")
}
outName := compressFileName(fileName)
if IsGIF(data) {
return data, withExt(outName, ".gif"), nil
}
if !NeedCompress(data) {
return data, outName, nil
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, "", fmt.Errorf("解码图片失败: %w", err)
}
thumb := thumbnail(img, CoverMaxWidth, CoverMaxHeight)
var buf bytes.Buffer
if err = jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: CoverJPEGQuality}); err != nil {
return nil, "", err
}
return buf.Bytes(), withExt(outName, ".jpg"), nil
}
// SafeCoverFileName 生成仅含 ASCII 的上传文件名。
// 原封面路径含中文时,直接用 path.Base 会把中文带进上传名,图床返回地址会异常。
func SafeCoverFileName(src string) string {
src = strings.TrimSpace(src)
ext := asciiImageExt(src)
if src == "" {
return "playlet_cover" + ext
}
sum := sha1.Sum([]byte(src))
return fmt.Sprintf("playlet_cover_%x%s", sum[:8], ext)
}
func thumbnail(img image.Image, maxW, maxH int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxW && h <= maxH {
return img
}
wr := float64(maxW) / float64(w)
hr := float64(maxH) / float64(h)
scale := wr
if hr < wr {
scale = hr
}
nw := int(float64(w) * scale)
nh := int(float64(h) * scale)
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
for y := 0; y < nh; y++ {
sy := b.Min.Y + y*h/nh
for x := 0; x < nw; x++ {
sx := b.Min.X + x*w/nw
dst.Set(x, y, img.At(sx, sy))
}
}
return dst
}
func compressFileName(fileName string) string {
fileName = filepath.Base(strings.TrimSpace(fileName))
if fileName == "" || fileName == "." || fileName == string(filepath.Separator) || !isASCIIFileName(fileName) {
return "playlet_cover.jpg"
}
return fileName
}
func asciiImageExt(src string) string {
name := src
if u, err := url.Parse(src); err == nil && u.Path != "" {
name = u.Path
}
name = strings.Split(path.Base(name), "?")[0]
if dec, err := url.PathUnescape(name); err == nil {
name = dec
}
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
return ext
default:
return ".jpg"
}
}
func isASCIIFileName(name string) bool {
if !strings.Contains(name, ".") {
return false
}
for _, r := range name {
if r > unicode.MaxASCII || r < 32 || r == '"' || r == '/' || r == '\\' {
return false
}
}
return true
}
func withExt(fileName, ext string) string {
return strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ext
}