1. 图片/视频查看重新实现,保持与微信一致

2. 增加软件信息页面
3. 修复企业联系人头像和名称不显示问题
4. 修复撤回消息显示不正常的问题
This commit is contained in:
HAL 2024-12-14 14:56:45 +08:00
parent 30ccdd6c65
commit d34225785c
14 changed files with 874 additions and 1031 deletions

View File

@ -9,6 +9,7 @@ PC微信聊天记录数据导出工具
效果图如下: 效果图如下:
![](./res/result.png) ![](./res/result.png)
![](./res/result2.png)
## 演示视频 ## 演示视频
[演示视频](https://www.bilibili.com/video/BV1bPH1eWEEy/?share_source=copy_web&vd_source=b5cfa9258a9ad9900a00e9c1ce3cb4b6) [演示视频](https://www.bilibili.com/video/BV1bPH1eWEEy/?share_source=copy_web&vd_source=b5cfa9258a9ad9900a00e9c1ce3cb4b6)
@ -47,7 +48,7 @@ wails build
- [x] 多开账号数据切换 - [x] 多开账号数据切换
- [x] 头像使用本地头像 - [x] 头像使用本地头像
- [ ] 支持更多消息类型显示 - [ ] 支持更多消息类型显示
- [ ] 图片查看器重绘 - [x] 图片查看器重绘
- [ ] 实现表情预先下载(实现完全离线查看) - [ ] 实现表情预先下载(实现完全离线查看)
- [ ] 聊天报告 - [ ] 聊天报告
- [ ] AI本地模型应用 - [ ] AI本地模型应用
@ -64,6 +65,10 @@ A: 这是由于可能数据存在于内存中还没有回写到磁盘导致的
**Q: 有些图片、视频打不开**<br> **Q: 有些图片、视频打不开**<br>
A: 这是电脑端微信没有点开过这个消息,默认只加载了预览图而已,如果手机有打开过可以把手机的记录迁移到电脑,迁移后重新退出登陆一次微信导出即可。<br> A: 这是电脑端微信没有点开过这个消息,默认只加载了预览图而已,如果手机有打开过可以把手机的记录迁移到电脑,迁移后重新退出登陆一次微信导出即可。<br>
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=git-jiadong/wechatDataBackup&type=Date)](https://star-history.com/?utm_source=bestxtools.com#git-jiadong/wechatDataBackup&Date)
## 免责声明 ## 免责声明
**⚠️ 本项目仅供学习、研究使用,严禁商业使用**<br/> **⚠️ 本项目仅供学习、研究使用,严禁商业使用**<br/>
**⚠️ 用于网络安全用途的,请确保在国家法律法规下使用**<br/> **⚠️ 用于网络安全用途的,请确保在国家法律法规下使用**<br/>

141
app.go
View File

@ -5,9 +5,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"mime"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"wechatDataBackup/pkg/utils" "wechatDataBackup/pkg/utils"
"wechatDataBackup/pkg/wechat" "wechatDataBackup/pkg/wechat"
@ -21,7 +23,7 @@ const (
configDefaultUserKey = "userConfig.defaultUser" configDefaultUserKey = "userConfig.defaultUser"
configUsersKey = "userConfig.users" configUsersKey = "userConfig.users"
configExportPathKey = "exportPath" configExportPathKey = "exportPath"
appVersion = "v1.0.5" appVersion = "v1.0.6"
) )
type FileLoader struct { type FileLoader struct {
@ -30,6 +32,7 @@ type FileLoader struct {
} }
func NewFileLoader(prefix string) *FileLoader { func NewFileLoader(prefix string) *FileLoader {
mime.AddExtensionType(".mp3", "audio/mpeg")
return &FileLoader{FilePrefix: prefix} return &FileLoader{FilePrefix: prefix}
} }
@ -39,16 +42,74 @@ func (h *FileLoader) SetFilePrefix(prefix string) {
} }
func (h *FileLoader) ServeHTTP(res http.ResponseWriter, req *http.Request) { func (h *FileLoader) ServeHTTP(res http.ResponseWriter, req *http.Request) {
var err error
requestedFilename := h.FilePrefix + "\\" + strings.TrimPrefix(req.URL.Path, "/") requestedFilename := h.FilePrefix + "\\" + strings.TrimPrefix(req.URL.Path, "/")
// log.Println("Requesting file:", requestedFilename)
fileData, err := os.ReadFile(requestedFilename) file, err := os.Open(requestedFilename)
if err != nil { if err != nil {
res.WriteHeader(http.StatusBadRequest) http.Error(res, fmt.Sprintf("Could not load file %s", requestedFilename), http.StatusBadRequest)
res.Write([]byte(fmt.Sprintf("Could not load file %s", requestedFilename))) return
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
http.Error(res, "Could not retrieve file info", http.StatusInternalServerError)
return
} }
res.Write(fileData) fileSize := fileInfo.Size()
rangeHeader := req.Header.Get("Range")
if rangeHeader == "" {
// 无 Range 请求,直接返回整个文件
res.Header().Set("Content-Length", strconv.FormatInt(fileSize, 10))
http.ServeContent(res, req, requestedFilename, fileInfo.ModTime(), file)
return
}
var start, end int64
if strings.HasPrefix(rangeHeader, "bytes=") {
ranges := strings.Split(strings.TrimPrefix(rangeHeader, "bytes="), "-")
start, _ = strconv.ParseInt(ranges[0], 10, 64)
if len(ranges) > 1 && ranges[1] != "" {
end, _ = strconv.ParseInt(ranges[1], 10, 64)
} else {
end = fileSize - 1
}
} else {
http.Error(res, "Invalid Range header", http.StatusRequestedRangeNotSatisfiable)
return
}
if start < 0 || end >= fileSize || start > end {
http.Error(res, "Requested range not satisfiable", http.StatusRequestedRangeNotSatisfiable)
return
}
contentType := mime.TypeByExtension(filepath.Ext(requestedFilename))
if contentType == "" {
contentType = "application/octet-stream"
}
res.Header().Set("Content-Type", contentType)
res.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize))
res.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10))
res.WriteHeader(http.StatusPartialContent)
buffer := make([]byte, 102400)
file.Seek(start, 0)
for current := start; current <= end; {
readSize := int64(len(buffer))
if end-current+1 < readSize {
readSize = end - current + 1
}
n, err := file.Read(buffer[:readSize])
if err != nil {
break
}
res.Write(buffer[:n])
current += int64(n)
}
} }
// App struct // App struct
@ -90,7 +151,7 @@ type ErrorMessage struct {
// NewApp creates a new App application struct // NewApp creates a new App application struct
func NewApp() *App { func NewApp() *App {
a := &App{} a := &App{}
log.Println("App version:", appVersion)
a.firstInit = true a.firstInit = true
a.FLoader = NewFileLoader(".\\") a.FLoader = NewFileLoader(".\\")
viper.SetConfigName(defaultConfig) viper.SetConfigName(defaultConfig)
@ -306,7 +367,7 @@ func (a *App) GetWechatContactList(pageIndex int, pageSize int) string {
} }
func (a *App) GetWechatMessageListByTime(userName string, time int64, pageSize int, direction string) string { func (a *App) GetWechatMessageListByTime(userName string, time int64, pageSize int, direction string) string {
log.Println("GetWechatMessageList:", userName, pageSize, time, direction) log.Println("GetWechatMessageListByTime:", userName, pageSize, time, direction)
if len(userName) == 0 { if len(userName) == 0 {
return "{\"Total\":0, \"Rows\":[]}" return "{\"Total\":0, \"Rows\":[]}"
} }
@ -318,11 +379,33 @@ func (a *App) GetWechatMessageListByTime(userName string, time int64, pageSize i
} }
list, err := a.provider.WeChatGetMessageListByTime(userName, time, pageSize, dire) list, err := a.provider.WeChatGetMessageListByTime(userName, time, pageSize, dire)
if err != nil { if err != nil {
log.Println("WeChatGetMessageList failed:", err) log.Println("GetWechatMessageListByTime failed:", err)
return "" return ""
} }
listStr, _ := json.Marshal(list) listStr, _ := json.Marshal(list)
log.Println("GetWechatMessageList:", list.Total) log.Println("GetWechatMessageListByTime:", list.Total)
return string(listStr)
}
func (a *App) GetWechatMessageListByType(userName string, time int64, pageSize int, msgType string, direction string) string {
log.Println("GetWechatMessageListByType:", userName, pageSize, time, msgType, direction)
if len(userName) == 0 {
return "{\"Total\":0, \"Rows\":[]}"
}
dire := wechat.Message_Search_Forward
if direction == "backward" {
dire = wechat.Message_Search_Backward
} else if direction == "both" {
dire = wechat.Message_Search_Both
}
list, err := a.provider.WeChatGetMessageListByType(userName, time, pageSize, msgType, dire)
if err != nil {
log.Println("WeChatGetMessageListByType failed:", err)
return ""
}
listStr, _ := json.Marshal(list)
log.Println("WeChatGetMessageListByType:", list.Total)
return string(listStr) return string(listStr)
} }
@ -594,3 +677,39 @@ func (a *App) scanAccountByPath(path string) error {
func (a *App) OepnLogFileExplorer() { func (a *App) OepnLogFileExplorer() {
utils.OpenFileOrExplorer(".\\app.log", true) utils.OpenFileOrExplorer(".\\app.log", true)
} }
func (a *App) SaveFileDialog(file string, alisa string) string {
filePath := a.FLoader.FilePrefix + file
if _, err := os.Stat(filePath); err != nil {
log.Println("SaveFileDialog:", err)
return err.Error()
}
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultFilename: alisa,
Title: "选择保存路径",
})
if err != nil {
log.Println("SaveFileDialog:", err)
return err.Error()
}
if savePath == "" {
return ""
}
dirPath := filepath.Dir(savePath)
if !utils.PathIsCanWriteFile(dirPath) {
errStr := "Path Is Can't Write File: " + filepath.Dir(savePath)
log.Println(errStr)
return errStr
}
_, err = utils.CopyFile(filePath, savePath)
if err != nil {
log.Println("Error CopyFile", filePath, savePath, err)
return err.Error()
}
return ""
}

