Files
code-utils/images.go

172 lines
5.4 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
package main
// images.go 处理待办/工单 Markdown 内容里的图片:
// 粘贴或选择的图片统一解码、缩放(最长边 contentImgMaxEdge后按设置存储 ——
// base64 模式返回内嵌 dataURL随内容同步到云端path 模式写入数据目录 images/ 并返回路径(仅本机可见),
// server 模式上传到 nl-pms-api 返回 http URL跨设备/团队可访问,见 fileapi.go
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
const (
contentImgMaxEdge = 1100 // 内容图最长边,兼顾清晰度与同步体积
contentImgMaxBytes = 20 << 20 // 原始输入上限 20MB
contentJPEGQuality = 82
)
// SaveContentImage 处理编辑器里粘贴的图片dataURL返回可直接写进 Markdown 的图片地址。
func (a *App) SaveContentImage(dataURL string) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
raw, e := dataURLBytes(dataURL)
if e != nil {
return "", e
}
return a.storeContentImage(raw)
}
// PickContentImage 打开图片选择框,处理后返回 Markdown 图片地址;用户取消时返回空串。
func (a *App) PickContentImage() (string, error) {
if e := a.ready(); e != nil {
return "", e
}
p, e := application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择要插入的图片", "Choose image to insert")).
AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp").
PromptForSingleSelection()
if e != nil || strings.TrimSpace(p) == "" {
return "", e
}
info, e := os.Stat(p)
if e != nil {
return "", errors.New("FILE_NOT_FOUND")
}
if info.Size() > contentImgMaxBytes {
return "", errors.New("IMAGE_FILE_TOO_LARGE")
}
raw, e := os.ReadFile(p)
if e != nil {
return "", errors.New("FILE_READ_FAILED")
}
return a.storeContentImage(raw)
}
// ReadContentImageAsDataURL 把 Markdown 引用的本地图片读成 dataURLpath 模式渲染时用)。
func (a *App) ReadContentImageAsDataURL(path string) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
info, e := os.Stat(path)
if e != nil {
return "", errors.New("FILE_NOT_FOUND")
}
if info.Size() > contentImgMaxBytes {
return "", errors.New("IMAGE_FILE_TOO_LARGE")
}
raw, e := os.ReadFile(path)
if e != nil {
return "", errors.New("FILE_READ_FAILED")
}
data, mime, e := encodeContentImage(raw)
if e != nil {
return "", e
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
// storeContentImage 缩放压缩后按设置存储server 模式上传换 http URL
// path 模式写入数据目录返回正斜杠路径,默认返回内嵌 dataURL。
func (a *App) storeContentImage(raw []byte) (string, error) {
data, mime, e := encodeContentImage(raw)
if e != nil {
return "", e
}
// 存储走向由管理员的全局配置决定上传时实时获取server 一律上传;
// local 下沿用本机 imageMode老用户的 path 数据继续可用),默认 base64。
if a.currentFileStorage().Mode == "server" {
return a.uploadImageToServer(data, mime, "content")
}
s, err := a.store.Settings()
if err == nil && s.ImageMode == "path" {
dir := filepath.Join(filepath.Dir(a.store.path), "images")
if e := os.MkdirAll(dir, 0755); e != nil {
return "", errors.New("IMAGE_SAVE_FAILED")
}
ext := ".jpg"
if mime == "image/png" {
ext = ".png"
}
p := filepath.Join(dir, fmt.Sprintf("img-%d%s", time.Now().UnixNano(), ext))
if e := os.WriteFile(p, data, 0644); e != nil {
return "", errors.New("IMAGE_SAVE_FAILED")
}
// Markdown 里用正斜杠路径,避免反斜杠被当成转义。
return filepath.ToSlash(p), nil
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
// encodeContentImage 解码任意支持格式,缩放后重新编码:
// 不透明图输出 JPEG体积小带透明通道的输出 PNG。
func encodeContentImage(raw []byte) ([]byte, string, error) {
return encodeImageEdge(raw, contentImgMaxEdge)
}
// encodeImageEdge 同上,但可指定最长边(节日背景等场景用更小尺寸控制体积)。
func encodeImageEdge(raw []byte, maxEdge int) ([]byte, string, error) {
src, _, e := image.Decode(bytes.NewReader(raw))
if e != nil {
return nil, "", errors.New("IMAGE_DECODE_FAILED")
}
img := downscaleImage(src, maxEdge)
var buf bytes.Buffer
if imageOpaque(img) {
if e = jpeg.Encode(&buf, img, &jpeg.Options{Quality: contentJPEGQuality}); e != nil {
return nil, "", errors.New("IMAGE_DECODE_FAILED")
}
return buf.Bytes(), "image/jpeg", nil
}
if e = png.Encode(&buf, img); e != nil {
return nil, "", errors.New("IMAGE_DECODE_FAILED")
}
return buf.Bytes(), "image/png", nil
}
// imageOpaque 判断图片是否完全不透明;无法判断时按含透明处理(走 PNG
func imageOpaque(img image.Image) bool {
if o, ok := img.(interface{ Opaque() bool }); ok {
return o.Opaque()
}
return false
}
// dataURLBytes 解出 dataURL 里的原始字节。
func dataURLBytes(s string) ([]byte, error) {
i := strings.Index(s, ";base64,")
if !strings.HasPrefix(s, "data:image/") || i < 0 {
return nil, errors.New("IMAGE_DECODE_FAILED")
}
if len(s)-i > contentImgMaxBytes {
return nil, errors.New("IMAGE_FILE_TOO_LARGE")
}
raw, e := base64.StdEncoding.DecodeString(s[i+8:])
if e != nil {
return nil, errors.New("IMAGE_DECODE_FAILED")
}
return raw, nil
}