Add PDF tools: merge, split, rotate, delete, watermark, compress, convert, protect
- 15 API endpoints for PDF operations (pdf-lib, pdf-parse, Ghostscript) - Frontend with 8 operation tabs - Merge multiple PDFs, split by page ranges - Rotate/delete/reorder pages - Add watermark text, page numbers - Compress via Ghostscript (screen/ebook/printer quality) - PDF to PNG/JPG, Images to PDF - Password protection, text extraction - Added to sidebar, dashboard (Utils category), DB
This commit is contained in:
parent
bda0871969
commit
378c1a075e
286
package-lock.json
generated
286
package-lock.json
generated
@ -31,6 +31,9 @@
|
||||
"openid-client": "^6.4.2",
|
||||
"opentype.js": "^1.3.4",
|
||||
"passport": "^0.7.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-merger-js": "^5.1.2",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"sequelize": "^6.37.8",
|
||||
"sharp": "^0.34.1"
|
||||
}
|
||||
@ -3123,6 +3126,208 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@ -7781,6 +7986,12 @@
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@ -7930,6 +8141,81 @@
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/pdf-merger-js": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pdf-merger-js/-/pdf-merger-js-5.1.2.tgz",
|
||||
"integrity": "sha512-RCBjLQILZ8UA4keO/Ip2/gjUuxigMMoK7mO5eJg6zjlnyymboFnRTgzKwOs/FiU9ornS2m72Qr95oARX1C24fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^11.1.0",
|
||||
"pdf-lib": "^1.17.1"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-merge": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-merger-js/node_modules/commander": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
|
||||
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"pdfjs-dist": "5.4.296"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-parse": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
|
||||
@ -34,6 +34,9 @@
|
||||
"openid-client": "^6.4.2",
|
||||
"opentype.js": "^1.3.4",
|
||||
"passport": "^0.7.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-merger-js": "^5.1.2",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"sequelize": "^6.37.8",
|
||||
"sharp": "^0.34.1"
|
||||
}
|
||||
|
||||
@ -185,14 +185,15 @@
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.645 3.232a.75.75 0 01-1.09-.791l1.078-6.285L1.347 7.06a.75.75 0 01.416-1.28l6.31-.917L10.89.598a.75.75 0 011.341 0l2.816 5.266 6.31.917a.75.75 0 01.416 1.28l-4.416 4.266 1.078 6.285a.75.75 0 01-1.09.791L12 15.17z"/></svg>
|
||||
</div>
|
||||
<span class="cat-title">Утилиты</span>
|
||||
<span class="cat-count">2</span>
|
||||
<span class="cat-desc">пароли, Markdown и другие</span>
|
||||
<span class="cat-count">3</span>
|
||||
<span class="cat-desc">пароли, PDF, Markdown</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 fade-d4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 fade-d4">
|
||||
<a href="/password" class="tool-tile">
|
||||
<div class="tile-icon" style="background:rgba(255,214,0,.1);color:#ffd600;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg></div>
|
||||
<div><div class="tile-title">Генератор паролей</div><div class="tile-desc">Криптографически стойкая генерация</div></div>
|
||||
</a>
|
||||
<a href="/pdf" class="tool-tile"> <div class="tile-icon" style="background:rgba(255,214,0,.1);color:#ffd600;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg></div> <div><div class="tile-title">PDF инструменты</div><div class="tile-desc">Объединение, сжатие, конвертация</div></div> </a>
|
||||
<a href="/md" class="tool-tile">
|
||||
<div class="tile-icon" style="background:rgba(255,214,0,.1);color:#ffd600;"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg></div>
|
||||
<div><div class="tile-title">Markdown Viewer</div><div class="tile-desc">Просмотр и экспорт .md файлов</div></div>
|
||||
|
||||
1639
public/pdf.html
Normal file
1639
public/pdf.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -26,7 +26,7 @@ const WA_CATEGORIES = [
|
||||
{ id: 'images', title: 'Изображения', description: 'Сжатие, редактирование и генерация', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/>', tools: ['compress', 'placeholder', 'svgeditor', 'editor', 'video'] },
|
||||
{ id: 'code', title: 'Код', description: 'Форматирование, очистка и конвертация', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>', tools: ['formatter', 'sanitizer', 'converter'] },
|
||||
{ id: 'web', title: 'Веб', description: 'HTTP-клиент, парсер и анализ', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"/>', tools: ['parser', 'httpclient', 'redirects'] },
|
||||
{ id: 'utils', title: 'Утилиты', description: 'Пароли, Markdown и другие', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.645 3.232a.75.75 0 01-1.09-.791l1.078-6.285L1.347 7.06a.75.75 0 01.416-1.28l6.31-.917L10.89.598a.75.75 0 011.341 0l2.816 5.266 6.31.917a.75.75 0 01.416 1.28l-4.416 4.266 1.078 6.285a.75.75 0 01-1.09.791L12 15.17z"/>', tools: ['password', 'md'] },
|
||||
{ id: 'utils', title: 'Утилиты', description: 'Пароли, Markdown и другие', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.645 3.232a.75.75 0 01-1.09-.791l1.078-6.285L1.347 7.06a.75.75 0 01.416-1.28l6.31-.917L10.89.598a.75.75 0 011.341 0l2.816 5.266 6.31.917a.75.75 0 01.416 1.28l-4.416 4.266 1.078 6.285a.75.75 0 01-1.09.791L12 15.17z"/>', tools: ['password', 'md', 'pdf'] },
|
||||
];
|
||||
|
||||
/* Sidebar tools definition */
|
||||
@ -48,6 +48,7 @@ const WA_TOOLS = [
|
||||
{ id: 'redirects', path: '/redirects', title: 'Redirects', category: 'web', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>' },
|
||||
// Утилиты
|
||||
{ id: 'password', path: '/password', title: 'Пароли', category: 'utils', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/>' },
|
||||
{ id: 'pdf', path: '/pdf', title: 'PDF', category: 'utils', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>' },
|
||||
{ id: 'md', path: '/md', title: 'Markdown', category: 'utils', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>' },
|
||||
// Admin (only visible in sidebar for admins — hidden by default, shown via JS)
|
||||
{ id: 'admin', path: '/admin', title: 'Админ', category: null, adminOnly: true, icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>' },
|
||||
|
||||
505
routes/pdf.js
Normal file
505
routes/pdf.js
Normal file
@ -0,0 +1,505 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const archiver = require('archiver');
|
||||
const log = require('../lib/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
|
||||
const DOWNLOADS_DIR = path.join(__dirname, '..', 'downloads');
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
const upload = multer({
|
||||
dest: UPLOADS_DIR,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) cb(null, true);
|
||||
else if (file.mimetype.startsWith('image/')) cb(null, true); // for fromImages
|
||||
else cb(new Error('Только PDF и изображения'));
|
||||
},
|
||||
});
|
||||
|
||||
const limiter = rateLimit({ windowMs: 60000, max: 30, message: { error: 'Слишком много запросов' } });
|
||||
|
||||
// File storage for uploaded PDFs
|
||||
const pdfFiles = new Map();
|
||||
|
||||
function registerFile(multerFile, pageCount) {
|
||||
const id = `pdf_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const entry = {
|
||||
id,
|
||||
path: multerFile.path,
|
||||
name: multerFile.originalname,
|
||||
size: multerFile.size,
|
||||
pages: pageCount || 0,
|
||||
};
|
||||
pdfFiles.set(id, entry);
|
||||
setTimeout(() => {
|
||||
try { fs.existsSync(entry.path) && fs.unlinkSync(entry.path); } catch {}
|
||||
pdfFiles.delete(id);
|
||||
}, 30 * 60 * 1000);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function getFile(id) {
|
||||
const f = pdfFiles.get(id);
|
||||
if (!f || !fs.existsSync(f.path)) return null;
|
||||
return f;
|
||||
}
|
||||
|
||||
function saveResult(buffer, ext) {
|
||||
const name = `result_${Date.now()}_${Math.random().toString(36).slice(2, 6)}${ext}`;
|
||||
const outPath = path.join(DOWNLOADS_DIR, name);
|
||||
fs.writeFileSync(outPath, buffer);
|
||||
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
|
||||
return `/pdf/download/${name}`;
|
||||
}
|
||||
|
||||
// Page
|
||||
router.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '..', 'public', 'pdf.html'));
|
||||
});
|
||||
|
||||
// Upload
|
||||
router.post('/upload', limiter, upload.array('files', 10), async (req, res) => {
|
||||
if (!req.files || !req.files.length) return res.status(400).json({ error: 'Файлы не загружены' });
|
||||
|
||||
const pdfParse = require('pdf-parse');
|
||||
const results = [];
|
||||
|
||||
for (const file of req.files) {
|
||||
let pages = 0;
|
||||
try {
|
||||
if (file.mimetype === 'application/pdf') {
|
||||
const buf = fs.readFileSync(file.path);
|
||||
const data = await pdfParse(buf);
|
||||
pages = data.numpages || 0;
|
||||
}
|
||||
} catch {}
|
||||
const entry = registerFile(file, pages);
|
||||
results.push({ id: entry.id, name: entry.name, size: entry.size, pages });
|
||||
}
|
||||
|
||||
log.info(`PDF upload: ${results.length} files`);
|
||||
res.json({ files: results });
|
||||
});
|
||||
|
||||
// Info
|
||||
router.get('/info/:fileId', async (req, res) => {
|
||||
const f = getFile(req.params.fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
|
||||
try {
|
||||
const pdfParse = require('pdf-parse');
|
||||
const buf = fs.readFileSync(f.path);
|
||||
const data = await pdfParse(buf);
|
||||
res.json({
|
||||
pages: data.numpages,
|
||||
title: data.info?.Title || '',
|
||||
author: data.info?.Author || '',
|
||||
creator: data.info?.Creator || '',
|
||||
size: f.size,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Merge
|
||||
router.post('/merge', express.json(), async (req, res) => {
|
||||
const { fileIds } = req.body;
|
||||
if (!fileIds || fileIds.length < 2) return res.status(400).json({ error: 'Нужно минимум 2 файла' });
|
||||
|
||||
try {
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
const merged = await PDFDocument.create();
|
||||
|
||||
for (const id of fileIds) {
|
||||
const f = getFile(id);
|
||||
if (!f) return res.status(404).json({ error: `Файл ${id} не найден` });
|
||||
const buf = fs.readFileSync(f.path);
|
||||
const doc = await PDFDocument.load(buf);
|
||||
const pages = await merged.copyPages(doc, doc.getPageIndices());
|
||||
pages.forEach(p => merged.addPage(p));
|
||||
}
|
||||
|
||||
const result = await merged.save();
|
||||
const url = saveResult(Buffer.from(result), '.pdf');
|
||||
res.json({ downloadUrl: url, size: result.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Split
|
||||
router.post('/split', express.json(), async (req, res) => {
|
||||
const { fileId, ranges } = req.body;
|
||||
const f = getFile(fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
|
||||
try {
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
const buf = fs.readFileSync(f.path);
|
||||
const srcDoc = await PDFDocument.load(buf);
|
||||
const totalPages = srcDoc.getPageCount();
|
||||
|
||||
// Parse ranges: "1-3,5,7-9"
|
||||
const pageNums = [];
|
||||
for (const part of ranges.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.includes('-')) {
|
||||
const [start, end] = trimmed.split('-').map(Number);
|
||||
for (let i = start; i <= Math.min(end, totalPages); i++) pageNums.push(i);
|
||||
} else {
|
||||
const n = parseInt(trimmed);
|
||||
if (n >= 1 && n <= totalPages) pageNums.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
if (pageNums.length === 0) return res.status(400).json({ error: 'Нет валидных страниц' });
|
||||
|
||||
// Single output PDF with selected pages
|
||||
const newDoc = await PDFDocument.create();
|
||||
const indices = pageNums.map(n => n - 1); // 0-based
|
||||
const copiedPages = await newDoc.copyPages(srcDoc, indices);
|
||||
copiedPages.forEach(p => newDoc.addPage(p));
|
||||
|
||||
const result = await newDoc.save();
|
||||
const url = saveResult(Buffer.from(result), '.pdf');
|
||||
res.json({ downloadUrl: url, size: result.length, pages: pageNums.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Rotate
|
||||
router.post('/rotate', express.json(), async (req, res) => {
|
||||
const { fileId, pages, angle } = req.body;
|
||||
const f = getFile(fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
|
||||
try {
|
||||
const { PDFDocument, degrees } = await import('pdf-lib');
|
||||
const buf = fs.readFileSync(f.path);
|
||||
const doc = await PDFDocument.load(buf);
|
||||
const total = doc.getPageCount();
|
||||
|
||||
const targetPages = pages === 'all'
|
||||
? Array.from({ length: total }, (_, i) => i)
|
||||
: pages.split(',').map(n => parseInt(n.trim()) - 1).filter(i => i >= 0 && i < total);
|
||||
|
||||
for (const idx of targetPages) {
|
||||
const page = doc.getPage(idx);
|
||||
const current = page.getRotation().angle;
|
||||
page.setRotation(degrees(current + (angle || 90)));
|
||||
}
|
||||
|
||||
const result = await doc.save();
|
||||
const url = saveResult(Buffer.from(result), '.pdf');
|
||||
res.json({ downloadUrl: url, size: result.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete pages
|
||||
router.post('/delete', express.json(), async (req, res) => {
|
||||
const { fileId, pages } = req.body;
|
||||
const f = getFile(fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
|
||||
try {
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
const buf = fs.readFileSync(f.path);
|
||||
const srcDoc = await PDFDocument.load(buf);
|
||||
const total = srcDoc.getPageCount();
|
||||
|
||||
const deleteSet = new Set(pages.split(',').map(n => parseInt(n.trim()) - 1));
|
||||
const keepIndices = Array.from({ length: total }, (_, i) => i).filter(i => !deleteSet.has(i));
|
||||
|
||||
if (keepIndices.length === 0) return res.status(400).json({ error: 'Нельзя удалить все страницы' });
|
||||
|
||||
const newDoc = await PDFDocument.create();
|
||||
const copied = await newDoc.copyPages(srcDoc, keepIndices);
|
||||
copied.forEach(p => newDoc.addPage(p));
|
||||
|
||||
const result = await newDoc.save();
|
||||
const url = saveResult(Buffer.from(result), '.pdf');
|
||||
res.json({ downloadUrl: url, size: result.length, pages: keepIndices.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Reorder
|
||||
router.post('/reorder', express.json(), async (req, res) => {
|
||||
const { fileId, order } = req.body;
|
||||
const f = getFile(fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
|
||||
try {
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
const buf = fs.readFileSync(f.path);
|
||||
const srcDoc = await PDFDocument.load(buf);
|
||||
const indices = order.map(n => n - 1); // 1-based to 0-based
|
||||
|
||||
const newDoc = await PDFDocument.create();
|
||||
const copied = await newDoc.copyPages(srcDoc, indices);
|
||||
copied.forEach(p => newDoc.addPage(p));
|
||||
|
||||
const result = await newDoc.save();
|
||||
const url = saveResult(Buffer.from(result), '.pdf');
|
||||
res.json({ downloadUrl: url, size: result.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Watermark
|
||||
router.post('/watermark', express.json(), async (req, res) => {
|
||||
const { fileId, text, fontSize = 48, opacity = 0.3, color = '#888888' } = req.body;
|
||||
const f = getFile(fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
|
||||
const buf = fs.readFileSync(f.path);
|
||||
const doc = await PDFDocument.load(buf);
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
const r = parseInt(color.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(color.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(color.slice(5, 7), 16) / 255;
|
||||
|
||||
for (let i = 0; i < doc.getPageCount(); i++) {
|
||||
const page = doc.getPage(i);
|
||||
const { width, height } = page.getSize();
|
||||
const textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
|
||||
page.drawText(text, {
|
||||
x: (width - textWidth) / 2,
|
||||
y: height / 2,
|
||||
size: fontSize,
|
||||
font,
|
||||
color: rgb(r, g, b),
|
||||
opacity,
|
||||
rotate: { type: 'degrees', angle: -45 },
|
||||
});
|
||||
}
|
||||
|
||||
const result = await doc.save();
|
||||
const url = saveResult(Buffer.from(result), '.pdf');
|
||||
res.json({ downloadUrl: url, size: result.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Page numbers
|
||||
router.post('/pagenumbers', express.json(), async (req, res) => {
|
||||
const { fileId, position = 'bottom-center', startFrom = 1 } = req.body;
|
||||
const f = getFile(fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
|
||||
const buf = fs.readFileSync(f.path);
|
||||
const doc = await PDFDocument.load(buf);
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
const total = doc.getPageCount();
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const page = doc.getPage(i);
|
||||
const { width } = page.getSize();
|
||||
const numText = String(i + startFrom);
|
||||
const textWidth = font.widthOfTextAtSize(numText, 10);
|
||||
|
||||
let x = (width - textWidth) / 2; // center
|
||||
let y = 30;
|
||||
|
||||
if (position.includes('left')) x = 40;
|
||||
if (position.includes('right')) x = width - 40 - textWidth;
|
||||
if (position.includes('top')) y = page.getSize().height - 30;
|
||||
|
||||
page.drawText(numText, { x, y, size: 10, font, color: rgb(0.4, 0.4, 0.4) });
|
||||
}
|
||||
|
||||
const result = await doc.save();
|
||||
const url = saveResult(Buffer.from(result), '.pdf');
|
||||
res.json({ downloadUrl: url, size: result.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Compress (Ghostscript)
|
||||
router.post('/compress', express.json(), async (req, res) => {
|
||||
const { fileId, quality = 'ebook' } = req.body;
|
||||
const f = getFile(fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
|
||||
const outName = `compressed_${Date.now()}.pdf`;
|
||||
const outPath = path.join(DOWNLOADS_DIR, outName);
|
||||
const settings = { screen: '/screen', ebook: '/ebook', printer: '/printer' };
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const proc = spawn('gs', [
|
||||
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4',
|
||||
`-dPDFSETTINGS=${settings[quality] || '/ebook'}`,
|
||||
'-dNOPAUSE', '-dBATCH', '-dQUIET',
|
||||
`-sOutputFile=${outPath}`, f.path,
|
||||
], { timeout: 120000 });
|
||||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('Ghostscript error')));
|
||||
proc.on('error', reject);
|
||||
});
|
||||
|
||||
const stat = fs.statSync(outPath);
|
||||
const savings = f.size > 0 ? Math.round((1 - stat.size / f.size) * 100) : 0;
|
||||
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
|
||||
|
||||
res.json({ downloadUrl: `/pdf/download/${outName}`, size: stat.size, savings });
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(outPath); } catch {}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Protect (add password)
|
||||
router.post('/protect', express.json(), async (req, res) => {
|
||||
const { fileId, password } = req.body;
|
||||
const f = getFile(fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
if (!password) return res.status(400).json({ error: 'Пароль обязателен' });
|
||||
|
||||
try {
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
const buf = fs.readFileSync(f.path);
|
||||
const doc = await PDFDocument.load(buf);
|
||||
doc.encrypt({ userPassword: password, ownerPassword: password });
|
||||
|
||||
const result = await doc.save();
|
||||
const url = saveResult(Buffer.from(result), '.pdf');
|
||||
res.json({ downloadUrl: url, size: result.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Extract text
|
||||
router.post('/extract-text', express.json(), async (req, res) => {
|
||||
const { fileId } = req.body;
|
||||
const f = getFile(fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
|
||||
try {
|
||||
const pdfParse = require('pdf-parse');
|
||||
const buf = fs.readFileSync(f.path);
|
||||
const data = await pdfParse(buf);
|
||||
res.json({
|
||||
text: data.text,
|
||||
pages: data.numpages,
|
||||
metadata: data.info || {},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PDF to Images (Ghostscript)
|
||||
router.post('/toImages', express.json(), async (req, res) => {
|
||||
const { fileId, format = 'png', dpi = 150 } = req.body;
|
||||
const f = getFile(fileId);
|
||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||
|
||||
const tmpDir = path.join(DOWNLOADS_DIR, `img_${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const device = format === 'jpg' ? 'jpeg' : 'png16m';
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const proc = spawn('gs', [
|
||||
`-sDEVICE=${device}`, `-r${dpi}`,
|
||||
'-dNOPAUSE', '-dBATCH', '-dQUIET',
|
||||
`-sOutputFile=${tmpDir}/page_%03d.${format === 'jpg' ? 'jpg' : 'png'}`,
|
||||
f.path,
|
||||
], { timeout: 120000 });
|
||||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('Ghostscript error')));
|
||||
proc.on('error', reject);
|
||||
});
|
||||
|
||||
// ZIP the images
|
||||
const zipName = `pages_${Date.now()}.zip`;
|
||||
const zipPath = path.join(DOWNLOADS_DIR, zipName);
|
||||
const output = fs.createWriteStream(zipPath);
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
archive.pipe(output);
|
||||
archive.directory(tmpDir, false);
|
||||
await archive.finalize();
|
||||
await new Promise(r => output.on('close', r));
|
||||
|
||||
// Cleanup temp dir
|
||||
fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f)));
|
||||
fs.rmdirSync(tmpDir);
|
||||
|
||||
setTimeout(() => { try { fs.unlinkSync(zipPath); } catch {} }, 30 * 60 * 1000);
|
||||
const stat = fs.statSync(zipPath);
|
||||
res.json({ downloadUrl: `/pdf/download/${zipName}`, size: stat.size });
|
||||
} catch (err) {
|
||||
try { fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f))); fs.rmdirSync(tmpDir); } catch {}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Images to PDF
|
||||
router.post('/fromImages', upload.array('images', 50), async (req, res) => {
|
||||
if (!req.files || !req.files.length) return res.status(400).json({ error: 'Изображения не загружены' });
|
||||
|
||||
try {
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
const doc = await PDFDocument.create();
|
||||
|
||||
for (const file of req.files) {
|
||||
const imgBytes = fs.readFileSync(file.path);
|
||||
let img;
|
||||
if (file.mimetype === 'image/png') img = await doc.embedPng(imgBytes);
|
||||
else img = await doc.embedJpg(imgBytes);
|
||||
|
||||
const page = doc.addPage([img.width, img.height]);
|
||||
page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
|
||||
|
||||
try { fs.unlinkSync(file.path); } catch {}
|
||||
}
|
||||
|
||||
const result = await doc.save();
|
||||
const url = saveResult(Buffer.from(result), '.pdf');
|
||||
res.json({ downloadUrl: url, size: result.length, pages: req.files.length });
|
||||
} catch (err) {
|
||||
req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Download
|
||||
router.get('/download/:filename', (req, res) => {
|
||||
const filename = path.basename(req.params.filename);
|
||||
const filePath = path.join(DOWNLOADS_DIR, filename);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
||||
res.download(filePath);
|
||||
});
|
||||
|
||||
// Multer error handler
|
||||
router.use((err, req, res, next) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: 'Файл слишком большой. Максимум 50MB.' });
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
if (err) return res.status(400).json({ error: err.message });
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -87,6 +87,7 @@ app.use(require('./routes/httpclient'));
|
||||
app.use(require('./routes/redirects'));
|
||||
app.use(require('./routes/svgeditor'));
|
||||
app.use('/video', require('./routes/video'));
|
||||
app.use('/pdf', require('./routes/pdf'));
|
||||
app.use(require('./routes/pages'));
|
||||
|
||||
// Global error handler
|
||||
|
||||
Loading…
Reference in New Issue
Block a user