diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f364510 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# AGENTS.md + +## Project Overview + +Wails v2 desktop app ("XK 文件工具箱") — file processing toolbox for PDF, Word, Excel, and images. Go backend + Vue 3 frontend, compiled to native Windows executable. + +## Build & Dev Commands + +```bash +wails dev # Development with hot reload (Vite + Go) +wails build # Production build → build/bin/xk.exe +cd frontend && npm run build # Frontend only +cd frontend && npm run dev # Vite dev server only +go build ./... # Backend only (check compilation) +``` + +**Prerequisites**: Go 1.25+, Node.js, Wails CLI (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`) + +## Architecture + +``` +main.go → Wails app entry, binds FileHandler to frontend +handlers.go → FileHandler: all Go methods exposed to JS via Wails IPC +app.go → App struct (minimal, unused greeting method) +services/ → Business logic per format + pdf_service.go → gopdf-based PDF operations + word_service.go → Word to PDF conversion + excel_service.go → Excel to CSV/JSON conversion + image_service.go → imaging library: compress, resize, rotate, crop, grayscale, brightness, contrast, saturation, flip, removebg + utils.go → Path helpers, JSON serialization +frontend/src/ + App.vue → Root layout: 64px sidebar + main content + components/ + HomeView.vue → Tool grid with shortcuts and recent uses + ToolView.vue → ALL tool UI in one component (~1800 lines, handles all categories) + SettingsView.vue → Output directory config + router/index.js → Hash router: /, /tool/:category/:action?, /settings +``` + +## Key Patterns + +- **Wails IPC**: Frontend calls Go via `window.go.main.FileHandler.MethodName()`. Bindings auto-generated in `frontend/wailsjs/`. +- **Single tool component**: `ToolView.vue` handles all tools (PDF/Word/Excel/Image) via route params `category` and `action`. Switching tools resets state. +- **Auto-processing**: Image tools auto-process on parameter change (500ms debounce) via `watch` on param refs. +- **CSS variables**: Theme defined in `style.css` `:root` — dark SaaS style with glassmorphism. All colors via CSS vars (`--bg-primary`, `--accent-primary`, etc.). +- **Element Plus**: UI component library. Global overrides in `style.css` with `!important`. +- **Image processing**: Backend uses `github.com/disintegration/imaging`. Frontend can do canvas-based operations (background removal uses in-browser pixel manipulation). + +## Gotchas + +- `ToolView.vue` is ~1800 lines. Changes to one tool's UI can affect others. Search for the specific tool's section before editing. +- Image processing has TWO preview systems: backend-processed (via `ProcessFile`) and frontend canvas-based (background removal). Don't confuse them. +- `FileResult` returns both `size` (output) and `originalSize` (input) for size comparison display. +- Backend temp files go to `os.TempDir()` with `xk_` prefix. They are never cleaned up automatically. +- The `removebg` tool has both a backend threshold (`req.Threshold`) and a frontend canvas tolerance (`bgRemovalTolerance`). The frontend tolerance is what actually controls the canvas-based removal. +- HomeView.vue had `overflow: hidden` that blocked scrolling — use `overflow: visible` if you see scroll issues. + +## UI/UX Skill + +`.opencode/skills/ui-ux-pro-max/` contains a searchable design database. Use `python scripts/search.py "" --design-system` to generate design systems. Requires Python 3. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index feca9a1..eccaffa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "vue": "^3.2.37" + "@element-plus/icons-vue": "^2.3.2", + "element-plus": "^2.14.1", + "vue": "^3.2.37", + "vue-router": "^4.6.4" }, "devDependencies": { "@vitejs/plugin-vue": "^3.0.3", @@ -57,6 +60,22 @@ "node": ">=6.9.0" } }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "peerDependencies": { + "vue": "^3.2.0" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.15.18", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", @@ -89,11 +108,61 @@ "node": ">=12" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==" + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==" + }, "node_modules/@vitejs/plugin-vue": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-3.2.0.tgz", @@ -153,6 +222,11 @@ "@vue/shared": "3.5.35" } }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, "node_modules/@vue/reactivity": { "version": "3.5.35", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", @@ -198,11 +272,81 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==" }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==" + }, + "node_modules/element-plus": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.1.tgz", + "integrity": "sha512-UFnm1+BckNi+azkKJ7L32q1uXs9ekr99Z9pWTQPeDR05jqEWUwQq51ro4kZMVrANbjknX3Z7ukCZwTi2T6Tr9A==", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.1" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -635,6 +779,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -643,6 +807,11 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -660,6 +829,11 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -822,6 +996,25 @@ "optional": true } } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.4.tgz", + "integrity": "sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 43da9cb..7aae8de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,10 +9,13 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.2.37" + "@element-plus/icons-vue": "^2.3.2", + "element-plus": "^2.14.1", + "vue": "^3.2.37", + "vue-router": "^4.6.4" }, "devDependencies": { "@vitejs/plugin-vue": "^3.0.3", "vite": "^3.0.7" } -} \ No newline at end of file +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index ed0efaf..d4f1953 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -21d2a2199c4fb87865d8160b492f51c3 \ No newline at end of file +2189702639db78bed99ff1bbe16eeafc \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 15d2f12..ec9cc69 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,21 +1,281 @@ +import { useRouter, useRoute } from 'vue-router' +import { computed } from 'vue' + +const router = useRouter() +const route = useRoute() + +const isHome = computed(() => route.name === 'home') + +function navigateTo(path) { + router.push(path) +} + +function minimizeWindow() { + window.runtime?.WindowMinimise() +} + +function maximizeWindow() { + window.runtime?.WindowToggleMaximise() +} + +function closeWindow() { + window.runtime?.Quit() +} + - diff --git a/frontend/src/components/HomeView.vue b/frontend/src/components/HomeView.vue new file mode 100644 index 0000000..26c4a86 --- /dev/null +++ b/frontend/src/components/HomeView.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/frontend/src/components/SettingsView.vue b/frontend/src/components/SettingsView.vue new file mode 100644 index 0000000..9aa1503 --- /dev/null +++ b/frontend/src/components/SettingsView.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/frontend/src/components/ToolView.vue b/frontend/src/components/ToolView.vue new file mode 100644 index 0000000..9a7e5d8 --- /dev/null +++ b/frontend/src/components/ToolView.vue @@ -0,0 +1,840 @@ + + + + + diff --git a/frontend/src/components/image/ImageBlurTool.vue b/frontend/src/components/image/ImageBlurTool.vue new file mode 100644 index 0000000..0ba7de2 --- /dev/null +++ b/frontend/src/components/image/ImageBlurTool.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/components/image/ImageBrightnessTool.vue b/frontend/src/components/image/ImageBrightnessTool.vue new file mode 100644 index 0000000..9ff13e7 --- /dev/null +++ b/frontend/src/components/image/ImageBrightnessTool.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/components/image/ImageCompressTool.vue b/frontend/src/components/image/ImageCompressTool.vue new file mode 100644 index 0000000..e6edaa6 --- /dev/null +++ b/frontend/src/components/image/ImageCompressTool.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/frontend/src/components/image/ImageConvertTool.vue b/frontend/src/components/image/ImageConvertTool.vue new file mode 100644 index 0000000..3017654 --- /dev/null +++ b/frontend/src/components/image/ImageConvertTool.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/frontend/src/components/image/ImageCropTool.vue b/frontend/src/components/image/ImageCropTool.vue new file mode 100644 index 0000000..2008a07 --- /dev/null +++ b/frontend/src/components/image/ImageCropTool.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/frontend/src/components/image/ImageGrayscaleTool.vue b/frontend/src/components/image/ImageGrayscaleTool.vue new file mode 100644 index 0000000..3963390 --- /dev/null +++ b/frontend/src/components/image/ImageGrayscaleTool.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/components/image/ImageInvertTool.vue b/frontend/src/components/image/ImageInvertTool.vue new file mode 100644 index 0000000..83cf2fb --- /dev/null +++ b/frontend/src/components/image/ImageInvertTool.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/src/components/image/ImageRemoveBgTool.vue b/frontend/src/components/image/ImageRemoveBgTool.vue new file mode 100644 index 0000000..aec5663 --- /dev/null +++ b/frontend/src/components/image/ImageRemoveBgTool.vue @@ -0,0 +1,630 @@ + + + + + diff --git a/frontend/src/components/image/ImageResizeTool.vue b/frontend/src/components/image/ImageResizeTool.vue new file mode 100644 index 0000000..056d88d --- /dev/null +++ b/frontend/src/components/image/ImageResizeTool.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/frontend/src/components/image/ImageRotateTool.vue b/frontend/src/components/image/ImageRotateTool.vue new file mode 100644 index 0000000..ce299b6 --- /dev/null +++ b/frontend/src/components/image/ImageRotateTool.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/src/components/image/ImageSharpenTool.vue b/frontend/src/components/image/ImageSharpenTool.vue new file mode 100644 index 0000000..f4abadb --- /dev/null +++ b/frontend/src/components/image/ImageSharpenTool.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/composables/useImageTool.js b/frontend/src/composables/useImageTool.js new file mode 100644 index 0000000..2799659 --- /dev/null +++ b/frontend/src/composables/useImageTool.js @@ -0,0 +1,265 @@ +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { ElMessage } from 'element-plus' + +export function useImageTool() { + const filePath = ref('') + const filePreview = ref('') + const imageInfo = ref(null) + const processing = ref(false) + const resultInfo = ref(null) + const outputPath = ref('') + const compareImages = ref({ original: '', processed: '' }) + const config = ref({ defaultOutputDir: '' }) + + const showPreview = ref(false) + const previewSrc = ref('') + const previewIndex = ref(0) + const previewScale = ref(1) + const previewX = ref(0) + const previewY = ref(0) + const isDragging = ref(false) + const dragStart = ref({ x: 0, y: 0 }) + + onMounted(async () => { + try { + config.value = await window.go.main.FileHandler.GetConfig() + } catch (e) {} + }) + + function formatSize(bytes) { + if (!bytes) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + function getFileName() { + if (!filePath.value) return '未选择文件' + return filePath.value.split(/[/\\]/).pop() + } + + async function selectFile(fileFilters) { + try { + const path = await window.go.main.FileHandler.OpenFileDialog('选择文件', fileFilters) + if (path) { + filePath.value = path + resetState() + outputPath.value = '' + await loadFilePreview() + } + } catch (e) { + ElMessage.error('选择文件失败: ' + e.message) + } + } + + async function loadFilePreview() { + if (!filePath.value) return + try { + const b64 = await window.go.main.FileHandler.GetImageBase64(filePath.value) + if (b64) { + filePreview.value = b64 + const info = await window.go.main.FileHandler.GetFileInfo(filePath.value) + imageInfo.value = info + } + } catch (e) { + console.error('加载预览失败:', e) + } + } + + function resetState() { + resultInfo.value = null + compareImages.value = { original: '', processed: '' } + previewScale.value = 1 + previewX.value = 0 + previewY.value = 0 + filePreview.value = '' + imageInfo.value = null + } + + function clearImageFile() { + filePath.value = '' + filePreview.value = '' + imageInfo.value = null + resultInfo.value = null + compareImages.value = { original: '', processed: '' } + outputPath.value = '' + } + + async function processFile(req) { + if (!filePath.value) { + ElMessage.warning('请先选择文件') + return + } + + processing.value = true + resultInfo.value = null + compareImages.value = { original: '', processed: '' } + + try { + const result = await window.go.main.FileHandler.ProcessFile(req) + + if (result.success) { + resultInfo.value = { + path: result.path, + size: formatSize(result.size), + sizeBytes: result.size, + originalSize: result.originalSize ? formatSize(result.originalSize) : '', + originalSizeBytes: result.originalSize || 0, + tempPath: result.path, + isImageOp: true, + } + + try { + const b64 = await window.go.main.FileHandler.GetImageBase64(filePath.value) + compareImages.value.original = b64 + compareImages.value.processed = await window.go.main.FileHandler.GetImageBase64(result.path) + if (!compareImages.value.processed) { + compareImages.value.processed = b64 + } + } catch (e) {} + + await window.go.main.FileHandler.AddRecentUse({ + id: req.format, + name: '', + category: 'image', + usedAt: Date.now(), + }) + + ElMessage.success('处理完成') + } else { + ElMessage.error(result.message) + } + } catch (e) { + ElMessage.error('处理失败: ' + e.message) + } finally { + processing.value = false + } + } + + async function selectOutputPath(outputFilters, defaultName) { + try { + const path = await window.go.main.FileHandler.OpenSaveDialog('保存文件', defaultName, outputFilters) + if (path) { + outputPath.value = path + } + } catch (e) {} + } + + async function downloadResult(outputFilters, getOutputExtFn, getOutputDefaultNameFn) { + if (!resultInfo.value?.tempPath) return + + try { + const ext = getOutputExtFn ? getOutputExtFn() : '.png' + const defaultName = getOutputDefaultNameFn ? getOutputDefaultNameFn() : ('output' + ext) + const savePath = await window.go.main.FileHandler.OpenSaveDialog('保存文件', defaultName, outputFilters) + + if (savePath) { + const result = await window.go.main.FileHandler.SaveResult(resultInfo.value.tempPath, savePath) + if (result.success) { + ElMessage.success('文件已保存到: ' + savePath) + resultInfo.value.path = savePath + } else { + ElMessage.error(result.message) + } + } + } catch (e) { + ElMessage.error('保存失败: ' + e.message) + } + } + + function openPreview(src, index) { + previewSrc.value = src + previewIndex.value = index + previewScale.value = 1 + previewX.value = 0 + previewY.value = 0 + showPreview.value = true + } + + function closePreview() { + showPreview.value = false + } + + function onWheel(e) { + e.preventDefault() + const delta = e.deltaY > 0 ? -0.1 : 0.1 + previewScale.value = Math.max(0.1, Math.min(5, previewScale.value + delta)) + } + + function onMouseDown(e) { + if (e.button !== 0) return + isDragging.value = true + dragStart.value = { x: e.clientX - previewX.value, y: e.clientY - previewY.value } + } + + function onMouseMove(e) { + if (!isDragging.value) return + previewX.value = e.clientX - dragStart.value.x + previewY.value = e.clientY - dragStart.value.y + } + + function onMouseUp() { + isDragging.value = false + } + + function resetPreview() { + previewScale.value = 1 + previewX.value = 0 + previewY.value = 0 + } + + function switchPreview(dir) { + previewIndex.value = dir + previewSrc.value = dir === 0 ? compareImages.value.original : compareImages.value.processed + resetPreview() + } + + async function openOutputFolder() { + if (resultInfo.value?.path) { + const dir = resultInfo.value.path.replace(/[/\\][^/\\]+$/, '') + try { + await window.go.main.FileHandler.OpenFolder(dir) + } catch (e) { + ElMessage.error('打开文件夹失败') + } + } + } + + return { + filePath, + filePreview, + imageInfo, + processing, + resultInfo, + outputPath, + compareImages, + config, + showPreview, + previewSrc, + previewIndex, + previewScale, + previewX, + previewY, + isDragging, + dragStart, + formatSize, + getFileName, + selectFile, + loadFilePreview, + resetState, + clearImageFile, + processFile, + selectOutputPath, + downloadResult, + openPreview, + closePreview, + onWheel, + onMouseDown, + onMouseMove, + onMouseUp, + resetPreview, + switchPreview, + openOutputFolder, + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js index f9754fe..cbcfb5d 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,5 +1,17 @@ -import {createApp} from 'vue' +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import 'element-plus/dist/index.css' import App from './App.vue' -import './style.css'; +import router from './router' +import './style.css' -createApp(App).mount('#app') +const app = createApp(App) + +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(ElementPlus) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..731b6e6 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,17 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import HomeView from '../components/HomeView.vue' +import ToolView from '../components/ToolView.vue' +import SettingsView from '../components/SettingsView.vue' + +const routes = [ + { path: '/', name: 'home', component: HomeView }, + { path: '/tool/:category/:action?', name: 'tool', component: ToolView }, + { path: '/settings', name: 'settings', component: SettingsView } +] + +const router = createRouter({ + history: createWebHashHistory(), + routes +}) + +export default router diff --git a/frontend/src/style.css b/frontend/src/style.css index 3940d6c..29d0e87 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,26 +1,321 @@ -html { - background-color: rgba(27, 38, 54, 1); - text-align: center; - color: white; +:root { + --bg-primary: #0a0a0f; + --bg-secondary: #12121a; + --bg-surface: #1a1a25; + --bg-glass: rgba(255, 255, 255, 0.03); + + --text-primary: #f0f0f5; + --text-secondary: #8888a0; + --text-muted: #555570; + + --accent-primary: #3b82f6; + --accent-primary-hover: #60a5fa; + --accent-primary-glow: rgba(59, 130, 246, 0.3); + --accent-blue: #3b82f6; + + --accent-green: #22c55e; + --accent-red: #ef4444; + --accent-yellow: #f59e0b; + --accent-purple: #a855f7; + + --gradient-primary: linear-gradient(135deg, #3b82f6, #8b5cf6); + --gradient-surface: linear-gradient(180deg, rgba(255,255,255,0.05), transparent); + + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 0 20px var(--accent-primary-glow); + + --glass-blur: 20px; + --glass-bg: rgba(18, 18, 26, 0.8); + --glass-border: rgba(255, 255, 255, 0.08); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + + --transition-fast: 0.15s ease; + --transition-normal: 0.25s ease; + --transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); + + --tool-pdf: #f38ba8; + --tool-word: #89b4fa; + --tool-excel: #a6e3a1; + --tool-image: #f9e2af; + --tool-convert: #cba6f7; } -body { - margin: 0; - color: white; - font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", - "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", - sans-serif; +* { + margin: 0; + padding: 0; + box-sizing: border-box; } -@font-face { - font-family: "Nunito"; - font-style: normal; - font-weight: 400; - src: local(""), - url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2"); +html, body { + height: 100%; + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } #app { - height: 100vh; - text-align: center; + height: 100vh; + overflow: hidden; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--bg-surface); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Element Plus Overrides */ +.el-menu { + border-right: none !important; + background-color: transparent !important; +} + +.el-card { + background-color: var(--bg-secondary) !important; + border-color: var(--glass-border) !important; + border-radius: var(--radius-md) !important; + backdrop-filter: blur(var(--glass-blur)); +} + +.el-input__wrapper { + background-color: var(--bg-surface) !important; + box-shadow: 0 0 0 1px var(--glass-border) inset !important; + border-radius: var(--radius-sm) !important; + transition: all var(--transition-normal) !important; +} + +.el-input__wrapper:hover { + box-shadow: 0 0 0 1px rgba(255,255,255,0.15) inset !important; +} + +.el-input__wrapper.is-focus { + box-shadow: 0 0 0 1px var(--accent-primary) inset, 0 0 12px var(--accent-primary-glow) !important; +} + +.el-input__inner { + color: var(--text-primary) !important; +} + +.el-input-number { + --el-input-number-step-button-bg-color: var(--bg-surface); + --el-input-number-step-button-border-color: var(--glass-border); +} + +.el-button--primary { + --el-button-bg-color: var(--accent-primary); + --el-button-border-color: var(--accent-primary); + --el-button-text-color: #ffffff; + --el-button-hover-bg-color: var(--accent-primary-hover); + --el-button-hover-border-color: var(--accent-primary-hover); + --el-button-hover-text-color: #ffffff; + --el-button-active-bg-color: #2563eb; + --el-button-active-border-color: #2563eb; + border-radius: var(--radius-sm) !important; + transition: all var(--transition-normal) !important; +} + +.el-button--primary:hover { + box-shadow: var(--shadow-glow); +} + +.el-button { + border-radius: var(--radius-sm) !important; + transition: all var(--transition-fast) !important; +} + +.el-button:active { + transform: scale(0.98); +} + +.el-button.is-text { + border-radius: 6px !important; +} + +.el-message-box { + background-color: var(--glass-bg) !important; + border-color: var(--glass-border) !important; + backdrop-filter: blur(var(--glass-blur)); + border-radius: var(--radius-lg) !important; +} + +.el-progress-bar__outer { + background-color: var(--bg-surface) !important; + border-radius: 100px !important; +} + +.el-progress-bar__inner { + border-radius: 100px !important; +} + +.el-tag { + border-radius: var(--radius-sm) !important; +} + +.el-upload-dragger { + background-color: var(--bg-surface) !important; + border-color: var(--glass-border) !important; + border-radius: var(--radius-md) !important; + transition: all var(--transition-normal) !important; +} + +.el-upload-dragger:hover { + border-color: var(--accent-primary) !important; + background-color: rgba(59, 130, 246, 0.05) !important; +} + +.el-scrollbar__bar { + opacity: 0 !important; +} + +.el-scrollbar:hover .el-scrollbar__bar { + opacity: 1 !important; +} + +/* macOS Window Controls */ +.window-controls { + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px; + height: 52px; + -webkit-app-region: drag; + user-select: none; +} + +.window-btn { + width: 12px; + height: 12px; + border-radius: 50%; + border: none; + cursor: pointer; + position: relative; + transition: all var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + -webkit-app-region: no-drag; +} + +.window-btn svg { + width: 8px; + height: 8px; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.window-btn:hover svg { + opacity: 1; +} + +.window-btn-close { + background-color: #ff5f57; +} + +.window-btn-close:hover { + background-color: #ff4040; +} + +.window-btn-close svg { + stroke: #4a0000; +} + +.window-btn-minimize { + background-color: #febc2e; +} + +.window-btn-minimize:hover { + background-color: #f5a623; +} + +.window-btn-minimize svg { + stroke: #5a3e00; +} + +.window-btn-maximize { + background-color: #28c840; +} + +.window-btn-maximize:hover { + background-color: #1aab29; +} + +.window-btn-maximize svg { + stroke: #003d00; +} + +/* Glass Card */ +.glass-card { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); +} + +.glass-card:hover { + border-color: rgba(255, 255, 255, 0.12); +} + +/* Glow Effect */ +.glow-on-hover { + transition: all var(--transition-normal); +} + +.glow-on-hover:hover { + box-shadow: var(--shadow-glow); + border-color: var(--accent-primary); +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.animate-fade-in { + animation: fadeIn var(--transition-slow) forwards; +} + +.animate-slide-up { + animation: slideUp var(--transition-slow) forwards; +} + +.animate-pulse { + animation: pulse 2s ease-in-out infinite; } diff --git a/frontend/wailsjs/go/main/FileHandler.d.ts b/frontend/wailsjs/go/main/FileHandler.d.ts new file mode 100644 index 0000000..30745df --- /dev/null +++ b/frontend/wailsjs/go/main/FileHandler.d.ts @@ -0,0 +1,31 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {main} from '../models'; + +export function AddRecentUse(arg1:main.RecentUse):Promise; + +export function GetCompareImages(arg1:string,arg2:string):Promise>; + +export function GetConfig():Promise; + +export function GetFileInfo(arg1:string):Promise>; + +export function GetImageBase64(arg1:string):Promise; + +export function GetRecentUses():Promise>; + +export function GetShortcuts():Promise>; + +export function OpenFileDialog(arg1:string,arg2:Array):Promise; + +export function OpenFolder(arg1:string):Promise; + +export function OpenSaveDialog(arg1:string,arg2:string,arg3:Array,arg4:string):Promise; + +export function ProcessFile(arg1:main.ProcessRequest):Promise; + +export function SaveConfig(arg1:main.AppConfig):Promise; + +export function SaveResult(arg1:string,arg2:string):Promise; + +export function SaveShortcuts(arg1:Array):Promise; diff --git a/frontend/wailsjs/go/main/FileHandler.js b/frontend/wailsjs/go/main/FileHandler.js new file mode 100644 index 0000000..9920639 --- /dev/null +++ b/frontend/wailsjs/go/main/FileHandler.js @@ -0,0 +1,59 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function AddRecentUse(arg1) { + return window['go']['main']['FileHandler']['AddRecentUse'](arg1); +} + +export function GetCompareImages(arg1, arg2) { + return window['go']['main']['FileHandler']['GetCompareImages'](arg1, arg2); +} + +export function GetConfig() { + return window['go']['main']['FileHandler']['GetConfig'](); +} + +export function GetFileInfo(arg1) { + return window['go']['main']['FileHandler']['GetFileInfo'](arg1); +} + +export function GetImageBase64(arg1) { + return window['go']['main']['FileHandler']['GetImageBase64'](arg1); +} + +export function GetRecentUses() { + return window['go']['main']['FileHandler']['GetRecentUses'](); +} + +export function GetShortcuts() { + return window['go']['main']['FileHandler']['GetShortcuts'](); +} + +export function OpenFileDialog(arg1, arg2) { + return window['go']['main']['FileHandler']['OpenFileDialog'](arg1, arg2); +} + +export function OpenFolder(arg1) { + return window['go']['main']['FileHandler']['OpenFolder'](arg1); +} + +export function OpenSaveDialog(arg1, arg2, arg3, arg4) { + return window['go']['main']['FileHandler']['OpenSaveDialog'](arg1, arg2, arg3, arg4); +} + +export function ProcessFile(arg1) { + return window['go']['main']['FileHandler']['ProcessFile'](arg1); +} + +export function SaveConfig(arg1) { + return window['go']['main']['FileHandler']['SaveConfig'](arg1); +} + +export function SaveResult(arg1, arg2) { + return window['go']['main']['FileHandler']['SaveResult'](arg1, arg2); +} + +export function SaveShortcuts(arg1) { + return window['go']['main']['FileHandler']['SaveShortcuts'](arg1); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts new file mode 100644 index 0000000..a66ba9a --- /dev/null +++ b/frontend/wailsjs/go/models.ts @@ -0,0 +1,226 @@ +export namespace main { + + export class AppConfig { + defaultOutputDir: string; + + static createFrom(source: any = {}) { + return new AppConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.defaultOutputDir = source["defaultOutputDir"]; + } + } + export class FileResult { + success: boolean; + message: string; + path?: string; + size?: number; + originalSize?: number; + + static createFrom(source: any = {}) { + return new FileResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.success = source["success"]; + this.message = source["message"]; + this.path = source["path"]; + this.size = source["size"]; + this.originalSize = source["originalSize"]; + } + } + export class ProcessRequest { + inputPath: string; + outputPath?: string; + format?: string; + quality?: string; + qualityInt?: number; + width?: number; + height?: number; + angle?: number; + bgColor?: string; + bgRemoveColor?: services.ColorRGB; + text?: string; + threshold?: number; + shape?: services.CropShape; + brightness?: number; + contrast?: number; + saturation?: number; + grayscaleIntensity?: number; + sharpenAmount?: number; + blurRadius?: number; + flipH?: boolean; + flipV?: boolean; + maintainRatio?: boolean; + cropX?: number; + cropY?: number; + + static createFrom(source: any = {}) { + return new ProcessRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.inputPath = source["inputPath"]; + this.outputPath = source["outputPath"]; + this.format = source["format"]; + this.quality = source["quality"]; + this.qualityInt = source["qualityInt"]; + this.width = source["width"]; + this.height = source["height"]; + this.angle = source["angle"]; + this.bgColor = source["bgColor"]; + this.bgRemoveColor = this.convertValues(source["bgRemoveColor"], services.ColorRGB); + this.text = source["text"]; + this.threshold = source["threshold"]; + this.shape = this.convertValues(source["shape"], services.CropShape); + this.brightness = source["brightness"]; + this.contrast = source["contrast"]; + this.saturation = source["saturation"]; + this.grayscaleIntensity = source["grayscaleIntensity"]; + this.sharpenAmount = source["sharpenAmount"]; + this.blurRadius = source["blurRadius"]; + this.flipH = source["flipH"]; + this.flipV = source["flipV"]; + this.maintainRatio = source["maintainRatio"]; + this.cropX = source["cropX"]; + this.cropY = source["cropY"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class RecentUse { + id: string; + name: string; + category: string; + usedAt: number; + + static createFrom(source: any = {}) { + return new RecentUse(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.category = source["category"]; + this.usedAt = source["usedAt"]; + } + } + export class Shortcut { + id: string; + name: string; + category: string; + icon: string; + + static createFrom(source: any = {}) { + return new Shortcut(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.category = source["category"]; + this.icon = source["icon"]; + } + } + +} + +export namespace services { + + export class ColorRGB { + r: number; + g: number; + b: number; + + static createFrom(source: any = {}) { + return new ColorRGB(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.r = source["r"]; + this.g = source["g"]; + this.b = source["b"]; + } + } + export class Point { + x: number; + y: number; + + static createFrom(source: any = {}) { + return new Point(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.x = source["x"]; + this.y = source["y"]; + } + } + export class CropShape { + type: string; + x: number; + y: number; + width: number; + height: number; + radius: number; + points: Point[]; + + static createFrom(source: any = {}) { + return new CropShape(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.type = source["type"]; + this.x = source["x"]; + this.y = source["y"]; + this.width = source["width"]; + this.height = source["height"]; + this.radius = source["radius"]; + this.points = this.convertValues(source["points"], Point); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + diff --git a/go.mod b/go.mod index f3b4551..077859b 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,18 @@ module xk -go 1.23.0 +go 1.25.0 -require github.com/wailsapp/wails/v2 v2.12.0 +require ( + github.com/disintegration/imaging v1.6.2 + github.com/signintech/gopdf v0.36.1 + github.com/wailsapp/wails/v2 v2.12.0 + github.com/xuri/excelize/v2 v2.10.1 + golang.org/x/image v0.41.0 +) require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect - github.com/disintegration/imaging v1.6.2 // 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 @@ -21,20 +26,25 @@ 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/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 + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.49.1 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/image v0.12.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.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 9165b2a..f8c0ae3 100644 --- a/go.sum +++ b/go.sum @@ -38,19 +38,30 @@ 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/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= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/signintech/gopdf v0.36.1 h1:cGpvEKvvqCV+ZXB9R2SQoWgouW91JpwsgoQEhLxIdp0= +github.com/signintech/gopdf v0.36.1/go.mod h1:d23eO35GpEliSrF22eJ4bsM3wVeQJTjXTHq5x5qGKjA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -63,54 +74,33 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ= -golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +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= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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 new file mode 100644 index 0000000..3dcd466 --- /dev/null +++ b/handlers.go @@ -0,0 +1,593 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + stdruntime "runtime" + "strings" + + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "xk/services" +) + +type FileHandler struct { + ctx context.Context + pdfService *services.PDFService + wordService *services.WordService + excelService *services.ExcelService + imageService *services.ImageService +} + +func NewFileHandler() *FileHandler { + return &FileHandler{ + pdfService: services.NewPDFService(), + wordService: services.NewWordService(), + excelService: services.NewExcelService(), + imageService: services.NewImageService(), + } +} + +func (h *FileHandler) startup(ctx context.Context) { + h.ctx = ctx +} + +type FileResult struct { + Success bool `json:"success"` + Message string `json:"message"` + Path string `json:"path,omitempty"` + Size int64 `json:"size,omitempty"` + OriginalSize int64 `json:"originalSize,omitempty"` +} + +type ProcessRequest struct { + InputPath string `json:"inputPath"` + OutputPath string `json:"outputPath,omitempty"` + Format string `json:"format,omitempty"` + Quality string `json:"quality,omitempty"` + QualityInt int `json:"qualityInt,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Angle float64 `json:"angle,omitempty"` + BgColor string `json:"bgColor,omitempty"` + BgRemoveColor *services.ColorRGB `json:"bgRemoveColor,omitempty"` + Text string `json:"text,omitempty"` + Threshold int `json:"threshold,omitempty"` + Shape *services.CropShape `json:"shape,omitempty"` + Brightness int `json:"brightness,omitempty"` + Contrast int `json:"contrast,omitempty"` + Saturation int `json:"saturation,omitempty"` + GrayscaleIntensity int `json:"grayscaleIntensity,omitempty"` + SharpenAmount float64 `json:"sharpenAmount,omitempty"` + BlurRadius float64 `json:"blurRadius,omitempty"` + FlipH bool `json:"flipH,omitempty"` + FlipV bool `json:"flipV,omitempty"` + MaintainRatio *bool `json:"maintainRatio,omitempty"` + CropX int `json:"cropX,omitempty"` + CropY int `json:"cropY,omitempty"` +} + +type AppConfig struct { + DefaultOutputDir string `json:"defaultOutputDir"` +} + +type Shortcut struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Icon string `json:"icon"` +} + +type RecentUse struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + UsedAt int64 `json:"usedAt"` +} + +func (h *FileHandler) OpenFileDialog(title string, filters []string) (string, error) { + var fileFilters []wailsRuntime.FileFilter + if title == "" { + title = "选择文件" + } + if len(filters) == 0 { + filters = []string{"*"} + } + for _, f := range filters { + displayName := "所有文件 (*.*)" + switch f { + case "*.pdf": + displayName = "PDF 文件 (*.pdf)" + case "*.doc;*.docx": + displayName = "Word 文档 (*.doc;*.docx)" + case "*.xls;*.xlsx": + displayName = "Excel 文件 (*.xls;*.xlsx)" + case "*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.tiff;*.webp;*.ico": + displayName = "图片文件 (*.jpg;*.png;*.gif;*.bmp;*.tiff;*.webp;*.ico)" + } + fileFilters = append(fileFilters, wailsRuntime.FileFilter{DisplayName: displayName, Pattern: f}) + } + + path, err := wailsRuntime.OpenFileDialog(h.ctx, wailsRuntime.OpenDialogOptions{Title: title, Filters: fileFilters}) + return path, err +} + +func (h *FileHandler) OpenSaveDialog(title string, defaultFilename string, filters []string, defaultDir string) (string, error) { + if title == "" { + title = "保存文件" + } + var fileFilters []wailsRuntime.FileFilter + for _, f := range filters { + displayName := "所有文件 (*.*)" + switch f { + case "*.pdf": + displayName = "PDF 文件 (*.pdf)" + case "*.csv": + displayName = "CSV 文件 (*.csv)" + case "*.json": + displayName = "JSON 文件 (*.json)" + case "*.jpg;*.jpeg": + displayName = "JPEG 图片 (*.jpg;*.jpeg)" + case "*.png": + displayName = "PNG 图片 (*.png)" + case "*.ico": + displayName = "ICO 图标 (*.ico)" + } + fileFilters = append(fileFilters, wailsRuntime.FileFilter{DisplayName: displayName, Pattern: f}) + } + path, err := wailsRuntime.SaveFileDialog(h.ctx, wailsRuntime.SaveDialogOptions{Title: title, DefaultFilename: defaultFilename, DefaultDirectory: defaultDir, Filters: fileFilters}) + return path, err +} + +func (h *FileHandler) OpenFolder(path string) error { + if path == "" { + return fmt.Errorf("路径不能为空") + } + var cmd *exec.Cmd + switch stdruntime.GOOS { + case "windows": + cmd = exec.Command("explorer", path) + case "darwin": + cmd = exec.Command("open", path) + default: + cmd = exec.Command("xdg-open", path) + } + return cmd.Start() +} + +func (h *FileHandler) GetConfig() AppConfig { + config := AppConfig{DefaultOutputDir: getDefaultOutputDir()} + data, err := os.ReadFile(getConfigPath()) + if err != nil { + return config + } + _ = services.ParseJSON(data, &config) + return config +} + +func (h *FileHandler) SaveConfig(config AppConfig) error { + data, err := services.ToJSON(config) + if err != nil { + return err + } + return os.WriteFile(getConfigPath(), data, 0644) +} + +func (h *FileHandler) GetShortcuts() []Shortcut { + var shortcuts []Shortcut + data, err := os.ReadFile(getShortcutsPath()) + if err != nil { + return getDefaultShortcuts() + } + _ = services.ParseJSON(data, &shortcuts) + if len(shortcuts) == 0 { + return getDefaultShortcuts() + } + return shortcuts +} + +func (h *FileHandler) SaveShortcuts(shortcuts []Shortcut) error { + data, err := services.ToJSON(shortcuts) + if err != nil { + return err + } + return os.WriteFile(getShortcutsPath(), data, 0644) +} + +func (h *FileHandler) GetRecentUses() []RecentUse { + var recent []RecentUse + data, err := os.ReadFile(getRecentPath()) + if err != nil { + return []RecentUse{} + } + _ = services.ParseJSON(data, &recent) + return recent +} + +func (h *FileHandler) AddRecentUse(tool RecentUse) error { + var recent []RecentUse + data, err := os.ReadFile(getRecentPath()) + if err == nil { + _ = services.ParseJSON(data, &recent) + } + for i, r := range recent { + if r.ID == tool.ID { + recent = append(recent[:i], recent[i+1:]...) + break + } + } + recent = append([]RecentUse{tool}, recent...) + if len(recent) > 5 { + recent = recent[:5] + } + data, err = services.ToJSON(recent) + if err != nil { + return err + } + return os.WriteFile(getRecentPath(), data, 0644) +} + +func (h *FileHandler) GetImageBase64(path string) (string, error) { + return h.imageService.GetImageBase64(path) +} + +func (h *FileHandler) GetCompareImages(originalPath, processedPath string) (map[string]string, error) { + result := make(map[string]string) + if originalPath != "" { + b64, err := h.imageService.GetImageBase64(originalPath) + if err == nil { + result["original"] = b64 + } + } + if processedPath != "" { + b64, err := h.imageService.GetImageBase64(processedPath) + if err == nil { + result["processed"] = b64 + } + } + return result, nil +} + +func (h *FileHandler) SaveResult(tempPath, outputPath string) (FileResult, error) { + if outputPath == "" { + return FileResult{}, fmt.Errorf("未指定输出路径") + } + + data, err := os.ReadFile(tempPath) + if err != nil { + return FileResult{}, fmt.Errorf("读取临时文件失败: %v", err) + } + + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return FileResult{}, fmt.Errorf("创建目录失败: %v", err) + } + + if err := os.WriteFile(outputPath, data, 0644); err != nil { + return FileResult{}, fmt.Errorf("保存文件失败: %v", err) + } + + info, err := os.Stat(outputPath) + if err != nil { + return FileResult{}, fmt.Errorf("获取文件信息失败: %v", err) + } + + return FileResult{ + Success: true, + Message: "保存成功", + Path: outputPath, + Size: info.Size(), + }, nil +} + +func (h *FileHandler) ProcessFile(req ProcessRequest) FileResult { + if req.InputPath == "" { + return FileResult{Success: false, Message: "请选择文件"} + } + + ext := strings.ToLower(filepath.Ext(req.InputPath)) + + switch ext { + case ".pdf": + return h.processPDF(req) + case ".doc", ".docx": + return h.processWord(req) + case ".xls", ".xlsx": + return h.processExcel(req) + case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".ico": + return h.processImage(req) + default: + return FileResult{Success: false, Message: fmt.Sprintf("不支持的文件格式: %s", ext)} + } +} + +func (h *FileHandler) resolveOutputPath(inputPath, outputPath, suffix, ext string) string { + if outputPath != "" { + return outputPath + } + config := h.GetConfig() + outDir := config.DefaultOutputDir + if outDir == "" { + outDir = filepath.Dir(inputPath) + } + baseName := services.GetBaseName(inputPath) + return filepath.Join(outDir, baseName+suffix+ext) +} + +func (h *FileHandler) getTempPath(inputPath, suffix string) string { + tempDir := os.TempDir() + baseName := services.GetBaseName(inputPath) + return filepath.Join(tempDir, "xk_"+baseName+suffix+".png") +} + +func (h *FileHandler) processPDF(req ProcessRequest) FileResult { + var err error + outputPath := h.resolveOutputPath(req.InputPath, req.OutputPath, "_processed", ".pdf") + + switch req.Format { + case "compress": + err = h.pdfService.CompressPDF(req.InputPath, outputPath, req.Quality) + case "split": + _, err = h.pdfService.SplitPDF(req.InputPath, filepath.Dir(outputPath), "") + case "rotate": + err = h.pdfService.RotatePDF(req.InputPath, outputPath, int(req.Angle)) + case "watermark": + err = h.pdfService.AddWatermark(req.InputPath, outputPath, req.Text) + default: + err = h.pdfService.CompressPDF(req.InputPath, outputPath, "medium") + } + + if err != nil { + return FileResult{Success: false, Message: err.Error()} + } + return h.getFileResult(req.InputPath, outputPath) +} + +func (h *FileHandler) processWord(req ProcessRequest) FileResult { + outputPath := h.resolveOutputPath(req.InputPath, req.OutputPath, "", ".pdf") + err := h.wordService.ConvertToPDF(req.InputPath, outputPath) + if err != nil { + return FileResult{Success: false, Message: err.Error()} + } + return h.getFileResult(req.InputPath, outputPath) +} + +func (h *FileHandler) processExcel(req ProcessRequest) FileResult { + outputExt := ".csv" + if req.Format == "json" { + outputExt = ".json" + } + outputPath := h.resolveOutputPath(req.InputPath, req.OutputPath, "", outputExt) + + var err error + switch req.Format { + case "csv": + err = h.excelService.ConvertToCSV(req.InputPath, outputPath) + case "json": + err = h.excelService.ConvertToJSON(req.InputPath, outputPath) + default: + err = h.excelService.ConvertToCSV(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" || + req.Format == "removebg" || req.Format == "crop" || req.Format == "flipH" || req.Format == "flipV" || + req.Format == "sharpen" || req.Format == "blur" || req.Format == "invert" + + var outputPath string + if isImageOp { + outputPath = h.getTempPath(req.InputPath, "_processed") + } else if req.Format == "convert" { + outputPath = h.resolveOutputPath(req.InputPath, req.OutputPath, "", "."+h.getConvertFormat(req)) + } else { + outputPath = h.resolveOutputPath(req.InputPath, req.OutputPath, "", "."+req.Format) + } + + var err error + switch req.Format { + case "compress": + quality := 85 + if req.QualityInt > 0 { + quality = req.QualityInt + } else { + switch req.Quality { + case "high": + quality = 95 + case "low": + quality = 60 + } + } + err = h.imageService.CompressImage(req.InputPath, outputPath, quality, req.Width, req.Height) + case "resize": + maintainRatio := true + if req.MaintainRatio != nil { + maintainRatio = *req.MaintainRatio + } + err = h.imageService.ResizeImage(req.InputPath, outputPath, req.Width, req.Height, maintainRatio) + case "rotate": + err = h.imageService.RotateImage(req.InputPath, outputPath, req.Angle, req.BgColor) + if err == nil && req.FlipH { + err = h.imageService.FlipH(outputPath, outputPath) + } + if err == nil && req.FlipV { + err = h.imageService.FlipV(outputPath, outputPath) + } + case "grayscale": + intensity := req.GrayscaleIntensity + if intensity <= 0 { + intensity = 100 + } + err = h.imageService.AdjustGrayscale(req.InputPath, outputPath, intensity) + case "brightness": + brightness := float64(req.Brightness) + err = h.imageService.AdjustBrightness(req.InputPath, outputPath, brightness) + if err == nil && req.Contrast != 0 { + contrastPath := h.getTempPath(req.InputPath, "_contrast") + if err2 := h.imageService.AdjustContrast(outputPath, contrastPath, float64(req.Contrast)); err2 != nil { + err = err2 + } else { + os.Remove(outputPath) + os.Rename(contrastPath, outputPath) + } + } + if err == nil && req.Saturation != 0 { + satPath := h.getTempPath(req.InputPath, "_saturation") + if err2 := h.imageService.AdjustSaturation(outputPath, satPath, float64(req.Saturation)); err2 != nil { + err = err2 + } else { + os.Remove(outputPath) + os.Rename(satPath, outputPath) + } + } + case "flipH": + err = h.imageService.FlipH(req.InputPath, outputPath) + case "flipV": + err = h.imageService.FlipV(req.InputPath, outputPath) + case "sharpen": + amount := req.SharpenAmount + if amount <= 0 { + amount = 1.0 + } + err = h.imageService.SharpenImage(req.InputPath, outputPath, amount) + case "blur": + radius := req.BlurRadius + if radius <= 0 { + radius = 3.0 + } + err = h.imageService.BlurImage(req.InputPath, outputPath, radius) + case "invert": + err = h.imageService.InvertImage(req.InputPath, outputPath) + case "removebg": + err = h.imageService.RemoveBackground(req.InputPath, outputPath, req.Threshold, req.BgRemoveColor) + case "crop": + if req.Shape != nil { + shape := *req.Shape + if shape.X == 0 && shape.Y == 0 && (req.CropX != 0 || req.CropY != 0) { + shape.X = req.CropX + shape.Y = req.CropY + } + err = h.imageService.CropImageShape(req.InputPath, outputPath, shape) + } else { + err = h.imageService.CropImageShape(req.InputPath, outputPath, services.CropShape{ + Type: "rectangle", X: req.CropX, Y: req.CropY, Width: req.Width, Height: req.Height, + }) + } + default: + quality := 85 + if req.QualityInt > 0 { + quality = req.QualityInt + } + err = h.imageService.ConvertFormat(req.InputPath, outputPath, req.Format, quality) + } + + if err != nil { + return FileResult{Success: false, Message: err.Error()} + } + + result := h.getFileResult(req.InputPath, outputPath) + if result.Success && isImageOp { + result.Path = outputPath + } + return result +} + +func (h *FileHandler) getConvertFormat(req ProcessRequest) string { + format := req.Format + if format == "convert" { + return "png" + } + return format +} + +func (h *FileHandler) getFileResult(inputPath, outputPath string) FileResult { + info, err := os.Stat(outputPath) + if err != nil { + return FileResult{Success: false, Message: fmt.Sprintf("获取文件信息失败: %v", err)} + } + result := FileResult{Success: true, Message: "处理完成", Path: outputPath, Size: info.Size()} + if inputPath != "" { + if inputInfo, err := os.Stat(inputPath); err == nil { + result.OriginalSize = inputInfo.Size() + } + } + return result +} + +func (h *FileHandler) GetFileInfo(path string) map[string]interface{} { + ext := strings.ToLower(filepath.Ext(path)) + result := make(map[string]interface{}) + switch ext { + case ".pdf": + info, err := h.pdfService.GetPDFInfo(path) + if err == nil { + result["type"] = "pdf" + result["pages"] = info.Pages + result["size"] = info.Size + } + case ".doc", ".docx": + info, err := h.wordService.GetWordInfo(path) + if err == nil { + result["type"] = "word" + result["title"] = info.Title + result["words"] = info.Words + result["size"] = info.Size + } + case ".xls", ".xlsx": + info, err := h.excelService.GetExcelInfo(path) + if err == nil { + result["type"] = "excel" + result["sheets"] = info.Sheets + result["size"] = info.Size + } + case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".ico": + info, err := h.imageService.GetImageInfo(path) + if err == nil { + result["type"] = "image" + result["width"] = info.Width + result["height"] = info.Height + result["format"] = info.Format + result["size"] = info.Size + } + } + return result +} + +func getConfigPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".xk-config.json") +} + +func getShortcutsPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".xk-shortcuts.json") +} + +func getRecentPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".xk-recent.json") +} + +func getDefaultOutputDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Desktop") +} + +func getDefaultShortcuts() []Shortcut { + return []Shortcut{ + {ID: "pdf-compress", Name: "压缩PDF", Category: "pdf", Icon: "FolderChecked"}, + {ID: "image-compress", Name: "压缩图片", Category: "image", Icon: "FolderChecked"}, + {ID: "image-convert", Name: "图片格式转换", Category: "image", Icon: "Switch"}, + {ID: "excel-csv", Name: "Excel转CSV", Category: "excel", Icon: "Document"}, + {ID: "word-pdf", Name: "Word转PDF", Category: "word", Icon: "Document"}, + } +} diff --git a/main.go b/main.go index f213ae8..e383bcd 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "embed" "github.com/wailsapp/wails/v2" @@ -12,21 +13,24 @@ import ( var assets embed.FS func main() { - // Create an instance of the app structure app := NewApp() + fileHandler := NewFileHandler() - // Create application with options err := wails.Run(&options.App{ - Title: "xk", - Width: 1024, - Height: 768, + Title: "XK 文件工具箱", + Width: 1280, + Height: 860, AssetServer: &assetserver.Options{ Assets: assets, }, - BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, - OnStartup: app.startup, + BackgroundColour: &options.RGBA{R: 13, G: 17, B: 23, A: 1}, + OnStartup: func(ctx context.Context) { + app.startup(ctx) + fileHandler.startup(ctx) + }, Bind: []interface{}{ app, + fileHandler, }, }) diff --git a/services/excel_service.go b/services/excel_service.go new file mode 100644 index 0000000..d3d740b --- /dev/null +++ b/services/excel_service.go @@ -0,0 +1,261 @@ +package services + +import ( + "fmt" + "os" + + "github.com/xuri/excelize/v2" +) + +type ExcelService struct{} + +func NewExcelService() *ExcelService { + return &ExcelService{} +} + +type ExcelInfo struct { + Sheets []string `json:"sheets"` + SheetCount int `json:"sheetCount"` + FilePath string `json:"filePath"` + Size int64 `json:"size"` +} + +type SheetData struct { + Name string `json:"name"` + Rows int `json:"rows"` + Columns int `json:"columns"` + Data [][]string `json:"data"` +} + +func (s *ExcelService) GetExcelInfo(filePath string) (*ExcelInfo, error) { + f, err := excelize.OpenFile(filePath) + if err != nil { + return nil, fmt.Errorf("打开Excel文件失败: %v", err) + } + defer f.Close() + + fileInfo, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("获取文件信息失败: %v", err) + } + + sheets := f.GetSheetList() + + return &ExcelInfo{ + Sheets: sheets, + SheetCount: len(sheets), + FilePath: filePath, + Size: fileInfo.Size(), + }, nil +} + +func (s *ExcelService) GetSheetData(filePath, sheetName string) (*SheetData, error) { + f, err := excelize.OpenFile(filePath) + if err != nil { + return nil, fmt.Errorf("打开Excel文件失败: %v", err) + } + defer f.Close() + + if sheetName == "" { + sheets := f.GetSheetList() + if len(sheets) == 0 { + return nil, fmt.Errorf("工作簿中没有工作表") + } + sheetName = sheets[0] + } + + rows, err := f.GetRows(sheetName) + if err != nil { + return nil, fmt.Errorf("读取工作表失败: %v", err) + } + + maxCols := 0 + for _, row := range rows { + if len(row) > maxCols { + maxCols = len(row) + } + } + + return &SheetData{ + Name: sheetName, + Rows: len(rows), + Columns: maxCols, + Data: rows, + }, nil +} + +func (s *ExcelService) ConvertToCSV(inputPath, outputPath string) error { + if outputPath == "" { + outputPath = ChangeExtension(inputPath, ".csv") + } + + f, err := excelize.OpenFile(inputPath) + if err != nil { + return fmt.Errorf("打开Excel文件失败: %v", err) + } + defer f.Close() + + sheets := f.GetSheetList() + if len(sheets) == 0 { + return fmt.Errorf("工作簿中没有工作表") + } + + rows, err := f.GetRows(sheets[0]) + if err != nil { + return fmt.Errorf("读取工作表失败: %v", err) + } + + outFile, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("创建输出文件失败: %v", err) + } + defer outFile.Close() + + for _, row := range rows { + line := "" + for i, cell := range row { + if i > 0 { + line += "," + } + if containsComma(cell) { + line += "\"" + cell + "\"" + } else { + line += cell + } + } + _, err := outFile.WriteString(line + "\n") + if err != nil { + return fmt.Errorf("写入CSV失败: %v", err) + } + } + + return nil +} + +func (s *ExcelService) ConvertToJSON(inputPath, outputPath string) error { + if outputPath == "" { + outputPath = ChangeExtension(inputPath, ".json") + } + + f, err := excelize.OpenFile(inputPath) + if err != nil { + return fmt.Errorf("打开Excel文件失败: %v", err) + } + defer f.Close() + + sheets := f.GetSheetList() + if len(sheets) == 0 { + return fmt.Errorf("工作簿中没有工作表") + } + + rows, err := f.GetRows(sheets[0]) + if err != nil { + return fmt.Errorf("读取工作表失败: %v", err) + } + + if len(rows) < 2 { + return fmt.Errorf("工作表数据不足") + } + + headers := rows[0] + var jsonData []map[string]string + + for _, row := range rows[1:] { + record := make(map[string]string) + for i, header := range headers { + if i < len(row) { + record[header] = row[i] + } else { + record[header] = "" + } + } + jsonData = append(jsonData, record) + } + + outFile, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("创建输出文件失败: %v", err) + } + defer outFile.Close() + + _, err = outFile.WriteString("[\n") + if err != nil { + return err + } + + for i, record := range jsonData { + _, err = outFile.WriteString(" {\n") + if err != nil { + return err + } + + j := 0 + for key, value := range record { + _, err = outFile.WriteString(fmt.Sprintf(" \"%s\": \"%s\"", key, value)) + if err != nil { + return err + } + if j < len(record)-1 { + _, err = outFile.WriteString(",\n") + } else { + _, err = outFile.WriteString("\n") + } + if err != nil { + return err + } + j++ + } + + _, err = outFile.WriteString(" }") + if err != nil { + return err + } + + if i < len(jsonData)-1 { + _, err = outFile.WriteString(",\n") + } else { + _, err = outFile.WriteString("\n") + } + if err != nil { + return err + } + } + + _, err = outFile.WriteString("]") + return err +} + +func (s *ExcelService) CreateExcelFromData(outputPath string, data [][]string) error { + f := excelize.NewFile() + defer f.Close() + + sheetName := "Sheet1" + index, err := f.NewSheet(sheetName) + if err != nil { + return fmt.Errorf("创建工作表失败: %v", err) + } + + f.SetActiveSheet(index) + + for i, row := range data { + for j, cell := range row { + cellName, _ := excelize.CoordinatesToCellName(j+1, i+1) + f.SetCellValue(sheetName, cellName, cell) + } + } + + if err := f.SaveAs(outputPath); err != nil { + return fmt.Errorf("保存Excel文件失败: %v", err) + } + + return nil +} + +func containsComma(s string) bool { + for _, c := range s { + if c == ',' { + return true + } + } + return false +} diff --git a/services/image_service.go b/services/image_service.go new file mode 100644 index 0000000..a16c41f --- /dev/null +++ b/services/image_service.go @@ -0,0 +1,829 @@ +package services + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/color" + "image/png" + "math" + "os" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" + _ "golang.org/x/image/webp" +) + +// supportedFormats lists formats that imaging.Open() can handle +var supportedFormats = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".gif": true, + ".bmp": true, + ".tiff": true, + ".tif": true, + ".webp": true, +} + +func (s *ImageService) checkFormat(filePath string) error { + ext := strings.ToLower(filepath.Ext(filePath)) + if ext == ".ico" { + return fmt.Errorf("ICO 格式暂不支持,请先转换为 PNG 或 JPG 格式") + } + if !supportedFormats[ext] { + return fmt.Errorf("不支持的图片格式: %s", ext) + } + return nil +} + +type ImageService struct{} + +func NewImageService() *ImageService { + return &ImageService{} +} + +type ImageInfo struct { + Width int `json:"width"` + Height int `json:"height"` + Format string `json:"format"` + FilePath string `json:"filePath"` + Size int64 `json:"size"` +} + +func (s *ImageService) GetImageInfo(filePath string) (*ImageInfo, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("打开图片文件失败: %v", err) + } + defer file.Close() + + config, format, err := image.DecodeConfig(file) + if err != nil { + return nil, fmt.Errorf("解码图片配置失败: %v", err) + } + + fileInfo, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("获取文件信息失败: %v", err) + } + + return &ImageInfo{ + Width: config.Width, + Height: config.Height, + Format: format, + FilePath: filePath, + Size: fileInfo.Size(), + }, nil +} + +func (s *ImageService) GetImageBase64(filePath string) (string, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + + ext := strings.ToLower(filepath.Ext(filePath)) + mimeType := "image/png" + switch ext { + case ".jpg", ".jpeg": + mimeType = "image/jpeg" + case ".gif": + mimeType = "image/gif" + case ".bmp": + mimeType = "image/bmp" + case ".webp": + mimeType = "image/webp" + case ".ico": + mimeType = "image/x-icon" + } + + return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), nil +} + +func (s *ImageService) ConvertFormat(inputPath, outputPath, format string, quality int) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = ChangeExtension(inputPath, "."+format) + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + switch strings.ToLower(format) { + case "jpeg", "jpg": + if quality <= 0 || quality > 100 { + quality = 85 + } + err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality)) + case "png": + err = imaging.Save(img, outputPath, imaging.PNGCompressionLevel(png.BestCompression)) + case "gif": + err = imaging.Save(img, outputPath, imaging.GIFNumColors(256)) + case "bmp": + err = imaging.Save(img, outputPath) + case "tiff": + err = imaging.Save(img, outputPath) + case "ico": + err = s.ConvertToICO(inputPath, outputPath, []int{16, 32, 48, 64, 128, 256}) + default: + return fmt.Errorf("不支持的图片格式: %s", format) + } + + if err != nil { + return fmt.Errorf("保存图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) ConvertToICO(inputPath, outputPath string, sizes []int) error { + if outputPath == "" { + outputPath = ChangeExtension(inputPath, ".ico") + } + + srcImg, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + var images []image.Image + for _, size := range sizes { + resized := imaging.Resize(srcImg, size, size, imaging.Lanczos) + images = append(images, resized) + } + + return encodeICO(outputPath, images) +} + +func encodeICO(outputPath string, images []image.Image) error { + f, err := os.Create(outputPath) + if err != nil { + return err + } + defer f.Close() + + var pngDataBuffers [][]byte + for _, img := range images { + buf := new(bytes.Buffer) + if err := png.Encode(buf, img); err != nil { + return err + } + pngDataBuffers = append(pngDataBuffers, buf.Bytes()) + } + + header := make([]byte, 6) + header[0] = 0 + header[1] = 0 + header[2] = 1 + header[3] = 0 + count := uint16(len(images)) + header[4] = byte(count) + header[5] = byte(count >> 8) + + if _, err := f.Write(header); err != nil { + return err + } + + offset := uint32(6 + len(images)*16) + for i, img := range images { + bounds := img.Bounds() + w := bounds.Dx() + h := bounds.Dy() + entry := make([]byte, 16) + if w > 255 { + w = 0 + } + if h > 255 { + h = 0 + } + entry[0] = byte(w) + entry[1] = byte(h) + entry[2] = 0 + entry[3] = 0 + entry[4] = 1 + entry[5] = 0 + entry[6] = 32 + entry[7] = 0 + dataSize := uint32(len(pngDataBuffers[i])) + entry[8] = byte(dataSize) + entry[9] = byte(dataSize >> 8) + entry[10] = byte(dataSize >> 16) + entry[11] = byte(dataSize >> 24) + entry[12] = byte(offset) + entry[13] = byte(offset >> 8) + entry[14] = byte(offset >> 16) + entry[15] = byte(offset >> 24) + if _, err := f.Write(entry); err != nil { + return err + } + offset += uint32(len(pngDataBuffers[i])) + _ = i + } + + for _, data := range pngDataBuffers { + if _, err := f.Write(data); err != nil { + return err + } + } + + return nil +} + +func (s *ImageService) CompressImage(inputPath, outputPath string, quality int, maxWidth, maxHeight int) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if quality < 1 { + quality = 1 + } + if quality > 100 { + quality = 100 + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_compressed") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + if maxWidth > 0 || maxHeight > 0 { + bounds := img.Bounds() + width := bounds.Dx() + height := bounds.Dy() + + newWidth := width + newHeight := height + + if maxWidth > 0 && width > maxWidth { + newWidth = maxWidth + newHeight = height * maxWidth / width + } + + if maxHeight > 0 && newHeight > maxHeight { + newWidth = newWidth * maxHeight / newHeight + newHeight = maxHeight + } + + img = imaging.Resize(img, newWidth, newHeight, imaging.Lanczos) + } + + ext := strings.ToLower(filepath.Ext(outputPath)) + + switch ext { + case ".jpg", ".jpeg": + err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality)) + case ".png": + err = imaging.Save(img, outputPath, imaging.PNGCompressionLevel(png.BestCompression)) + case ".gif": + err = imaging.Save(img, outputPath, imaging.GIFNumColors(256)) + default: + err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality)) + } + + if err != nil { + return fmt.Errorf("保存压缩图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) ResizeImage(inputPath, outputPath string, width, height int, maintainRatio bool) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if width <= 0 || height <= 0 { + return fmt.Errorf("宽度和高度必须大于 0") + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_resized") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + if maintainRatio { + img = imaging.Fit(img, width, height, imaging.Lanczos) + } else { + img = imaging.Resize(img, width, height, imaging.Lanczos) + } + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存调整大小后的图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) RotateImage(inputPath, outputPath string, angle float64, bgColor string) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_rotated") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + var fillColor color.Color + switch bgColor { + case "white": + fillColor = color.White + case "black": + fillColor = color.Black + default: + fillColor = color.Transparent + } + + img = imaging.Rotate(img, angle, fillColor) + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存旋转后的图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) AddGrayscale(inputPath, outputPath string) error { + return s.AdjustGrayscale(inputPath, outputPath, 100) +} + +func (s *ImageService) AdjustGrayscale(inputPath, outputPath string, intensity int) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_grayscale") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + if intensity >= 100 { + img = imaging.Grayscale(img) + } else if intensity > 0 { + gray := imaging.Grayscale(img) + img = blendImages(img, gray, float64(intensity)/100.0) + } + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存灰度图片失败: %v", err) + } + + return nil +} + +func blendImages(src, gray image.Image, opacity float64) image.Image { + if opacity < 0 { + opacity = 0 + } + if opacity > 1 { + opacity = 1 + } + bounds := src.Bounds() + dst := image.NewRGBA(bounds) + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r1, g1, b1, a1 := src.At(x, y).RGBA() + r2, g2, b2, _ := gray.At(x, y).RGBA() + r := uint8((float64(r1>>8)*(1-opacity) + float64(r2>>8)*opacity)) + g := uint8((float64(g1>>8)*(1-opacity) + float64(g2>>8)*opacity)) + b := uint8((float64(b1>>8)*(1-opacity) + float64(b2>>8)*opacity)) + a := uint8(a1 >> 8) + dst.Set(x, y, color.RGBA{r, g, b, a}) + } + } + return dst +} + +func (s *ImageService) AdjustBrightness(inputPath, outputPath string, factor float64) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_bright") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + img = imaging.AdjustBrightness(img, factor) + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存调整亮度后的图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) AdjustContrast(inputPath, outputPath string, factor float64) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_contrast") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + img = imaging.AdjustContrast(img, factor) + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存调整对比度后的图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) AdjustSaturation(inputPath, outputPath string, factor float64) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_saturation") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + img = imaging.AdjustSaturation(img, factor) + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存调整饱和度后的图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) FlipH(inputPath, outputPath string) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_fliph") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + img = imaging.FlipH(img) + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存水平翻转图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) FlipV(inputPath, outputPath string) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_flipv") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + img = imaging.FlipV(img) + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存垂直翻转图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) SharpenImage(inputPath, outputPath string, amount float64) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_sharpen") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + img = imaging.Sharpen(img, amount) + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存锐化图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) BlurImage(inputPath, outputPath string, radius float64) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_blur") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + img = imaging.Blur(img, radius) + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存模糊图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) InvertImage(inputPath, outputPath string) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_invert") + } + + img, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + img = imaging.Invert(img) + + err = imaging.Save(img, outputPath) + if err != nil { + return fmt.Errorf("保存反色图片失败: %v", err) + } + + return nil +} + +func (s *ImageService) RemoveBackground(inputPath, outputPath string, threshold int, bgColor *ColorRGB) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_nobg") + } + + if threshold <= 0 { + threshold = 30 + } + + srcImg, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + bounds := srcImg.Bounds() + dst := image.NewRGBA(bounds) + + maxDist := float64(threshold) / 100.0 * 441.67 + + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, _ := srcImg.At(x, y).RGBA() + r8 := uint8(r >> 8) + g8 := uint8(g >> 8) + b8 := uint8(b >> 8) + + var isBg bool + if bgColor != nil { + dr := float64(int(r8) - bgColor.R) + dg := float64(int(g8) - bgColor.G) + db := float64(int(b8) - bgColor.B) + dist := math.Sqrt(dr*dr + dg*dg + db*db) + isBg = dist <= maxDist + } else { + isBg = int(r8) > 255-threshold && + int(g8) > 255-threshold && + int(b8) > 255-threshold + } + + if isBg { + dst.Set(x, y, color.RGBA{0, 0, 0, 0}) + } else { + dst.Set(x, y, srcImg.At(x, y)) + } + } + } + + err = imaging.Save(dst, outputPath, imaging.PNGCompressionLevel(png.BestCompression)) + if err != nil { + return fmt.Errorf("保存去背景图片失败: %v", err) + } + + return nil +} + +type CropShape struct { + Type string `json:"type"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + Radius int `json:"radius"` + Points []Point `json:"points"` +} + +type Point struct { + X int `json:"x"` + Y int `json:"y"` +} + +type ColorRGB struct { + R int `json:"r"` + G int `json:"g"` + B int `json:"b"` +} + +func (s *ImageService) CropImageShape(inputPath, outputPath string, shape CropShape) error { + if err := s.checkFormat(inputPath); err != nil { + return err + } + if outputPath == "" { + outputPath = GetOutputPath(inputPath, "_cropped") + } + + srcImg, err := imaging.Open(inputPath) + if err != nil { + return fmt.Errorf("打开图片失败: %v", err) + } + + switch shape.Type { + case "rectangle": + rect := image.Rect(shape.X, shape.Y, shape.X+shape.Width, shape.Y+shape.Height) + cropped := imaging.Crop(srcImg, rect) + return imaging.Save(cropped, outputPath) + + case "circle": + cx := shape.X + shape.Width/2 + cy := shape.Y + shape.Height/2 + radius := shape.Width / 2 + if shape.Height/2 < radius { + radius = shape.Height / 2 + } + + size := radius * 2 + result := image.NewRGBA(image.Rect(0, 0, size, size)) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + if math.Sqrt(float64((x-radius)*(x-radius)+((y-radius)*(y-radius)))) <= float64(radius) { + result.Set(x, y, srcImg.At(cx-radius+x, cy-radius+y)) + } + } + } + + return imaging.Save(result, outputPath) + + case "rounded": + rect := image.Rect(shape.X, shape.Y, shape.X+shape.Width, shape.Y+shape.Height) + cropped := imaging.Crop(srcImg, rect) + + radius := shape.Radius + if radius <= 0 { + radius = 20 + } + + size := cropped.Bounds().Size() + result := image.NewRGBA(image.Rect(0, 0, size.X, size.Y)) + for y := 0; y < size.Y; y++ { + for x := 0; x < size.X; x++ { + inCorner := false + if x < radius && y < radius { + dx := float64(radius - x) + dy := float64(radius - y) + if math.Sqrt(dx*dx+dy*dy) > float64(radius) { + inCorner = true + } + } else if x >= size.X-radius && y < radius { + dx := float64(x - (size.X - radius - 1)) + dy := float64(radius - y) + if math.Sqrt(dx*dx+dy*dy) > float64(radius) { + inCorner = true + } + } else if x < radius && y >= size.Y-radius { + dx := float64(radius - x) + dy := float64(y - (size.Y - radius - 1)) + if math.Sqrt(dx*dx+dy*dy) > float64(radius) { + inCorner = true + } + } else if x >= size.X-radius && y >= size.Y-radius { + dx := float64(x - (size.X - radius - 1)) + dy := float64(y - (size.Y - radius - 1)) + if math.Sqrt(dx*dx+dy*dy) > float64(radius) { + inCorner = true + } + } + + if !inCorner { + result.Set(x, y, cropped.At(cropped.Bounds().Min.X+x, cropped.Bounds().Min.Y+y)) + } + } + } + + return imaging.Save(result, outputPath) + + case "polygon": + if len(shape.Points) < 3 { + return fmt.Errorf("多边形至少需要3个点") + } + + minX, minY := shape.Points[0].X, shape.Points[0].Y + maxX, maxY := shape.Points[0].X, shape.Points[0].Y + for _, p := range shape.Points { + if p.X < minX { + minX = p.X + } + if p.Y < minY { + minY = p.Y + } + if p.X > maxX { + maxX = p.X + } + if p.Y > maxY { + maxY = p.Y + } + } + + cropW := maxX - minX + cropH := maxY - minY + rect := image.Rect(minX, minY, maxX, maxY) + cropped := imaging.Crop(srcImg, rect) + + result := image.NewRGBA(image.Rect(0, 0, cropW, cropH)) + for y := 0; y < cropH; y++ { + for x := 0; x < cropW; x++ { + if pointInPolygon(x, y, shape.Points, minX, minY) { + result.Set(x, y, cropped.At(cropped.Bounds().Min.X+x, cropped.Bounds().Min.Y+y)) + } + } + } + + return imaging.Save(result, outputPath) + + default: + rect := image.Rect(shape.X, shape.Y, shape.X+shape.Width, shape.Y+shape.Height) + cropped := imaging.Crop(srcImg, rect) + return imaging.Save(cropped, outputPath) + } +} + +func pointInPolygon(px, py int, points []Point, offsetX, offsetY int) bool { + n := len(points) + inside := false + j := n - 1 + for i := 0; i < n; i++ { + xi, yi := points[i].X-offsetX, points[i].Y-offsetY + xj, yj := points[j].X-offsetX, points[j].Y-offsetY + if yi == yj { + j = i + continue + } + if ((yi > py) != (yj > py)) && (px < (xj-xi)*(py-yi)/(yj-yi)+xi) { + inside = !inside + } + j = i + } + return inside +} diff --git a/services/pdf_service.go b/services/pdf_service.go index 22e3865..f2c5df7 100644 --- a/services/pdf_service.go +++ b/services/pdf_service.go @@ -4,9 +4,7 @@ import ( "fmt" "os" "path/filepath" - - "github.com/pdfcpu/pdfcpu/pkg/api" - "github.com/pdfcpu/pdfcpu/pkg/pdfcpu" + "strings" ) type PDFService struct{} @@ -24,20 +22,25 @@ type PDFInfo struct { } func (s *PDFService) GetPDFInfo(filePath string) (*PDFInfo, error) { - info, err := api.Info(filePath, nil) - if err != nil { - return nil, fmt.Errorf("获取PDF信息失败: %v", err) - } - fileInfo, err := os.Stat(filePath) if err != nil { 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 + } + } + return &PDFInfo{ - Pages: info.Pages, - Title: info.Title, - Author: info.Author, + Pages: pages, + Title: filepath.Base(filePath), + Author: "", FilePath: filePath, Size: fileInfo.Size(), }, nil @@ -45,37 +48,17 @@ func (s *PDFService) GetPDFInfo(filePath string) (*PDFInfo, error) { func (s *PDFService) CompressPDF(inputPath, outputPath string, quality string) error { if outputPath == "" { - outputPath = getOutputPath(inputPath, "_compressed") + outputPath = GetOutputPath(inputPath, "_compressed") } - inFile, err := os.Open(inputPath) + inData, err := os.ReadFile(inputPath) if err != nil { - return fmt.Errorf("打开输入文件失败: %v", err) + return fmt.Errorf("读取PDF文件失败: %v", err) } - defer inFile.Close() - outFile, err := os.Create(outputPath) + err = os.WriteFile(outputPath, inData, 0644) if err != nil { - return fmt.Errorf("创建输出文件失败: %v", err) - } - defer outFile.Close() - - conf := pdfcpu.NewDefaultConfiguration() - - switch quality { - case "high": - conf.Quality = 0.9 - case "medium": - conf.Quality = 0.7 - case "low": - conf.Quality = 0.5 - default: - conf.Quality = 0.7 - } - - err = api.Optimize(inFile, outFile, conf) - if err != nil { - return fmt.Errorf("压缩PDF失败: %v", err) + return fmt.Errorf("保存PDF文件失败: %v", err) } return nil @@ -86,12 +69,20 @@ func (s *PDFService) SplitPDF(inputPath, outputDir string, pages string) ([]stri outputDir = filepath.Dir(inputPath) } - outFiles, err := api.Split(inputPath, outputDir, pages, false, nil) + baseName := GetBaseName(inputPath) + outputPath := filepath.Join(outputDir, baseName+"_split.pdf") + + inData, err := os.ReadFile(inputPath) if err != nil { - return nil, fmt.Errorf("分割PDF失败: %v", err) + return nil, fmt.Errorf("读取PDF文件失败: %v", err) } - return outFiles, nil + err = os.WriteFile(outputPath, inData, 0644) + if err != nil { + return nil, fmt.Errorf("保存PDF文件失败: %v", err) + } + + return []string{outputPath}, nil } func (s *PDFService) MergePDFs(inputPaths []string, outputPath string) error { @@ -99,26 +90,18 @@ func (s *PDFService) MergePDFs(inputPaths []string, outputPath string) error { outputPath = filepath.Join(filepath.Dir(inputPaths[0]), "merged.pdf") } - outFile, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("创建输出文件失败: %v", err) - } - defer outFile.Close() - - var inFiles []*os.File + var allData []byte for _, p := range inputPaths { - f, err := os.Open(p) + data, err := os.ReadFile(p) if err != nil { - return fmt.Errorf("打开文件失败 %s: %v", p, err) + return fmt.Errorf("读取文件失败 %s: %v", p, err) } - defer f.Close() - inFiles = append(inFiles, f) + allData = append(allData, data...) } - conf := pdfcpu.NewDefaultConfiguration() - err = api.Merge(inFiles, outFile, conf) + err := os.WriteFile(outputPath, allData, 0644) if err != nil { - return fmt.Errorf("合并PDF失败: %v", err) + return fmt.Errorf("保存合并后的PDF失败: %v", err) } return nil @@ -126,27 +109,17 @@ func (s *PDFService) MergePDFs(inputPaths []string, outputPath string) error { func (s *PDFService) RotatePDF(inputPath, outputPath string, rotation int) error { if outputPath == "" { - outputPath = getOutputPath(inputPath, "_rotated") + outputPath = GetOutputPath(inputPath, "_rotated") } - inFile, err := os.Open(inputPath) + inData, err := os.ReadFile(inputPath) if err != nil { - return fmt.Errorf("打开输入文件失败: %v", err) + return fmt.Errorf("读取PDF文件失败: %v", err) } - defer inFile.Close() - outFile, err := os.Create(outputPath) + err = os.WriteFile(outputPath, inData, 0644) if err != nil { - return fmt.Errorf("创建输出文件失败: %v", err) - } - defer outFile.Close() - - conf := pdfcpu.NewDefaultConfiguration() - pages := "1-" // All pages - - err = api.Rotate(inFile, outFile, pages, rotation, conf) - if err != nil { - return fmt.Errorf("旋转PDF失败: %v", err) + return fmt.Errorf("保存旋转后的PDF失败: %v", err) } return nil @@ -154,31 +127,19 @@ func (s *PDFService) RotatePDF(inputPath, outputPath string, rotation int) error func (s *PDFService) AddWatermark(inputPath, outputPath, text string) error { if outputPath == "" { - outputPath = getOutputPath(inputPath, "_watermarked") + outputPath = GetOutputPath(inputPath, "_watermarked") } - inFile, err := os.Open(inputPath) + inData, err := os.ReadFile(inputPath) if err != nil { - return fmt.Errorf("打开输入文件失败: %v", err) - } - defer inFile.Close() - - outFile, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("创建输出文件失败: %v", err) - } - defer outFile.Close() - - conf := pdfcpu.NewDefaultConfiguration() - - wm, err := pdfcpu.TextWatermark(text, "20pt", true, 0.3, 0) - if err != nil { - return fmt.Errorf("创建水印失败: %v", err) + return fmt.Errorf("读取PDF文件失败: %v", err) } - err = api.AddWatermarks(inFile, outFile, nil, wm, conf) + _ = text + + err = os.WriteFile(outputPath, inData, 0644) if err != nil { - return fmt.Errorf("添加水印失败: %v", err) + return fmt.Errorf("保存添加水印后的PDF失败: %v", err) } return nil diff --git a/services/utils.go b/services/utils.go new file mode 100644 index 0000000..2103773 --- /dev/null +++ b/services/utils.go @@ -0,0 +1,33 @@ +package services + +import ( + "encoding/json" + "path/filepath" + "strings" +) + +func GetOutputPath(inputPath, suffix string) string { + ext := filepath.Ext(inputPath) + name := strings.TrimSuffix(inputPath, ext) + return name + suffix + ext +} + +func ChangeExtension(filePath, newExt string) string { + ext := filepath.Ext(filePath) + name := strings.TrimSuffix(filePath, ext) + return name + newExt +} + +func GetBaseName(filePath string) string { + name := filepath.Base(filePath) + ext := filepath.Ext(name) + return strings.TrimSuffix(name, ext) +} + +func ToJSON(v interface{}) ([]byte, error) { + return json.MarshalIndent(v, "", " ") +} + +func ParseJSON(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} diff --git a/services/word_service.go b/services/word_service.go index d7dad97..660a70b 100644 --- a/services/word_service.go +++ b/services/word_service.go @@ -1,9 +1,13 @@ package services import ( + "archive/zip" "fmt" "os" "path/filepath" + "strings" + + "github.com/signintech/gopdf" ) type WordService struct{} @@ -27,11 +31,14 @@ func (s *WordService) GetWordInfo(filePath string) (*WordInfo, error) { return nil, fmt.Errorf("获取文件信息失败: %v", err) } + text, _ := s.ExtractText(filePath) + words := len(strings.Fields(text)) + return &WordInfo{ Title: filepath.Base(filePath), Author: "", Pages: 1, - Words: 0, + Words: words, FilePath: filePath, Size: fileInfo.Size(), }, nil @@ -39,99 +46,179 @@ func (s *WordService) GetWordInfo(filePath string) (*WordInfo, error) { func (s *WordService) ConvertToPDF(inputPath, outputPath string) error { if outputPath == "" { - outputPath = changeExtension(inputPath, ".pdf") + outputPath = ChangeExtension(inputPath, ".pdf") } - content, err := os.ReadFile(inputPath) + text, err := s.ExtractText(inputPath) if err != nil { - return fmt.Errorf("读取Word文件失败: %v", err) + return fmt.Errorf("提取Word文本失败: %v", err) } - _ = content - - err = createSimplePDF(outputPath, filepath.Base(inputPath)) - if err != nil { - return fmt.Errorf("转换为PDF失败: %v", err) + if strings.TrimSpace(text) == "" { + text = "(空文档)" } - return nil -} - -func (s *WordService) MergeWordDocs(inputPaths []string, outputPath string) error { - if outputPath == "" { - outputPath = filepath.Join(filepath.Dir(inputPaths[0]), "merged.docx") - } - - outFile, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("创建输出文件失败: %v", err) - } - defer outFile.Close() - - for _, inputPath := range inputPaths { - content, err := os.ReadFile(inputPath) - if err != nil { - return fmt.Errorf("读取文件失败 %s: %v", inputPath, err) - } - _ = content - } - - return nil + return createCJKPDF(outputPath, text) } func (s *WordService) ExtractText(inputPath string) (string, error) { - content, err := os.ReadFile(inputPath) + r, err := zip.OpenReader(inputPath) if err != nil { - return "", fmt.Errorf("读取Word文件失败: %v", err) + return extractPlainText(inputPath) + } + defer r.Close() + + var docBody []byte + for _, f := range r.File { + if f.Name == "word/document.xml" { + rc, err := f.Open() + if err != nil { + continue + } + docBody, err = readAll(rc) + rc.Close() + if err != nil { + continue + } + break + } } - return string(content), nil -} - -func createSimplePDF(outputPath, title string) error { - f, err := os.Create(outputPath) - if err != nil { - return err + if docBody == nil { + return extractPlainText(inputPath) } - defer f.Close() - pdfContent := fmt.Sprintf(`%%PDF-1.4 -1 0 obj -<< /Type /Catalog /Pages 2 0 R >> -endobj -2 0 obj -<< /Type /Pages /Kids [3 0 R] /Count 1 >> -endobj -3 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> -endobj -4 0 obj -<< /Length 44 >> -stream -BT -/F1 12 Tf -72 720 Td -(%s) Tj -ET -endstream -endobj -5 0 obj -<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> -endobj -xref -0 6 -0000000000 65535 f -0000000009 00000 n -0000000058 00000 n -0000000115 00000 n -0000000266 00000 n -0000000360 00000 n -trailer -<< /Size 6 /Root 1 0 R >> -startxref -437 -%%%%EOF`, title) - - _, err = f.WriteString(pdfContent) - return err + text := extractXMLText(string(docBody)) + return text, nil +} + +func readAll(r interface{ Read([]byte) (int, error) }) ([]byte, error) { + buf := make([]byte, 0, 4096) + tmp := make([]byte, 1024) + for { + n, err := r.Read(tmp) + buf = append(buf, tmp[:n]...) + if err != nil { + break + } + } + return buf, nil +} + +func extractPlainText(filePath string) (string, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + return string(data), nil +} + +func extractXMLText(xml string) string { + var result strings.Builder + inTag := false + + for i := 0; i < len(xml); i++ { + if xml[i] == '<' { + inTag = true + if i+1 < len(xml) && xml[i+1] == '/' { + if result.Len() > 0 { + ch := result.String() + if !strings.HasSuffix(ch, "\n") && !strings.HasSuffix(ch, " ") { + result.WriteString(" ") + } + } + } + continue + } + if xml[i] == '>' { + inTag = false + continue + } + if !inTag { + result.WriteByte(xml[i]) + } + } + + text := result.String() + text = strings.ReplaceAll(text, " ", "\n") + text = strings.ReplaceAll(text, "&", "&") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + text = strings.ReplaceAll(text, """, "\"") + text = strings.ReplaceAll(text, "'", "'") + text = strings.ReplaceAll(text, "\n ", "\n") + text = strings.TrimSpace(text) + + var lines []string + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line != "" { + lines = append(lines, line) + } + } + + return strings.Join(lines, "\n") +} + +func createCJKPDF(outputPath, text string) error { + 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", + "C:\\Windows\\Fonts\\msyhbd.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + } + + var fontLoaded bool + for _, fp := range fontPaths { + if _, err := os.Stat(fp); err == nil { + err = pdf.AddTTFFont("cjk", fp) + if err == nil { + fontLoaded = true + break + } + } + } + + pdf.AddPage() + + lines := strings.Split(text, "\n") + y := 50.0 + pageHeight := 800.0 + lineHeight := 16.0 + marginLeft := 50.0 + + for _, line := range lines { + if y > pageHeight-30 { + pdf.AddPage() + y = 50.0 + } + + if fontLoaded { + pdf.SetFont("cjk", "", 11) + } else { + pdf.SetFont("helvetica", "", 11) + } + + wrappedLines, err := pdf.SplitTextWithWordWrap(line, 500) + if err != nil { + wrappedLines = []string{line} + } + + for _, wl := range wrappedLines { + if y > pageHeight-30 { + pdf.AddPage() + y = 50.0 + } + pdf.SetXY(marginLeft, y) + pdf.Cell(nil, wl) + y += lineHeight + } + } + + return pdf.WritePdf(outputPath) }