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) {
" + 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") }