View File

@ -1,3 +1,9 @@
## v1.0.6
1. 图片/视频查看重新实现,保持与微信一致
2. 增加软件信息页面
3. 修复企业联系人头像和名称不显示问题
4. 修复撤回消息显示不正常的问题
## v1.0.5 ## v1.0.5
1. 修复联系人搜索显示异常的问题 1. 修复联系人搜索显示异常的问题
2. 修复启动时界面加载显示慢的问题 2. 修复启动时界面加载显示慢的问题

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

533
frontend/dist/assets/index.d5e97187.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>wechatDataBackup</title> <title>wechatDataBackup</title>
<script type="module" crossorigin src="/assets/index.5130ac24.js"></script> <script type="module" crossorigin src="/assets/index.d5e97187.js"></script>
<link rel="stylesheet" href="/assets/index.f70722f4.css"> <link rel="stylesheet" href="/assets/index.a11c4643.css">
</head> </head>
<body > <body >
<div id="root"></div> <div id="root"></div>

View File

@ -1,7 +1,9 @@
package utils package utils
import ( import (
"errors"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"os/exec" "os/exec"
@ -120,3 +122,31 @@ func PathIsCanWriteFile(path string) bool {
return true return true
} }
func CopyFile(src, dst string) (int64, error) {
stat, err := os.Stat(src)
if err != nil {
return 0, err
}
if stat.IsDir() {
return 0, errors.New(src + " is dir")
}
sourceFile, err := os.Open(src)
if err != nil {
return 0, err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return 0, err
}
defer destFile.Close()
bytesWritten, err := io.Copy(destFile, sourceFile)
if err != nil {
return bytesWritten, err
}
return bytesWritten, nil
}

View File

@ -180,7 +180,7 @@ type WechatDataProvider struct {
resPath string resPath string
prefixResPath string prefixResPath string
microMsg *sql.DB microMsg *sql.DB
openIMContact *sql.DB
msgDBs []*wechatMsgDB msgDBs []*wechatMsgDB
userInfoMap map[string]WeChatUserInfo userInfoMap map[string]WeChatUserInfo
userInfoMtx sync.Mutex userInfoMtx sync.Mutex
@ -191,6 +191,7 @@ type WechatDataProvider struct {
const ( const (
MicroMsgDB = "MicroMsg.db" MicroMsgDB = "MicroMsg.db"
OpenIMContactDB = "OpenIMContact.db"
) )
type byTime []*wechatMsgDB type byTime []*wechatMsgDB
@ -239,6 +240,15 @@ func CreateWechatDataProvider(resPath string, prefixRes string) (*WechatDataProv
return provider, err return provider, err
} }
var openIMContact *sql.DB
OpenIMContactDBPath := resPath + "\\Msg\\" + OpenIMContactDB
if _, err := os.Stat(OpenIMContactDBPath); err == nil {
openIMContact, err = sql.Open("sqlite3", OpenIMContactDBPath)
if err != nil {
log.Printf("open db %s error: %v", OpenIMContactDBPath, err)
}
}
index := 0 index := 0
for { for {
msgDBPath := fmt.Sprintf("%s\\Msg\\Multi\\MSG%d.db", provider.resPath, index) msgDBPath := fmt.Sprintf("%s\\Msg\\Multi\\MSG%d.db", provider.resPath, index)
@ -262,6 +272,7 @@ func CreateWechatDataProvider(resPath string, prefixRes string) (*WechatDataProv
} }
provider.userInfoMap = make(map[string]WeChatUserInfo) provider.userInfoMap = make(map[string]WeChatUserInfo)
provider.microMsg = microMsg provider.microMsg = microMsg
provider.openIMContact = openIMContact
provider.SelfInfo, err = provider.WechatGetUserInfoByNameOnCache(userName) provider.SelfInfo, err = provider.WechatGetUserInfoByNameOnCache(userName)
if err != nil { if err != nil {
log.Printf("WechatGetUserInfoByName %s failed: %v", userName, err) log.Printf("WechatGetUserInfoByName %s failed: %v", userName, err)
@ -288,6 +299,13 @@ func (P *WechatDataProvider) WechatWechatDataProviderClose() {
} }
} }
if P.openIMContact != nil {
err := P.openIMContact.Close()
if err != nil {
log.Println("db close:", err)
}
}
for _, db := range P.msgDBs { for _, db := range P.msgDBs {
err := db.db.Close() err := db.db.Close()
if err != nil { if err != nil {
@ -336,6 +354,47 @@ func (P *WechatDataProvider) WechatGetUserInfoByName(name string) (*WeChatUserIn
return info, nil return info, nil
} }
func (P *WechatDataProvider) WechatGetOpenIMMUserInfoByName(name string) (*WeChatUserInfo, error) {
info := &WeChatUserInfo{}
var UserName, ReMark, NickName string
querySql := fmt.Sprintf("select ifnull(UserName,'') as UserName, ifnull(ReMark,'') as ReMark, ifnull(NickName,'') as NickName from OpenIMContact where UserName='%s';", name)
// log.Println(querySql)
if P.openIMContact != nil {
err := P.openIMContact.QueryRow(querySql).Scan(&UserName, &ReMark, &NickName)
if err != nil {
log.Println("not found User:", err)
return info, err
}
}
log.Printf("UserName %s, ReMark %s, NickName %s\n", UserName, ReMark, NickName)
var smallHeadImgUrl, bigHeadImgUrl string
querySql = fmt.Sprintf("select ifnull(smallHeadImgUrl,'') as smallHeadImgUrl, ifnull(bigHeadImgUrl,'') as bigHeadImgUrl from ContactHeadImgUrl where usrName='%s';", UserName)
// log.Println(querySql)
err := P.microMsg.QueryRow(querySql).Scan(&smallHeadImgUrl, &bigHeadImgUrl)
if err != nil {
log.Println("not find headimg", err)
}
info.UserName = UserName
info.Alias = ""
info.ReMark = ReMark
info.NickName = NickName
info.SmallHeadImgUrl = smallHeadImgUrl
info.BigHeadImgUrl = bigHeadImgUrl
info.IsGroup = strings.HasSuffix(UserName, "@chatroom")
localHeadImgPath := fmt.Sprintf("%s\\FileStorage\\HeadImage\\%s.headimg", P.resPath, name)
relativePath := fmt.Sprintf("%s\\FileStorage\\HeadImage\\%s.headimg", P.prefixResPath, name)
if _, err = os.Stat(localHeadImgPath); err == nil {
info.LocalHeadImgUrl = relativePath
}
// log.Println(info)
return info, nil
}
func (P *WechatDataProvider) WeChatGetSessionList(pageIndex int, pageSize int) (*WeChatSessionList, error) { func (P *WechatDataProvider) WeChatGetSessionList(pageIndex int, pageSize int) (*WeChatSessionList, error) {
List := &WeChatSessionList{} List := &WeChatSessionList{}
List.Rows = make([]WeChatSession, 0) List.Rows = make([]WeChatSession, 0)
@ -364,7 +423,7 @@ func (P *WechatDataProvider) WeChatGetSessionList(pageIndex int, pageSize int) (
session.UserName = strUsrName session.UserName = strUsrName
session.NickName = strNickName session.NickName = strNickName
session.Content = strContent session.Content = revokemsg_parse(strContent)
session.Time = nTime session.Time = nTime
session.IsGroup = strings.HasSuffix(strUsrName, "@chatroom") session.IsGroup = strings.HasSuffix(strUsrName, "@chatroom")
info, err := P.WechatGetUserInfoByNameOnCache(strUsrName) info, err := P.WechatGetUserInfoByNameOnCache(strUsrName)
@ -502,7 +561,7 @@ func (P *WechatDataProvider) weChatGetMessageListByTime(userName string, time in
message.IsSender = IsSender message.IsSender = IsSender
message.CreateTime = CreateTime message.CreateTime = CreateTime
message.Talker = StrTalker message.Talker = StrTalker
message.Content = StrContent message.Content = revokemsg_parse(StrContent)
message.IsChatRoom = strings.HasSuffix(StrTalker, "@chatroom") message.IsChatRoom = strings.HasSuffix(StrTalker, "@chatroom")
message.compressContent = make([]byte, len(CompressContent)) message.compressContent = make([]byte, len(CompressContent))
message.bytesExtra = make([]byte, len(BytesExtra)) message.bytesExtra = make([]byte, len(BytesExtra))
@ -564,6 +623,87 @@ func (P *WechatDataProvider) WeChatGetMessageListByKeyWord(userName string, time
return List, nil return List, nil
} }
func (P *WechatDataProvider) WeChatGetMessageListByType(userName string, time int64, pageSize int, msgType string, direction Message_Search_Direction) (*WeChatMessageList, error) {
List := &WeChatMessageList{}
List.Rows = make([]WeChatMessage, 0)
selectTime := time
selectpageSize := 30
needSize := pageSize
if msgType != "" {
selectpageSize = 600
}
if direction == Message_Search_Both {
needSize = pageSize / 2
}
for direction == Message_Search_Forward || direction == Message_Search_Both {
selectList, err := P.weChatGetMessageListByTime(userName, selectTime, selectpageSize, Message_Search_Forward)
if err != nil {
return List, err
}
if selectList.Total == 0 {
break
}
for i, _ := range selectList.Rows {
if weChatMessageTypeFilter(&selectList.Rows[i], msgType) {
List.Rows = append(List.Rows, selectList.Rows[i])
List.Total += 1
needSize -= 1
if needSize <= 0 {
break
}
}
}
if needSize <= 0 {
break
}
selectTime = selectList.Rows[selectList.Total-1].CreateTime - 1
log.Printf("Forward selectTime %d, selectpageSize %d needSize %d\n", selectTime, selectpageSize, needSize)
}
selectTime = time
if direction == Message_Search_Both {
needSize = pageSize / 2
}
for direction == Message_Search_Backward || direction == Message_Search_Both {
selectList, err := P.weChatGetMessageListByTime(userName, selectTime, selectpageSize, Message_Search_Backward)
if err != nil {
return List, err
}
if selectList.Total == 0 {
break
}
tmpTotal := 0
tmpRows := make([]WeChatMessage, 0)
for i := selectList.Total - 1; i >= 0; i-- {
if weChatMessageTypeFilter(&selectList.Rows[i], msgType) {
tmpRows = append([]WeChatMessage{selectList.Rows[i]}, tmpRows...)
tmpTotal += 1
needSize -= 1
if needSize <= 0 {
break
}
}
}
if tmpTotal > 0 {
List.Rows = append(tmpRows, List.Rows...)
List.Total += tmpTotal
}
selectTime = selectList.Rows[0].CreateTime + 1
if needSize <= 0 {
break
}
log.Printf("Backward selectTime %d, selectpageSize %d needSize %d\n", selectTime, selectpageSize, needSize)
}
return List, nil
}
func (P *WechatDataProvider) WeChatGetMessageDate(userName string) (*WeChatMessageDate, error) { func (P *WechatDataProvider) WeChatGetMessageDate(userName string) (*WeChatMessageDate, error) {
messageData := &WeChatMessageDate{} messageData := &WeChatMessageDate{}
messageData.Date = make([]string, 0) messageData.Date = make([]string, 0)
@ -661,7 +801,7 @@ func (P *WechatDataProvider) wechatMessageExtraHandle(msg *WeChatMessage) {
switch ext.Field1 { switch ext.Field1 {
case 1: case 1:
if msg.IsChatRoom { if msg.IsChatRoom {
msg.Talker = ext.Field2 msg.UserInfo.UserName = ext.Field2
} }
case 3: case 3:
if len(ext.Field2) > 0 && (msg.Type == Wechat_Message_Type_Picture || msg.Type == Wechat_Message_Type_Video || msg.Type == Wechat_Message_Type_Misc) { if len(ext.Field2) > 0 && (msg.Type == Wechat_Message_Type_Picture || msg.Type == Wechat_Message_Type_Video || msg.Type == Wechat_Message_Type_Misc) {
@ -784,6 +924,8 @@ func (P *WechatDataProvider) wechatMessageGetUserInfo(msg *WeChatMessage) {
who := msg.Talker who := msg.Talker
if msg.IsSender == 1 { if msg.IsSender == 1 {
who = P.SelfInfo.UserName who = P.SelfInfo.UserName
} else if msg.IsChatRoom {
who = msg.UserInfo.UserName
} }
pinfo, err := P.WechatGetUserInfoByNameOnCache(who) pinfo, err := P.WechatGetUserInfoByNameOnCache(who)
@ -963,7 +1105,13 @@ func (P *WechatDataProvider) WechatGetUserInfoByNameOnCache(name string) (*WeCha
return &info, nil return &info, nil
} }
pinfo, err := P.WechatGetUserInfoByName(name) var pinfo *WeChatUserInfo
var err error
if strings.HasSuffix(name, "@openim") {
pinfo, err = P.WechatGetOpenIMMUserInfoByName(name)
} else {
pinfo, err = P.WechatGetUserInfoByName(name)
}
if err != nil { if err != nil {
log.Printf("WechatGetUserInfoByName %s failed: %v\n", name, err) log.Printf("WechatGetUserInfoByName %s failed: %v\n", name, err)
return nil, err return nil, err
@ -1067,3 +1215,12 @@ func WechatGetAccountInfo(resPath, prefixRes, accountName string) (*WeChatAccoun
// log.Println(info) // log.Println(info)
return info, nil return info, nil
} }
func revokemsg_parse(content string) string {
if strings.HasPrefix(content, "<revokemsg>") && strings.HasSuffix(content, "</revokemsg>") {
trimmed := strings.TrimSuffix(strings.TrimPrefix(content, "<revokemsg>"), "</revokemsg>")
return trimmed
}
return content
}

BIN
res/result2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB