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 }