172 lines
5.4 KiB
Go
172 lines
5.4 KiB
Go
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 引用的本地图片读成 dataURL(path 模式渲染时用)。
|
||
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
|
||
}
|