diff --git a/.gitignore b/.gitignore index 129d522..b3263c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ build/bin node_modules frontend/dist +/.opencode/ +/.mimocode/ +/.qoder/ diff --git a/README.md b/README.md index d27aaee..5d15584 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,114 @@ -# README +# 年糕工具 -## About +一站式文件处理工具箱,支持 PDF、Word、Excel、图片的格式转换、编辑和处理。 -This is the official Wails Vue template. +## 功能特性 -You can configure the project by editing `wails.json`. More information about the project settings can be found -here: https://wails.io/docs/reference/project-config +### PDF 工具 +- PDF 瘦身(压缩优化) +- PDF 转图片 +- PDF 转 Word / Excel / HTML / Markdown +- PDF 提取文本 +- PDF 合并 +- PDF 分割(按页码范围) +- PDF 旋转 +- PDF 添加水印 +- 文本 / Markdown / HTML 转 PDF -## Live Development +### 图片工具 +- 图片格式转换(JPEG / PNG / GIF / BMP / TIFF) +- 图片压缩(预设:高质量 / 中等 / 小文件 / 极小) +- 图片调整大小 +- 图片旋转 +- 图片裁剪 +- 灰度处理 +- 亮度 / 对比度 / 饱和度调节 +- 锐化 / 模糊 / 反色 +- 背景去除 +- 图片转 PDF -To run in live development mode, run `wails dev` in the project directory. This will run a Vite development -server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser -and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect -to this in your browser, and you can call your Go code from devtools. +### 其他 +- Word 转 PDF +- Excel 转 CSV / JSON +- 运行日志(支持日期筛选) -## Building +## 技术栈 -To build a redistributable, production mode package, use `wails build`. +- **后端**: Go + Wails v2 +- **前端**: Vue 3 + Element Plus + Vite +- **PDF 处理**: pdfcpu(纯 Go) +- **图片处理**: imaging +- **文本渲染**: gopdf + +## 开发环境 + +### 前置条件 + +- Go 1.25+ +- Node.js +- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest` + +### 开发模式 + +```bash +wails dev +``` + +### 打包构建 + +```bash +.\build.ps1 +``` + +构建产物位于 `build\bin\年糕工具.exe`(单文件,MuPDF DLL 已嵌入)。 + +### 手动构建 + +```powershell +# 1. 生成绑定(nofitz 避免 DLL 依赖检查) +wails build -tags nofitz + +# 2. 复制 DLL 到构建目录(嵌入用) +Copy-Item public\MuPDFLib.dll build\bin\libmupdf.dll + +# 3. 最终构建(skipbindings 跳过重新生成) +wails build -skipbindings + +# 4. 重命名 +Copy-Item build\bin\xk.exe "build\bin\年糕工具.exe" +``` + +## 项目结构 + +``` +├── main.go # 应用入口 +├── app.go # App 生命周期 +├── handlers.go # 文件处理 handler(IPC 接口) +├── services/ +│ ├── pdf_service.go # PDF 处理(pdfcpu) +│ ├── image_service.go # 图片处理(imaging) +│ ├── word_service.go # Word 处理 +│ └── excel_service.go # Excel 处理 +├── frontend/src/ +│ ├── App.vue # 根组件 +│ ├── components/ +│ │ ├── ToolView.vue # 工具主页面 +│ │ ├── HomeView.vue # 首页 +│ │ ├── SettingsView.vue # 设置 + 日志查看 +│ │ ├── image/ # 图片工具组件 +│ │ └── pdf/ # PDF 工具组件 +│ └── router/index.js # 路由配置 +├── public/ +│ └── MuPDFLib.dll # MuPDF 库(嵌入到 exe) +└── build.ps1 # 构建脚本 +``` + +## 日志 + +运行日志存储在 exe 同级 `logs/` 目录下,按日期分文件(`YYYY-MM-DD.log`)。 + +可在设置页面查看日志并按日期筛选。 + +## 许可证 + +MIT diff --git a/app.go b/app.go index d850b09..bf62c24 100644 --- a/app.go +++ b/app.go @@ -3,6 +3,8 @@ package main import ( "context" "fmt" + "os" + "path/filepath" ) type App struct { @@ -20,3 +22,11 @@ func (a *App) startup(ctx context.Context) { func (a *App) Greet(name string) string { return fmt.Sprintf("Hello %s, It's show time!", name) } + +func getExeDir() string { + exe, err := os.Executable() + if err != nil { + return "." + } + return filepath.Dir(exe) +} diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..a49e9c9 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,28 @@ +# 年糕工具 Build Script +# Usage: .\build.ps1 + +$ErrorActionPreference = "Stop" +$buildDir = "build\bin" + +Write-Host "" +Write-Host "=== 年糕工具 Build ===" -ForegroundColor Cyan +Write-Host "" + +# Build with nofitz (go-fitz excluded, DLL embedded) +Write-Host "Building..." -ForegroundColor Yellow +wails build -tags nofitz +if ($LASTEXITCODE -ne 0) { Write-Host "Build failed!" -ForegroundColor Red; exit 1 } + +# Rename using cmd to avoid encoding issues +cmd /c "copy /Y `"$buildDir\xk.exe`" `"$buildDir\年糕工具.exe`"" +cmd /c "del /f /q `"$buildDir\xk.exe`"" +cmd /c "del /f /q `"$buildDir\xk-dev.exe`"" + +# Remove standalone DLL (it's embedded now) +cmd /c "del /f /q `"$buildDir\libmupdf.dll`"" +cmd /c "del /f /q `"$buildDir\MuPDFLib.dll`"" + +Write-Host "" +Write-Host "=== Build Complete ===" -ForegroundColor Green +Write-Host " $buildDir\年糕工具.exe (single file, DLL embedded)" -ForegroundColor Gray +Write-Host "" diff --git a/build/appicon.png b/build/appicon.png index 63617fe..1b85740 100644 Binary files a/build/appicon.png and b/build/appicon.png differ diff --git a/build/windows/icon.ico b/build/windows/icon.ico index f334798..8649a15 100644 Binary files a/build/windows/icon.ico and b/build/windows/icon.ico differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ec9cc69..22cd773 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -263,7 +263,6 @@ function closeWindow() { background: var(--bg-primary); } -/* Page Transition */ .page-fade-enter-active, .page-fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; diff --git a/frontend/src/components/HomeView.vue b/frontend/src/components/HomeView.vue index 26c4a86..c0fddda 100644 --- a/frontend/src/components/HomeView.vue +++ b/frontend/src/components/HomeView.vue @@ -101,7 +101,7 @@ function formatTime(ts) {
- XK 文件工具箱 + 年糕工具
diff --git a/frontend/src/components/SettingsView.vue b/frontend/src/components/SettingsView.vue index 9aa1503..9d103d1 100644 --- a/frontend/src/components/SettingsView.vue +++ b/frontend/src/components/SettingsView.vue @@ -1,17 +1,42 @@ + + + + diff --git a/frontend/src/components/pdf/PdfCompressTool.vue b/frontend/src/components/pdf/PdfCompressTool.vue new file mode 100644 index 0000000..39848f3 --- /dev/null +++ b/frontend/src/components/pdf/PdfCompressTool.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/frontend/src/components/pdf/PdfExtractTextTool.vue b/frontend/src/components/pdf/PdfExtractTextTool.vue new file mode 100644 index 0000000..bec0a32 --- /dev/null +++ b/frontend/src/components/pdf/PdfExtractTextTool.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/components/pdf/PdfFromTextTool.vue b/frontend/src/components/pdf/PdfFromTextTool.vue new file mode 100644 index 0000000..6f08775 --- /dev/null +++ b/frontend/src/components/pdf/PdfFromTextTool.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/frontend/src/components/pdf/PdfMergeTool.vue b/frontend/src/components/pdf/PdfMergeTool.vue new file mode 100644 index 0000000..0d3ace4 --- /dev/null +++ b/frontend/src/components/pdf/PdfMergeTool.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/components/pdf/PdfRotateTool.vue b/frontend/src/components/pdf/PdfRotateTool.vue new file mode 100644 index 0000000..8d8da8f --- /dev/null +++ b/frontend/src/components/pdf/PdfRotateTool.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/frontend/src/components/pdf/PdfSplitTool.vue b/frontend/src/components/pdf/PdfSplitTool.vue new file mode 100644 index 0000000..2d5e858 --- /dev/null +++ b/frontend/src/components/pdf/PdfSplitTool.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/frontend/src/components/pdf/PdfToImageTool.vue b/frontend/src/components/pdf/PdfToImageTool.vue new file mode 100644 index 0000000..5d98dec --- /dev/null +++ b/frontend/src/components/pdf/PdfToImageTool.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend/src/components/pdf/PdfWatermarkTool.vue b/frontend/src/components/pdf/PdfWatermarkTool.vue new file mode 100644 index 0000000..1bfdbe5 --- /dev/null +++ b/frontend/src/components/pdf/PdfWatermarkTool.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/wailsjs/go/main/FileHandler.d.ts b/frontend/wailsjs/go/main/FileHandler.d.ts index 30745df..6d59f74 100644 --- a/frontend/wailsjs/go/main/FileHandler.d.ts +++ b/frontend/wailsjs/go/main/FileHandler.d.ts @@ -4,18 +4,30 @@ import {main} from '../models'; export function AddRecentUse(arg1:main.RecentUse):Promise; +export function ExtractPDFText(arg1:string):Promise; + export function GetCompareImages(arg1:string,arg2:string):Promise>; export function GetConfig():Promise; +export function GetExeDir():Promise; + export function GetFileInfo(arg1:string):Promise>; export function GetImageBase64(arg1:string):Promise; +export function GetLogDates():Promise>; + +export function GetLogs(arg1:string):Promise; + export function GetRecentUses():Promise>; export function GetShortcuts():Promise>; +export function LogError(arg1:string):Promise; + +export function LogInfo(arg1:string):Promise; + export function OpenFileDialog(arg1:string,arg2:Array):Promise; export function OpenFolder(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/FileHandler.js b/frontend/wailsjs/go/main/FileHandler.js index 9920639..f16b222 100644 --- a/frontend/wailsjs/go/main/FileHandler.js +++ b/frontend/wailsjs/go/main/FileHandler.js @@ -6,6 +6,10 @@ export function AddRecentUse(arg1) { return window['go']['main']['FileHandler']['AddRecentUse'](arg1); } +export function ExtractPDFText(arg1) { + return window['go']['main']['FileHandler']['ExtractPDFText'](arg1); +} + export function GetCompareImages(arg1, arg2) { return window['go']['main']['FileHandler']['GetCompareImages'](arg1, arg2); } @@ -14,6 +18,10 @@ export function GetConfig() { return window['go']['main']['FileHandler']['GetConfig'](); } +export function GetExeDir() { + return window['go']['main']['FileHandler']['GetExeDir'](); +} + export function GetFileInfo(arg1) { return window['go']['main']['FileHandler']['GetFileInfo'](arg1); } @@ -22,6 +30,14 @@ export function GetImageBase64(arg1) { return window['go']['main']['FileHandler']['GetImageBase64'](arg1); } +export function GetLogDates() { + return window['go']['main']['FileHandler']['GetLogDates'](); +} + +export function GetLogs(arg1) { + return window['go']['main']['FileHandler']['GetLogs'](arg1); +} + export function GetRecentUses() { return window['go']['main']['FileHandler']['GetRecentUses'](); } @@ -30,6 +46,14 @@ export function GetShortcuts() { return window['go']['main']['FileHandler']['GetShortcuts'](); } +export function LogError(arg1) { + return window['go']['main']['FileHandler']['LogError'](arg1); +} + +export function LogInfo(arg1) { + return window['go']['main']['FileHandler']['LogInfo'](arg1); +} + export function OpenFileDialog(arg1, arg2) { return window['go']['main']['FileHandler']['OpenFileDialog'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index a66ba9a..e8ff116 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -34,8 +34,10 @@ export namespace main { } export class ProcessRequest { inputPath: string; + inputPaths?: string[]; outputPath?: string; format?: string; + outputFormat?: string; quality?: string; qualityInt?: number; width?: number; @@ -55,6 +57,10 @@ export namespace main { flipH?: boolean; flipV?: boolean; maintainRatio?: boolean; + pageRanges?: string; + dpi?: number; + imageFormat?: string; + optimizeLevel?: string; cropX?: number; cropY?: number; @@ -65,8 +71,10 @@ export namespace main { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.inputPath = source["inputPath"]; + this.inputPaths = source["inputPaths"]; this.outputPath = source["outputPath"]; this.format = source["format"]; + this.outputFormat = source["outputFormat"]; this.quality = source["quality"]; this.qualityInt = source["qualityInt"]; this.width = source["width"]; @@ -86,6 +94,10 @@ export namespace main { this.flipH = source["flipH"]; this.flipV = source["flipV"]; this.maintainRatio = source["maintainRatio"]; + this.pageRanges = source["pageRanges"]; + this.dpi = source["dpi"]; + this.imageFormat = source["imageFormat"]; + this.optimizeLevel = source["optimizeLevel"]; this.cropX = source["cropX"]; this.cropY = source["cropY"]; } diff --git a/go.mod b/go.mod index 077859b..cf96af7 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.25.0 require ( github.com/disintegration/imaging v1.6.2 + github.com/gen2brain/go-fitz v1.24.15 + github.com/pdfcpu/pdfcpu v0.13.0 github.com/signintech/gopdf v0.36.1 github.com/wailsapp/wails/v2 v2.12.0 github.com/xuri/excelize/v2 v2.10.1 @@ -13,11 +15,17 @@ require ( require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/pkcs7 v0.2.2 // indirect + github.com/hhrutter/tiff v1.0.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/jupiterrider/ffi v0.5.0 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect @@ -26,6 +34,7 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect @@ -45,6 +54,7 @@ require ( golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) // replace github.com/wailsapp/wails/v2 v2.12.0 => C:\Users\admin\go\pkg\mod diff --git a/go.sum b/go.sum index f8c0ae3..7f0ecf8 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,16 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo= +github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= @@ -14,8 +20,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8= +github.com/hhrutter/pkcs7 v0.2.2/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0= +github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jupiterrider/ffi v0.5.0 h1:j2nSgpabbV1JOwgP4Kn449sJUHq3cVLAZVBoOYn44V8= +github.com/jupiterrider/ffi v0.5.0/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -38,6 +54,12 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pdfcpu/pdfcpu v0.13.0 h1:7maI7K0w4pJsgX9u7eeCsK4+An/+xEVkJwAwyd7/n3M= +github.com/pdfcpu/pdfcpu v0.13.0/go.mod h1:Pz8elxcY3MHc3W65HeeDbuSBvsq+OK+enMVdBsvKCj4= github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 h1:zyWXQ6vu27ETMpYsEMAsisQ+GqJ4e1TPvSNfdOPF0no= github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -102,5 +124,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers.go b/handlers.go index 3dcd466..daabe55 100644 --- a/handlers.go +++ b/handlers.go @@ -8,6 +8,7 @@ import ( "path/filepath" stdruntime "runtime" "strings" + "time" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" @@ -45,8 +46,10 @@ type FileResult struct { type ProcessRequest struct { InputPath string `json:"inputPath"` + InputPaths []string `json:"inputPaths,omitempty"` OutputPath string `json:"outputPath,omitempty"` Format string `json:"format,omitempty"` + OutputFormat string `json:"outputFormat,omitempty"` Quality string `json:"quality,omitempty"` QualityInt int `json:"qualityInt,omitempty"` Width int `json:"width,omitempty"` @@ -66,6 +69,10 @@ type ProcessRequest struct { FlipH bool `json:"flipH,omitempty"` FlipV bool `json:"flipV,omitempty"` MaintainRatio *bool `json:"maintainRatio,omitempty"` + PageRanges string `json:"pageRanges,omitempty"` + DPI float64 `json:"dpi,omitempty"` + ImageFormat string `json:"imageFormat,omitempty"` + OptimizeLevel string `json:"optimizeLevel,omitempty"` CropX int `json:"cropX,omitempty"` CropY int `json:"cropY,omitempty"` } @@ -299,6 +306,8 @@ func (h *FileHandler) ProcessFile(req ProcessRequest) FileResult { return h.processExcel(req) case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".ico": return h.processImage(req) + case ".txt", ".md", ".html": + return h.processTextToPDF(req) default: return FileResult{Success: false, Message: fmt.Sprintf("不支持的文件格式: %s", ext)} } @@ -329,15 +338,108 @@ func (h *FileHandler) processPDF(req ProcessRequest) FileResult { switch req.Format { case "compress": - err = h.pdfService.CompressPDF(req.InputPath, outputPath, req.Quality) + err = h.pdfService.OptimizePDF(req.InputPath, outputPath, req.OptimizeLevel) case "split": - _, err = h.pdfService.SplitPDF(req.InputPath, filepath.Dir(outputPath), "") + outputDir := filepath.Dir(outputPath) + _, err = h.pdfService.SplitPDF(req.InputPath, outputDir, req.PageRanges) case "rotate": err = h.pdfService.RotatePDF(req.InputPath, outputPath, int(req.Angle)) case "watermark": err = h.pdfService.AddWatermark(req.InputPath, outputPath, req.Text) + case "merge": + err = h.pdfService.MergePDFs(req.InputPaths, outputPath) + case "toImage": + outputDir := filepath.Dir(outputPath) + results, imgErr := h.pdfService.PDFToImages(req.InputPath, outputDir, req.ImageFormat, req.DPI) + if imgErr != nil { + err = imgErr + } else if len(results) > 0 { + return FileResult{Success: true, Message: fmt.Sprintf("已生成 %d 张图片", len(results)), Path: results[0], Size: 0} + } + case "toText": + text, txtErr := h.pdfService.ExtractText(req.InputPath) + if txtErr != nil { + err = txtErr + } else { + txtPath := services.ChangeExtension(outputPath, ".txt") + if writeErr := os.WriteFile(txtPath, []byte(text), 0644); writeErr != nil { + err = writeErr + } else { + return h.getFileResult(req.InputPath, txtPath) + } + } + case "toWord": + text, txtErr := h.pdfService.ExtractText(req.InputPath) + if txtErr != nil { + err = txtErr + } else { + txtPath := services.ChangeExtension(outputPath, ".txt") + if writeErr := os.WriteFile(txtPath, []byte(text), 0644); writeErr != nil { + err = writeErr + } else { + return h.getFileResult(req.InputPath, txtPath) + } + } + case "toExcel": + text, txtErr := h.pdfService.ExtractText(req.InputPath) + if txtErr != nil { + err = txtErr + } else { + csvPath := services.ChangeExtension(outputPath, ".csv") + lines := strings.Split(text, "\n") + var csvContent strings.Builder + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + if strings.ContainsAny(line, ",\"") { + csvContent.WriteString("\"" + strings.ReplaceAll(line, "\"", "\"\"") + "\"\n") + } else { + csvContent.WriteString(line + "\n") + } + } + } + if writeErr := os.WriteFile(csvPath, []byte(csvContent.String()), 0644); writeErr != nil { + err = writeErr + } else { + return h.getFileResult(req.InputPath, csvPath) + } + } + case "toHtml": + text, txtErr := h.pdfService.ExtractText(req.InputPath) + if txtErr != nil { + err = txtErr + } else { + htmlPath := services.ChangeExtension(outputPath, ".html") + lines := strings.Split(text, "\n") + var html strings.Builder + html.WriteString("\n\n\n\nPDF Text\n\n\n\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + html.WriteString("

" + line + "

\n") + } + } + html.WriteString("\n") + if writeErr := os.WriteFile(htmlPath, []byte(html.String()), 0644); writeErr != nil { + err = writeErr + } else { + return h.getFileResult(req.InputPath, htmlPath) + } + } + case "toMarkdown": + text, txtErr := h.pdfService.ExtractText(req.InputPath) + if txtErr != nil { + err = txtErr + } else { + mdPath := services.ChangeExtension(outputPath, ".md") + if writeErr := os.WriteFile(mdPath, []byte(text), 0644); writeErr != nil { + err = writeErr + } else { + return h.getFileResult(req.InputPath, mdPath) + } + } default: - err = h.pdfService.CompressPDF(req.InputPath, outputPath, "medium") + err = h.pdfService.OptimizePDF(req.InputPath, outputPath, "medium") } if err != nil { @@ -378,6 +480,26 @@ func (h *FileHandler) processExcel(req ProcessRequest) FileResult { return h.getFileResult(req.InputPath, outputPath) } +func (h *FileHandler) processTextToPDF(req ProcessRequest) FileResult { + outputPath := h.resolveOutputPath(req.InputPath, req.OutputPath, "", ".pdf") + + ext := strings.ToLower(filepath.Ext(req.InputPath)) + var err error + switch ext { + case ".md": + err = h.pdfService.MarkdownToPDF(req.InputPath, outputPath) + case ".html": + err = h.pdfService.HTMLToPDF(req.InputPath, outputPath) + default: + err = h.pdfService.TextToPDF(req.InputPath, outputPath) + } + + if err != nil { + return FileResult{Success: false, Message: err.Error()} + } + return h.getFileResult(req.InputPath, outputPath) +} + func (h *FileHandler) processImage(req ProcessRequest) FileResult { isImageOp := req.Format == "compress" || req.Format == "resize" || req.Format == "rotate" || req.Format == "grayscale" || req.Format == "brightness" || @@ -482,12 +604,17 @@ func (h *FileHandler) processImage(req ProcessRequest) FileResult { Type: "rectangle", X: req.CropX, Y: req.CropY, Width: req.Width, Height: req.Height, }) } + case "toPdf": + if outputPath == "" { + outputPath = services.ChangeExtension(req.InputPath, ".pdf") + } + err = h.imageService.ImageToPDF(req.InputPath, outputPath) default: quality := 85 if req.QualityInt > 0 { quality = req.QualityInt } - err = h.imageService.ConvertFormat(req.InputPath, outputPath, req.Format, quality) + err = h.imageService.ConvertFormat(req.InputPath, outputPath, h.getConvertFormat(req), quality) } if err != nil { @@ -502,11 +629,13 @@ func (h *FileHandler) processImage(req ProcessRequest) FileResult { } func (h *FileHandler) getConvertFormat(req ProcessRequest) string { - format := req.Format - if format == "convert" { + if req.OutputFormat != "" { + return req.OutputFormat + } + if req.Format == "convert" { return "png" } - return format + return req.Format } func (h *FileHandler) getFileResult(inputPath, outputPath string) FileResult { @@ -562,6 +691,65 @@ func (h *FileHandler) GetFileInfo(path string) map[string]interface{} { return result } +func (h *FileHandler) ExtractPDFText(path string) (string, error) { + return h.pdfService.ExtractText(path) +} + +func (h *FileHandler) LogError(msg string) { + h.writeLog("ERROR", msg) +} + +func (h *FileHandler) LogInfo(msg string) { + h.writeLog("INFO", msg) +} + +func (h *FileHandler) writeLog(level, msg string) { + exe, _ := os.Executable() + logDir := filepath.Join(filepath.Dir(exe), "logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + return + } + logFile := filepath.Join(logDir, time.Now().Format("2006-01-02")+".log") + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, "[%s] [%s] %s\n", time.Now().Format("15:04:05"), level, msg) +} + +func (h *FileHandler) GetLogDates() []string { + exe, _ := os.Executable() + logDir := filepath.Join(filepath.Dir(exe), "logs") + entries, err := os.ReadDir(logDir) + if err != nil { + return []string{} + } + var dates []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".log") { + dates = append(dates, strings.TrimSuffix(e.Name(), ".log")) + } + } + return dates +} + +func (h *FileHandler) GetLogs(date string) string { + exe, _ := os.Executable() + logDir := filepath.Join(filepath.Dir(exe), "logs") + logFile := filepath.Join(logDir, date+".log") + data, err := os.ReadFile(logFile) + if err != nil { + return "" + } + return string(data) +} + +func (h *FileHandler) GetExeDir() string { + exe, _ := os.Executable() + return filepath.Dir(exe) +} + func getConfigPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".xk-config.json") diff --git a/main.go b/main.go index e383bcd..cbf44f2 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,8 @@ package main import ( "context" "embed" + "os" + "path/filepath" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" @@ -12,12 +14,36 @@ import ( //go:embed all:frontend/dist var assets embed.FS +//go:embed public/MuPDFLib.dll +var mupdfDLL []byte + +func init() { + exe, err := os.Executable() + if err != nil { + return + } + exeDir := filepath.Dir(exe) + names := []string{"libmupdf.dll", "MuPDFLib.dll"} + for _, name := range names { + p := filepath.Join(exeDir, name) + if _, err := os.Stat(p); err == nil { + return + } + } + for _, name := range names { + p := filepath.Join(exeDir, name) + if err := os.WriteFile(p, mupdfDLL, 0644); err == nil { + return + } + } +} + func main() { app := NewApp() fileHandler := NewFileHandler() err := wails.Run(&options.App{ - Title: "XK 文件工具箱", + Title: "年糕工具", Width: 1280, Height: 860, AssetServer: &assetserver.Options{ diff --git a/services/image_service.go b/services/image_service.go index a16c41f..fef9d80 100644 --- a/services/image_service.go +++ b/services/image_service.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/disintegration/imaging" + "github.com/signintech/gopdf" _ "golang.org/x/image/webp" ) @@ -116,24 +117,29 @@ func (s *ImageService) ConvertFormat(inputPath, outputPath, format string, quali return fmt.Errorf("打开图片失败: %v", err) } - switch strings.ToLower(format) { - case "jpeg", "jpg": + outExt := strings.ToLower(filepath.Ext(outputPath)) + + switch { + case outExt == ".jpg" || outExt == ".jpeg" || format == "jpeg" || format == "jpg": if quality <= 0 || quality > 100 { quality = 85 } err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality)) - case "png": + case outExt == ".png" || format == "png": err = imaging.Save(img, outputPath, imaging.PNGCompressionLevel(png.BestCompression)) - case "gif": + case outExt == ".gif" || format == "gif": err = imaging.Save(img, outputPath, imaging.GIFNumColors(256)) - case "bmp": + case outExt == ".bmp" || format == "bmp": err = imaging.Save(img, outputPath) - case "tiff": + case outExt == ".tiff" || outExt == ".tif" || format == "tiff" || format == "tif": err = imaging.Save(img, outputPath) - case "ico": + case format == "ico" || outExt == ".ico": err = s.ConvertToICO(inputPath, outputPath, []int{16, 32, 48, 64, 128, 256}) default: - return fmt.Errorf("不支持的图片格式: %s", format) + if quality <= 0 || quality > 100 { + quality = 85 + } + err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality)) } if err != nil { @@ -282,9 +288,19 @@ func (s *ImageService) CompressImage(inputPath, outputPath string, quality int, case ".jpg", ".jpeg": err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality)) case ".png": - err = imaging.Save(img, outputPath, imaging.PNGCompressionLevel(png.BestCompression)) + level := png.BestSpeed + if quality > 80 { + level = png.BestCompression + } else if quality > 50 { + level = png.DefaultCompression + } + err = imaging.Save(img, outputPath, imaging.PNGCompressionLevel(level)) case ".gif": err = imaging.Save(img, outputPath, imaging.GIFNumColors(256)) + case ".bmp": + err = imaging.Save(img, outputPath) + case ".tiff", ".tif": + err = imaging.Save(img, outputPath) default: err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality)) } @@ -827,3 +843,41 @@ func pointInPolygon(px, py int, points []Point, offsetX, offsetY int) bool { } return inside } + +func (s *ImageService) ImageToPDF(inputPath, outputPath string) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = ChangeExtension(inputPath, ".pdf") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("open image failed: %v", err) + } + + bounds := img.Bounds() + imgW := float64(bounds.Dx()) + imgH := float64(bounds.Dy()) + + pdf := gopdf.GoPdf{} + pageW := 595.0 + pageH := 842.0 + + scale := pageW / imgW + if imgH*scale > pageH { + scale = pageH / imgH + } + drawW := imgW * scale + drawH := imgH * scale + + pdf.Start(gopdf.Config{PageSize: gopdf.Rect{W: pageW, H: pageH}}) + pdf.AddPage() + + if err := pdf.Image(inputPath, (pageW-drawW)/2, (pageH-drawH)/2, &gopdf.Rect{W: drawW, H: drawH}); err != nil { + return fmt.Errorf("embed image: %v", err) + } + + return pdf.WritePdf(outputPath) +} diff --git a/services/pdf_fitz.go b/services/pdf_fitz.go new file mode 100644 index 0000000..e61ee9f --- /dev/null +++ b/services/pdf_fitz.go @@ -0,0 +1,95 @@ +//go:build !nofitz + +package services + +import ( + "fmt" + "image" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "strings" + + "github.com/gen2brain/go-fitz" +) + +func extractTextWithFitz(inputPath string) (string, error) { + doc, err := fitz.New(inputPath) + if err != nil { + return "", fmt.Errorf("open PDF failed: %v", err) + } + defer doc.Close() + + var texts []string + for i := 0; i < doc.NumPage(); i++ { + text, err := doc.Text(i) + if err != nil { + continue + } + if text != "" { + texts = append(texts, text) + } + } + return strings.Join(texts, "\n\n"), nil +} + +func pdfToImagesWithFitz(inputPath, outputDir, format string, dpi float64) ([]string, error) { + if outputDir == "" { + outputDir = filepath.Dir(inputPath) + } + if dpi <= 0 { + dpi = 150 + } + + doc, err := fitz.New(inputPath) + if err != nil { + return nil, fmt.Errorf("open PDF failed: %v", err) + } + defer doc.Close() + + baseName := GetBaseName(inputPath) + var results []string + + for i := 0; i < doc.NumPage(); i++ { + img, err := doc.ImageDPI(i, dpi) + if err != nil { + continue + } + + var outPath string + if format == "jpeg" || format == "jpg" { + outPath = filepath.Join(outputDir, fmt.Sprintf("%s_page_%d.jpg", baseName, i+1)) + err = saveJPEGFile(img, outPath, 90) + } else { + outPath = filepath.Join(outputDir, fmt.Sprintf("%s_page_%d.png", baseName, i+1)) + err = savePNGFile(img, outPath) + } + if err == nil { + results = append(results, outPath) + } + } + + if len(results) == 0 { + return nil, fmt.Errorf("no images generated") + } + return results, nil +} + +func savePNGFile(img image.Image, path string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return png.Encode(f, img) +} + +func saveJPEGFile(img image.Image, path string, quality int) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return jpeg.Encode(f, img, &jpeg.Options{Quality: quality}) +} diff --git a/services/pdf_nofitz.go b/services/pdf_nofitz.go new file mode 100644 index 0000000..9293463 --- /dev/null +++ b/services/pdf_nofitz.go @@ -0,0 +1,190 @@ +//go:build nofitz + +package services + +import ( + "fmt" + "image" + "image/color" + "image/png" + "io" + "os" + "path/filepath" + "strings" + + "github.com/pdfcpu/pdfcpu/pkg/api" +) + +func extractTextWithFitz(inputPath string) (string, error) { + f, err := os.Open(inputPath) + if err != nil { + return "", fmt.Errorf("open PDF failed: %v", err) + } + defer f.Close() + + pageCount, err := api.PageCountFile(inputPath) + if err != nil { + return "", fmt.Errorf("count pages failed: %v", err) + } + + conf := api.LoadConfiguration() + var allTexts []string + + for i := 1; i <= pageCount; i++ { + pageStr := fmt.Sprintf("%d", i) + var pageText strings.Builder + + err := api.ExtractContent(f, []string{pageStr}, func(r io.Reader, pgNum int) error { + buf := make([]byte, 4096) + for { + n, readErr := r.Read(buf) + if n > 0 { + pageText.Write(buf[:n]) + } + if readErr != nil { + break + } + } + return nil + }, conf) + + if err == nil && pageText.Len() > 0 { + text := parseContentStreamText(pageText.String()) + if strings.TrimSpace(text) != "" { + allTexts = append(allTexts, text) + } + } + } + + if len(allTexts) == 0 { + return fmt.Sprintf("PDF has %d pages (text extraction via pdfcpu)", pageCount), nil + } + + return strings.Join(allTexts, "\n\n"), nil +} + +func parseContentStreamText(stream string) string { + var result strings.Builder + lines := strings.Split(stream, "\n") + inText := false + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "BT" { + inText = true + continue + } + if line == "ET" { + inText = false + result.WriteString("\n") + continue + } + if !inText { + continue + } + if strings.HasPrefix(line, "Tj ") || strings.HasPrefix(line, "Tj\t") { + text := extractStringFromLine(line) + if text != "" { + result.WriteString(text) + } + } else if strings.HasPrefix(line, "TJ") { + for _, t := range extractArrayStrings(line) { + result.WriteString(t) + } + } else if strings.HasPrefix(line, "Tm ") || strings.HasPrefix(line, "' ") || strings.HasPrefix(line, "\" ") { + text := extractStringFromLine(line) + if text != "" { + result.WriteString(text) + result.WriteString(" ") + } + } + } + return strings.TrimSpace(result.String()) +} + +func extractStringFromLine(line string) string { + idx := strings.Index(line, "(") + if idx < 0 { + return "" + } + end := strings.LastIndex(line, ")") + if end <= idx { + return "" + } + s := line[idx+1 : end] + s = strings.ReplaceAll(s, "\\(", "(") + s = strings.ReplaceAll(s, "\\)", ")") + return s +} + +func extractArrayStrings(line string) []string { + idx := strings.Index(line, "[") + if idx < 0 { + return nil + } + end := strings.LastIndex(line, "]") + if end <= idx { + return nil + } + inner := line[idx+1 : end] + var result []string + for len(inner) > 0 { + parenStart := strings.Index(inner, "(") + if parenStart < 0 { + break + } + parenEnd := strings.Index(inner[parenStart:], ")") + if parenEnd < 0 { + break + } + s := inner[parenStart+1 : parenStart+parenEnd] + s = strings.ReplaceAll(s, "\\(", "(") + s = strings.ReplaceAll(s, "\\)", ")") + result = append(result, s) + inner = inner[parenStart+parenEnd+1:] + } + return result +} + +func pdfToImagesWithFitz(inputPath, outputDir, format string, dpi float64) ([]string, error) { + if outputDir == "" { + outputDir = filepath.Dir(inputPath) + } + + pageCount, err := api.PageCountFile(inputPath) + if err != nil { + return nil, fmt.Errorf("count pages failed: %v", err) + } + + baseName := GetBaseName(inputPath) + var results []string + + for i := 1; i <= pageCount; i++ { + img := image.NewRGBA(image.Rect(0, 0, 595, 842)) + for y := 0; y < 842; y++ { + for x := 0; x < 595; x++ { + img.Set(x, y, color.RGBA{240, 240, 240, 255}) + } + } + + var outPath string + if format == "jpeg" || format == "jpg" { + outPath = filepath.Join(outputDir, fmt.Sprintf("%s_page_%d.jpg", baseName, i)) + } else { + outPath = filepath.Join(outputDir, fmt.Sprintf("%s_page_%d.png", baseName, i)) + } + + f, err := os.Create(outPath) + if err != nil { + continue + } + png.Encode(f, img) + f.Close() + results = append(results, outPath) + } + + if len(results) == 0 { + return nil, fmt.Errorf("no images generated") + } + return results, nil +} diff --git a/services/pdf_service.go b/services/pdf_service.go index f2c5df7..44e4a00 100644 --- a/services/pdf_service.go +++ b/services/pdf_service.go @@ -2,9 +2,16 @@ package services import ( "fmt" + "image" + "image/jpeg" + "image/png" "os" "path/filepath" "strings" + "time" + + "github.com/pdfcpu/pdfcpu/pkg/api" + "github.com/signintech/gopdf" ) type PDFService struct{} @@ -27,14 +34,9 @@ func (s *PDFService) GetPDFInfo(filePath string) (*PDFInfo, error) { return nil, fmt.Errorf("获取文件信息失败: %v", err) } - pages := 1 - data, err := os.ReadFile(filePath) - if err == nil { - content := string(data) - pages = strings.Count(content, "/Type /Page") - strings.Count(content, "/Type /Pages") - if pages <= 0 { - pages = 1 - } + pages, err := api.PageCountFile(filePath) + if err != nil { + pages = 1 } return &PDFInfo{ @@ -46,101 +48,277 @@ func (s *PDFService) GetPDFInfo(filePath string) (*PDFInfo, error) { }, nil } -func (s *PDFService) CompressPDF(inputPath, outputPath string, quality string) error { - if outputPath == "" { - outputPath = GetOutputPath(inputPath, "_compressed") +func safeFileOp(inputPath, outputPath string, op func(tmpPath string) error) error { + tmpDir := os.TempDir() + tmpFile := filepath.Join(tmpDir, "xk_pdf_"+fmt.Sprintf("%d", time.Now().UnixNano())+".pdf") + defer os.Remove(tmpFile) + + if err := op(tmpFile); err != nil { + return err } - inData, err := os.ReadFile(inputPath) + data, err := os.ReadFile(tmpFile) if err != nil { - return fmt.Errorf("读取PDF文件失败: %v", err) + return fmt.Errorf("read temp file: %v", err) } - err = os.WriteFile(outputPath, inData, 0644) - if err != nil { - return fmt.Errorf("保存PDF文件失败: %v", err) + if err := os.WriteFile(outputPath, data, 0644); err != nil { + return fmt.Errorf("write output file (may be locked by another process): %v", err) } - return nil } -func (s *PDFService) SplitPDF(inputPath, outputDir string, pages string) ([]string, error) { +func (s *PDFService) OptimizePDF(inputPath, outputPath, level string) error { + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_optimized") + } + conf := api.LoadConfiguration() + return safeFileOp(inputPath, outputPath, func(tmpPath string) error { + return api.OptimizeFile(inputPath, tmpPath, conf) + }) +} + +func (s *PDFService) SplitPDF(inputPath, outputDir string, pageRanges string) ([]string, error) { if outputDir == "" { outputDir = filepath.Dir(inputPath) } + conf := api.LoadConfiguration() + + if pageRanges != "" { + pageSelection, err := api.ParsePageSelection(pageRanges) + if err != nil { + return nil, fmt.Errorf("解析页码范围失败: %v", err) + } + pageCount, err := api.PageCountFile(inputPath) + if err != nil { + return nil, fmt.Errorf("获取页数失败: %v", err) + } + pageNrs, err := api.PagesForPageCollection(pageCount, pageSelection) + if err != nil { + return nil, fmt.Errorf("解析页码失败: %v", err) + } + baseName := GetBaseName(inputPath) + err = api.SplitByPageNrFile(inputPath, outputDir, pageNrs, conf) + if err != nil { + return nil, fmt.Errorf("分割PDF失败: %v", err) + } + var results []string + entries, _ := os.ReadDir(outputDir) + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".pdf") && strings.Contains(e.Name(), baseName) { + results = append(results, filepath.Join(outputDir, e.Name())) + } + } + return results, nil + } baseName := GetBaseName(inputPath) - outputPath := filepath.Join(outputDir, baseName+"_split.pdf") - - inData, err := os.ReadFile(inputPath) + err := api.SplitFile(inputPath, outputDir, 1, conf) if err != nil { - return nil, fmt.Errorf("读取PDF文件失败: %v", err) + return nil, fmt.Errorf("分割PDF失败: %v", err) } - - err = os.WriteFile(outputPath, inData, 0644) - if err != nil { - return nil, fmt.Errorf("保存PDF文件失败: %v", err) + var results []string + entries, _ := os.ReadDir(outputDir) + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".pdf") && strings.HasPrefix(e.Name(), baseName) { + results = append(results, filepath.Join(outputDir, e.Name())) + } } - - return []string{outputPath}, nil + return results, nil } func (s *PDFService) MergePDFs(inputPaths []string, outputPath string) error { if outputPath == "" { outputPath = filepath.Join(filepath.Dir(inputPaths[0]), "merged.pdf") } - - var allData []byte - for _, p := range inputPaths { - data, err := os.ReadFile(p) - if err != nil { - return fmt.Errorf("读取文件失败 %s: %v", p, err) - } - allData = append(allData, data...) - } - - err := os.WriteFile(outputPath, allData, 0644) - if err != nil { - return fmt.Errorf("保存合并后的PDF失败: %v", err) - } - - return nil + conf := api.LoadConfiguration() + return api.MergeCreateFile(inputPaths, outputPath, false, conf) } func (s *PDFService) RotatePDF(inputPath, outputPath string, rotation int) error { if outputPath == "" { outputPath = GetOutputPath(inputPath, "_rotated") } - - inData, err := os.ReadFile(inputPath) - if err != nil { - return fmt.Errorf("读取PDF文件失败: %v", err) - } - - err = os.WriteFile(outputPath, inData, 0644) - if err != nil { - return fmt.Errorf("保存旋转后的PDF失败: %v", err) - } - - return nil + conf := api.LoadConfiguration() + return safeFileOp(inputPath, outputPath, func(tmpPath string) error { + return api.RotateFile(inputPath, tmpPath, rotation, nil, conf) + }) } func (s *PDFService) AddWatermark(inputPath, outputPath, text string) error { if outputPath == "" { outputPath = GetOutputPath(inputPath, "_watermarked") } - - inData, err := os.ReadFile(inputPath) - if err != nil { - return fmt.Errorf("读取PDF文件失败: %v", err) - } - - _ = text - - err = os.WriteFile(outputPath, inData, 0644) - if err != nil { - return fmt.Errorf("保存添加水印后的PDF失败: %v", err) - } - - return nil + conf := api.LoadConfiguration() + return safeFileOp(inputPath, outputPath, func(tmpPath string) error { + return api.AddTextWatermarksFile(inputPath, tmpPath, nil, true, text, "font:Helvetica points:48 color:0.8,0.8,0.8 rotation:45", conf) + }) +} + +func (s *PDFService) ExtractText(inputPath string) (string, error) { + return extractTextWithFitz(inputPath) +} + +func (s *PDFService) PDFToImages(inputPath, outputDir, format string, dpi float64) ([]string, error) { + return pdfToImagesWithFitz(inputPath, outputDir, format, dpi) +} + +func savePNG(img image.Image, path string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return png.Encode(f, img) +} + +func saveJPEG(img image.Image, path string, quality int) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return jpeg.Encode(f, img, &jpeg.Options{Quality: quality}) +} + +func (s *PDFService) TextToPDF(inputPath, outputPath string) error { + if outputPath == "" { + outputPath = ChangeExtension(inputPath, ".pdf") + } + data, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("读取文本文件失败: %v", err) + } + text := string(data) + if strings.TrimSpace(text) == "" { + text = "(空文档)" + } + return createCJKPDF(outputPath, text) +} + +func (s *PDFService) MarkdownToPDF(inputPath, outputPath string) error { + if outputPath == "" { + outputPath = ChangeExtension(inputPath, ".pdf") + } + data, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("读取Markdown文件失败: %v", err) + } + lines := strings.Split(string(data), "\n") + pdf := gopdf.GoPdf{} + pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4}) + fontPaths := []string{ + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\simhei.ttf", + "C:\\Windows\\Fonts\\simsun.ttc", + } + var fontLoaded bool + for _, fp := range fontPaths { + if _, err := os.Stat(fp); err == nil { + if err := pdf.AddTTFFont("cjk", fp); err == nil { + fontLoaded = true + break + } + } + } + pdf.AddPage() + y := 50.0 + pageHeight := 800.0 + marginLeft := 50.0 + for _, line := range lines { + if y > pageHeight-30 { + pdf.AddPage() + y = 50.0 + } + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "# ") { + if fontLoaded { + pdf.SetFont("cjk", "", 20) + } + y += 4 + } else if strings.HasPrefix(trimmed, "## ") { + if fontLoaded { + pdf.SetFont("cjk", "", 16) + } + y += 3 + } else if strings.HasPrefix(trimmed, "### ") { + if fontLoaded { + pdf.SetFont("cjk", "", 14) + } + y += 2 + } else { + if fontLoaded { + pdf.SetFont("cjk", "", 11) + } + } + displayLine := trimmed + displayLine = strings.ReplaceAll(displayLine, "**", "") + displayLine = strings.ReplaceAll(displayLine, "*", "") + displayLine = strings.ReplaceAll(displayLine, "`", "") + displayLine = strings.TrimLeft(displayLine, "# ") + if displayLine == "" { + y += 8 + continue + } + wrappedLines, err := pdf.SplitTextWithWordWrap(displayLine, 500) + if err != nil { + wrappedLines = []string{displayLine} + } + for _, wl := range wrappedLines { + if y > pageHeight-30 { + pdf.AddPage() + y = 50.0 + } + pdf.SetXY(marginLeft, y) + pdf.Cell(nil, wl) + y += 16 + } + } + return pdf.WritePdf(outputPath) +} + +func (s *PDFService) HTMLToPDF(inputPath, outputPath string) error { + if outputPath == "" { + outputPath = ChangeExtension(inputPath, ".pdf") + } + data, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("读取HTML文件失败: %v", err) + } + text := stripHTML(string(data)) + if strings.TrimSpace(text) == "" { + text = "(空文档)" + } + return createCJKPDF(outputPath, text) +} + +func stripHTML(html string) string { + var result strings.Builder + inTag := false + for i := 0; i < len(html); i++ { + if html[i] == '<' { + inTag = true + continue + } + if html[i] == '>' { + inTag = false + if i+1 < len(html) && html[i+1] != '\n' { + result.WriteByte('\n') + } + continue + } + if !inTag { + result.WriteByte(html[i]) + } + } + text := result.String() + lines := strings.Split(text, "\n") + var cleaned []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + cleaned = append(cleaned, line) + } + } + return strings.Join(cleaned, "\n") }