//go:build windows package main import ( "bytes" "encoding/base64" "encoding/binary" "fmt" "image/png" "runtime" "syscall" "time" "unsafe" "golang.org/x/image/bmp" "golang.org/x/sys/windows" ) // binaryLE DIB/BMP 头字段均为小端 var binaryLE = binary.LittleEndian var ( procRegisterClipboardFormatW = windows.NewLazySystemDLL("user32.dll").NewProc("RegisterClipboardFormatW") procGlobalLockImg = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalLock") procGlobalUnlockImg = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalUnlock") procGlobalSizeImg = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalSize") ) // cfDIB 剪贴板设备无关位图格式,Win32 稳定 ABI 值 const cfDIB = 8 // pngFormatAtom "PNG" 注册格式的剪贴板原子号(系统级稳定,进程内缓存一次) var pngFormatAtom uintptr // ReadClipboardImage 读取系统剪贴板中的图片,返回 PNG 的 base64(无图返回空串+nil)。 // 优先取 "PNG" 注册格式(截图工具/浏览器复制时放的就是完整 PNG 文件字节), // 否则回落 CF_DIB:补 14 字节 BMP 文件头后交 x/image/bmp 解码再重编为 PNG。 // 供前端 Ctrl+V keydown 直读(WebView2 无编辑焦点时 DOM paste 事件不可靠派发)。 func (a *App) ReadClipboardImage() (string, error) { // OpenClipboard 与 CloseClipboard 必须同线程,否则剪贴板死锁 runtime.LockOSThread() defer runtime.UnlockOSThread() if pngFormatAtom == 0 { name, err := syscall.UTF16PtrFromString("PNG") if err != nil { return "", err } if atom, _, _ := procRegisterClipboardFormatW.Call(uintptr(unsafe.Pointer(name))); atom != 0 { pngFormatAtom = atom } } // 剪贴板是全局互斥资源,短重试避开其他进程的瞬时占用 opened := false for i := 0; i < 20; i++ { if r, _, _ := procOpenClipboard.Call(0); r != 0 { opened = true break } time.Sleep(10 * time.Millisecond) } if !opened { return "", syscall.EBUSY } defer procCloseClipboard.Call() if pngFormatAtom != 0 { if r, _, _ := procIsClipboardFormatAvailable.Call(pngFormatAtom); r != 0 { data, ok, err := clipboardFormatData(pngFormatAtom) if err != nil { return "", err } if ok && len(data) > 0 { return base64.StdEncoding.EncodeToString(data), nil } } } if r, _, _ := procIsClipboardFormatAvailable.Call(cfDIB); r == 0 { return "", nil } data, ok, err := clipboardFormatData(cfDIB) if err != nil { return "", err } if !ok || len(data) == 0 { return "", nil } pngBytes, err := dibToPNG(data) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(pngBytes), nil } // clipboardFormatData 取指定格式的剪贴板字节(调用方须已 OpenClipboard) func clipboardFormatData(format uintptr) ([]byte, bool, error) { h, _, _ := procGetClipboardData.Call(format) if h == 0 { return nil, false, nil } p, _, _ := procGlobalLockImg.Call(h) if p == 0 { return nil, false, syscall.EBUSY } defer procGlobalUnlockImg.Call(h) size, _, _ := procGlobalSizeImg.Call(h) if size == 0 { return nil, false, nil } data := make([]byte, size) copy(data, unsafe.Slice((*byte)(unsafe.Pointer(p)), size)) return data, true, nil } // dibToPNG 把 CF_DIB(BITMAPINFO 起、无 14 字节文件头)转为 PNG 字节。 // 覆盖 24/32bpp 的 BI_RGB(0) 与 BI_BITFIELDS(3) 常见情形:DIB 原样透传, // 仅补 BMP 文件头后交给 x/image/bmp 解码(行字节对齐、bottom-up、负高度=top-down 均由解码器处理) func dibToPNG(dib []byte) ([]byte, error) { if len(dib) < 40 { return nil, fmt.Errorf("DIB 数据过短: %d 字节", len(dib)) } headerSize := binaryLE.Uint32(dib[0:4]) bpp := binaryLE.Uint16(dib[14:16]) compression := binaryLE.Uint32(dib[16:20]) if headerSize < 40 || int(headerSize) > len(dib) { return nil, fmt.Errorf("不支持的 DIB 头大小: %d", headerSize) } if bpp != 24 && bpp != 32 { return nil, fmt.Errorf("不支持的位深: %d", bpp) } if compression != 0 && compression != 3 { // BI_RGB=0, BI_BITFIELDS=3 return nil, fmt.Errorf("不支持的压缩格式: %d", compression) } // 像素数据偏移 = 14 字节文件头 + 信息头;BI_BITFIELDS 且 40 字节头时 // 3 个掩码 DWORD 紧随其后(V4/V5 头 ≥108 字节的掩码内嵌在头里,无需外加) pixelOffset := 14 + int(headerSize) if compression == 3 && headerSize == 40 { pixelOffset += 12 } bmpBuf := make([]byte, 14+len(dib)) bmpBuf[0], bmpBuf[1] = 'B', 'M' binaryLE.PutUint32(bmpBuf[2:6], uint32(len(bmpBuf))) // 6..9 保留位保持 0 binaryLE.PutUint32(bmpBuf[10:14], uint32(pixelOffset)) copy(bmpBuf[14:], dib) img, err := bmp.Decode(bytes.NewReader(bmpBuf)) if err != nil { return nil, fmt.Errorf("DIB 解码失败: %w", err) } var out bytes.Buffer if err := png.Encode(&out, img); err != nil { return nil, fmt.Errorf("PNG 编码失败: %w", err) } return out.Bytes(), nil }