Compare commits

...
85 changed files with 2301 additions and 220 deletions
@@ -633,6 +633,11 @@ permissions.tags=read,write,edit,print
home.removePages.title=Remove
home.removePages.desc=Delete unwanted pages from your PDF document.
removePages.tags=Remove pages,delete pages
removePages.processingMode.label=Processing mode
removePages.processingMode.backend=Backend
removePages.processingMode.frontend=Browser
removePages.processingMode.backendDescription=Use the server to remove pages (required for complex formulas).
removePages.processingMode.frontendDescription=Remove the selected pages locally in your browser.
home.addPassword.title=Add Password
home.addPassword.desc=Encrypt your PDF document with a password.
@@ -1056,6 +1061,11 @@ AddStampRequest.overrideY=Override Y Coordinate
AddStampRequest.customMargin=Custom Margin
AddStampRequest.customColor=Custom Text Colour
AddStampRequest.submit=Submit
addStamp.processingMode.label=Processing mode
addStamp.processingMode.backend=Backend
addStamp.processingMode.frontend=Browser
addStamp.processingMode.backendDescription=Use the server to stamp pages (required for custom page formulas).
addStamp.processingMode.frontendDescription=Stamp selected pages directly in your browser.
#sanitizePDF
@@ -1104,6 +1114,11 @@ adjustContrast.download=Download
crop.title=Crop
crop.header=Crop PDF
crop.submit=Submit
crop.processingMode.label=Processing mode
crop.processingMode.backend=Backend
crop.processingMode.frontend=Browser
crop.processingMode.backendDescription=Use the server to crop pages (recommended for very large files).
crop.processingMode.frontendDescription=Crop each page locally without uploading the PDF.
#autoSplitPDF
@@ -1480,6 +1495,11 @@ watermark.selectText.10=Convert PDF to PDF-Image
watermark.submit=Add Watermark
watermark.type.1=Text
watermark.type.2=Image
watermark.processingMode.label=Processing mode
watermark.processingMode.backend=Backend
watermark.processingMode.frontend=Browser
watermark.processingMode.backendDescription=Use the server to apply the watermark (recommended for large files or flattening).
watermark.processingMode.frontendDescription=Process the watermark directly in your browser without uploading files.
#Change permissions
+35
View File
@@ -0,0 +1,35 @@
# Backend feature audit for frontend-only viability
This note reviews each frontend tool that currently posts to the Java API and summarises what the backend actually does today. For every tool (or small group of closely related tools) the table calls out the implementation details we observed in the Spring controllers/services and whether an equivalent browser-only port looks tractable with the current pdf-lib based helper layer.
| Tool(s) | What the backend does | Frontend-only feasibility |
| --- | --- | --- |
| Add Attachments | Streams every uploaded attachment into `PDComplexFileSpecification` entries, updates the embedded files name tree, and toggles the viewer preferences through PDFBox APIs.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java†L34-L53】【F:app/core/src/main/java/stirling/software/SPDF/service/AttachmentService.java†L28-L104】 | pdf-lib does not expose low-level name tree or attachment primitives today, so we would need significant custom parsing/writing work to recreate the COS dictionaries in the browser. |
| Add Page Numbers | Iterates every selected page and draws text with precise positioning, font selection (including non Latin fonts), and string templating via `PDPageContentStream` and PDFBox font metrics.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PageNumbersController.java†L37-L148】 | Achievable but non-trivial—pdf-lib can draw text, yet we would have to reimplement margin presets, font loading, and multi-language font fallbacks in TS. |
| Add Password / Change Permissions / Remove Password | Loads the document with PDFBox, toggles security flags, and applies `StandardProtectionPolicy` encryption or strips it entirely when removing passwords.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java†L32-L113】 | Browser tooling has no equivalent for modifying encrypted PDFs; a frontend-only port would require implementing encryption/decryption, which is out of scope. |
| Add Stamp | Builds either text or image stamps with alpha blending, font loading from bundled TTFs, optional coordinate overrides, and applies them through `PDPageContentStream` for selected pages.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java†L52-L167】 | Implemented with pdf-lib in the browser—`addStampClientSide` mirrors the font loading, opacity, and positioning logic so the tool can now run without the backend for supported formats.【F:frontend/src/utils/pdfOperations/addStamp.ts†L1-L120】 |
| Add Watermark | Mirrors the stamp pipeline but adds image conversion, transparency, and optional PDF-to-image fallback via shared PDF utilities.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java†L64-L153】 | Implemented with pdf-lib in the browser—`addWatermarkClientSide` handles tiled text/image watermarks; the PDF-to-image fallback still requires the server when requested.【F:frontend/src/utils/pdfOperations/addWatermark.ts†L1-L99】 |
| Adjust Page Scale | Rebuilds each page inside a new document, importing the original page as a form XObject and scaling it with PDFBox `LayerUtility` while computing centring offsets.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java†L38-L103】 | Technically possible client-side, but pdf-lib lacks a one-liner for reusing existing content streams; we would need to reimplement the import/scale maths and could run into performance issues for large files. |
| Auto Rename | Reads the PDF with a custom `PDFTextStripper`, groups text runs by font size, and heuristically picks the biggest headline as the suggested filename.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java†L33-L118】 | pdf-lib has no built-in text extraction; we would have to port PDFBox text parsing to JS or rely on wasm, so a browser rewrite is currently impractical. |
| Automate (pipelines) | Parses the uploaded JSON config and sequentially replays each operation by making internal HTTP calls to the other backend endpoints, managing ZIP outputs and error accumulation.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java†L47-L132】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java†L91-L200】 | Browser-only automation would require reproducing the entire backend orchestration plus every dependent tool in JS, so it is not viable until the individual operations have browser implementations. |
| Booklet Imposition / Reorganise Pages / Remove Pages | Uses PDFBox to compute saddle-stitch signatures, duplicate/reorder pages, and rebuild the document via form XObjects and layout maths.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java†L41-L183】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java†L38-L220】 | Simple page removal now ships with a browser implementation (`removePagesClientSide`), but booklet imposition and complex reordering still require backend logic.【F:frontend/src/utils/pdfOperations/removePages.ts†L1-L27】 |
| Change Metadata / Sanitize | Mutates document info dictionaries, strips XMP blocks, removes JavaScript/embedded files, and walks annotations/forms to clear actions with PDFBox COS access.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java†L54-L185】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java†L46-L198】 | Requires low-level catalog and COS manipulation that pdf-lib exposes only partially; a full port would likely need a dedicated parser/writer to match PDFBox behaviour. |
| Compress | Chains optional Ghostscript and QPDF CLI invocations, falls back to iterative image recompression inside PDFBox, and manages many temp files to hit the requested target size.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java†L658-L823】 | Impossible to match in-browser without wasm bindings to Ghostscript/QPDF equivalents; image recompression alone would not deliver parity. |
| Convert (URL → PDF, PDF → PDF/A, etc.) | Delegates to external binaries such as WeasyPrint and LibreOffice before finishing with PDFBox clean-up and metadata stamping.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java†L40-L99】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java†L78-L214】 | These flows rely on heavy native tooling; there is no realistic pure-browser substitute today. |
| Crop / Adjust Layout | Imports each page as a form XObject, applies clipping rectangles, and rebuilds the media box to the requested dimensions with LayerUtility.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java†L33-L95】 | Rectangular cropping is now handled in-browser via `cropPdfClientSide`; the advanced layout transformations still lean on the server.【F:frontend/src/utils/pdfOperations/crop.ts†L1-L33】 |
| Extract Images / Remove Blanks / Flatten | Traverse resources and renderers from PDFBox to locate images, run multithreaded extraction, identify blank pages via PDFRenderer heuristics, or rasterise entire pages when flattening forms.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java†L54-L170】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java†L68-L166】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java†L38-L85】 | Image extraction and blank-page detection depend on PDFBox renderers; replicating them in JS would need a wasm renderer or bespoke canvas logic, so they are currently impractical. |
| Merge / Overlay PDFs | Loads multiple files, optionally sorts them, stitches pages, builds outlines, and uses PDFBox `Overlay` helper to mix foreground/background documents with temp-file juggling.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java†L183-L220】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java†L39-L200】 | Simple merges are achievable with pdf-lib, but matching TOC generation, overlay modes, and stream-safe stitching would require a sizeable port. |
| OCR | Chooses between OCRmyPDF and Tesseract CLI runs, manages temporary sidecar files, and merges the results back into a PDF/ZIP response.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java†L76-L214】 | No pure-browser OCR stack offers comparable quality/performance; a wasm bridge to tesseract.js might help, but it would struggle with large PDFs and multi-language pipelines. |
| Security: Cert Sign / Validate Signature / Remove Cert | Leverages BouncyCastle and PDFBox to embed PKCS#7 signatures, craft visible appearance streams, and validate chains via PKIX path building and revocation checks.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java†L115-L199】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java†L68-L220】 | Browser crypto APIs cannot access arbitrary certificate stores or craft incremental PDF signatures, so these flows must stay server-side. |
## Can our existing frontend libraries cover the remaining gaps?
The browser stack is currently anchored around `pdf-lib` for structural edits and `pdfjs-dist` for rendering support.【F:frontend/package.json†L8-L67】 Those libraries are excellent for page re-ordering, lightweight drawing, and viewer presentation, but the backend-only tools above depend on capabilities that pdf-lib and pdf.js simply do not expose today:
* **Encryption and permission management** The password tools use PDFBox `StandardProtectionPolicy` workflow to set encryption keys, permission flags, and to strip security when unlocking documents.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java†L32-L113】 None of the shipped frontend libraries can generate or remove those encrypted revisions, so we cannot match the backend feature-set in the browser.
* **Low-level catalog and attachment access** Adding attachments reaches into the embedded-file name tree, constructs `PDComplexFileSpecification` objects, and rewrites viewer preferences.【F:app/core/src/main/java/stirling/software/SPDF/service/AttachmentService.java†L28-L104】 Metadata sanitisation follows a similar COS-level pattern. pdf-lib intentionally hides these PDF internals, leaving no straightforward way to recreate the same structures client-side.
* **Rasterisation-heavy flows** Compression, blank-page removal, and full-page flattening all rely on PDFBox renderers plus optional Ghostscript/QPDF passes for image recompression and heuristic detection.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java†L44-L205】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java†L68-L166】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java†L38-L85】 Replicating those pipelines in pure TypeScript would require shipping large wasm builds of the same engines.
* **External binary orchestration** Converters and OCR call out to native tools such as LibreOffice, WeasyPrint, OCRmyPDF, and Tesseract, marshalling intermediate files before re-wrapping the results.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java†L78-L214】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java†L76-L214】 Without comparable browser binaries we cannot achieve parity for those tasks.
* **Digital signatures and certificate validation** Signing invokes BouncyCastle to parse keystores, craft PKCS#7 envelopes, and paint visible signature appearances, while validation performs PKIX path building and revocation checks.【F:app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java†L1-L199】【F:app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java†L68-L220】 Browser crypto APIs deliberately omit those PDF-specific primitives.
Because the features we still need from PDFBox span encryption, name-tree rewrites, rasterisation, native CLI orchestration, and advanced cryptography, the current frontend libraries cannot satisfy the outstanding requirements on their own. A wasm port of a native PDF engine would be required before we could retire these backend endpoints.
+55
View File
@@ -0,0 +1,55 @@
# Frontend tool dependency overview
This document summarises how each Stirling PDF frontend tool currently executes its work, identifying whether the implementation already runs entirely in the browser or relies on backend REST APIs.
## Key observations
* With the exception of a handful of tools, the React hooks delegate processing to `useToolOperation`, which ultimately posts the selected files to backend endpoints via `apiClient.post`.【F:frontend/src/hooks/tools/shared/useToolOperation.ts†L213-L267】【F:frontend/src/hooks/tools/shared/useToolApiCalls.ts†L19-L103】
* Automation flows rely on the same backend-driven operations through the shared executor, which performs direct Axios POST requests and ZIP handling.【F:frontend/src/utils/automationExecutor.ts†L52-L127】
* **Adjust Contrast** and **Remove Annotations** remain pure client-side processors today, and **Rotate** now offers an optional browser-based mode powered by `pdf-lib`, while Swagger UI is a static documentation viewer. All other PDF manipulations depend on backend services.
## Tool matrix
| Tool | Operation Hook(s) | Endpoint(s) | Frontend-only viability |
| --- | --- | --- | --- |
| AddAttachments | AddAttachments: frontend/src/hooks/tools/addAttachments/useAddAttachmentsOperation.ts【F:frontend/src/hooks/tools/addAttachments/useAddAttachmentsOperation.ts†L2-L33】 | AddAttachments: /api/v1/misc/add-attachments【F:frontend/src/hooks/tools/addAttachments/useAddAttachmentsOperation.ts†L2-L33】 | Requires backend API for PDF processing |
| AddPageNumbers | AddPageNumbers: frontend/src/components/tools/addPageNumbers/useAddPageNumbersOperation.ts【F:frontend/src/components/tools/addPageNumbers/useAddPageNumbersOperation.ts†L2-L31】 | AddPageNumbers: /api/v1/misc/add-page-numbers【F:frontend/src/components/tools/addPageNumbers/useAddPageNumbersOperation.ts†L2-L31】 | Requires backend API for PDF processing |
| AddPassword | AddPassword: frontend/src/hooks/tools/addPassword/useAddPasswordOperation.ts【F:frontend/src/hooks/tools/addPassword/useAddPasswordOperation.ts†L2-L39】 | AddPassword: /api/v1/security/add-password【F:frontend/src/hooks/tools/addPassword/useAddPasswordOperation.ts†L2-L39】 | Requires backend API for PDF processing |
| AddStamp | AddStamp: frontend/src/components/tools/addStamp/useAddStampOperation.ts【F:frontend/src/components/tools/addStamp/useAddStampOperation.ts†L2-L65】 | AddStamp: /api/v1/misc/add-stamp【F:frontend/src/components/tools/addStamp/useAddStampOperation.ts†L2-L65】 | Toggle between backend processing and new browser stamping pipeline using pdf-lib【F:frontend/src/components/tools/addStamp/useAddStampOperation.ts†L2-L65】【F:frontend/src/utils/pdfOperations/addStamp.ts†L1-L120】 |
| AddWatermark | AddWatermark: frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts【F:frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts†L2-L65】 | AddWatermark: /api/v1/security/add-watermark【F:frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts†L2-L65】 | Toggle between backend processing and new browser watermarking pipeline using pdf-lib【F:frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts†L2-L65】【F:frontend/src/utils/pdfOperations/addWatermark.ts†L1-L99】 |
| AdjustContrast | AdjustContrast: frontend/src/hooks/tools/adjustContrast/useAdjustContrastOperation.ts【F:frontend/src/hooks/tools/adjustContrast/useAdjustContrastOperation.ts†L2-L89】 | — | Already frontend-only (client-side implementation) |
| AdjustPageScale | AdjustPageScale: frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts【F:frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts†L2-L25】 | AdjustPageScale: /api/v1/general/scale-pages【F:frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts†L2-L25】 | Requires backend API for PDF processing |
| AutoRename | AutoRename: frontend/src/hooks/tools/autoRename/useAutoRenameOperation.ts【F:frontend/src/hooks/tools/autoRename/useAutoRenameOperation.ts†L2-L38】 | AutoRename: /api/v1/misc/auto-rename【F:frontend/src/hooks/tools/autoRename/useAutoRenameOperation.ts†L2-L38】 | Requires backend API for PDF processing |
| Automate | Automate: frontend/src/hooks/tools/automate/useAutomateOperation.ts【F:frontend/src/hooks/tools/automate/useAutomateOperation.ts†L1-L45】 | — | Requires backend API for PDF processing |
| BookletImposition | BookletImposition: frontend/src/hooks/tools/bookletImposition/useBookletImpositionOperation.ts【F:frontend/src/hooks/tools/bookletImposition/useBookletImpositionOperation.ts†L2-L33】 | BookletImposition: /api/v1/general/booklet-imposition【F:frontend/src/hooks/tools/bookletImposition/useBookletImpositionOperation.ts†L2-L33】 | Requires backend API for PDF processing |
| CertSign | CertSign: frontend/src/hooks/tools/certSign/useCertSignOperation.ts【F:frontend/src/hooks/tools/certSign/useCertSignOperation.ts†L2-L67】 | CertSign: /api/v1/security/cert-sign【F:frontend/src/hooks/tools/certSign/useCertSignOperation.ts†L2-L67】 | Requires backend API for PDF processing |
| ChangeMetadata | ChangeMetadata: frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.ts【F:frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.ts†L2-L66】 | ChangeMetadata: /api/v1/misc/update-metadata【F:frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.ts†L2-L66】 | Requires backend API for PDF processing |
| ChangePermissions | ChangePermissions: frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.ts【F:frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.ts†L2-L37】 | ChangePermissions: /api/v1/security/add-password【F:frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.ts†L2-L37】 | Requires backend API for PDF processing |
| Compress | Compress: frontend/src/hooks/tools/compress/useCompressOperation.ts【F:frontend/src/hooks/tools/compress/useCompressOperation.ts†L2-L37】 | Compress: /api/v1/misc/compress-pdf【F:frontend/src/hooks/tools/compress/useCompressOperation.ts†L2-L37】 | Requires backend API for PDF processing |
| Convert | Convert: frontend/src/hooks/tools/convert/useConvertOperation.ts【F:frontend/src/hooks/tools/convert/useConvertOperation.ts†L6-L156】 | — | Requires backend API for PDF processing |
| Crop | Crop: frontend/src/hooks/tools/crop/useCropOperation.ts【F:frontend/src/hooks/tools/crop/useCropOperation.ts†L2-L45】 | Crop: /api/v1/general/crop【F:frontend/src/hooks/tools/crop/useCropOperation.ts†L2-L45】 | Toggle between backend processing and new browser cropping pipeline using pdf-lib【F:frontend/src/hooks/tools/crop/useCropOperation.ts†L2-L45】【F:frontend/src/utils/pdfOperations/crop.ts†L1-L33】 |
| ExtractImages | ExtractImages: frontend/src/hooks/tools/extractImages/useExtractImagesOperation.ts【F:frontend/src/hooks/tools/extractImages/useExtractImagesOperation.ts†L3-L36】 | ExtractImages: /api/v1/misc/extract-images【F:frontend/src/hooks/tools/extractImages/useExtractImagesOperation.ts†L3-L36】 | Requires backend API for PDF processing |
| Flatten | Flatten: frontend/src/hooks/tools/flatten/useFlattenOperation.ts【F:frontend/src/hooks/tools/flatten/useFlattenOperation.ts†L2-L27】 | Flatten: /api/v1/misc/flatten【F:frontend/src/hooks/tools/flatten/useFlattenOperation.ts†L2-L27】 | Requires backend API for PDF processing |
| Merge | Merge: frontend/src/hooks/tools/merge/useMergeOperation.ts【F:frontend/src/hooks/tools/merge/useMergeOperation.ts†L2-L34】 | Merge: /api/v1/general/merge-pdfs【F:frontend/src/hooks/tools/merge/useMergeOperation.ts†L2-L34】 | Requires backend API for PDF processing |
| OCR | OCR: frontend/src/hooks/tools/ocr/useOCROperation.ts【F:frontend/src/hooks/tools/ocr/useOCROperation.ts†L4-L124】 | OCR: /api/v1/misc/ocr-pdf【F:frontend/src/hooks/tools/ocr/useOCROperation.ts†L4-L124】 | Requires backend API for PDF processing |
| OverlayPdfs | OverlayPdfs: frontend/src/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts【F:frontend/src/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts†L2-L38】 | OverlayPdfs: /api/v1/general/overlay-pdfs【F:frontend/src/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts†L2-L38】 | Requires backend API for PDF processing |
| PageLayout | PageLayout: frontend/src/hooks/tools/pageLayout/usePageLayoutOperation.ts【F:frontend/src/hooks/tools/pageLayout/usePageLayoutOperation.ts†L2-L25】 | PageLayout: /api/v1/general/multi-page-layout【F:frontend/src/hooks/tools/pageLayout/usePageLayoutOperation.ts†L2-L25】 | Requires backend API for PDF processing |
| Redact | Redact: frontend/src/hooks/tools/redact/useRedactOperation.ts【F:frontend/src/hooks/tools/redact/useRedactOperation.ts†L2-L46】 | Redact: /api/v1/security/auto-redact【F:frontend/src/hooks/tools/redact/useRedactOperation.ts†L2-L46】 | Requires backend API for PDF processing |
| RemoveAnnotations | RemoveAnnotations: frontend/src/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts【F:frontend/src/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts†L2-L92】 | — | Already frontend-only (client-side implementation) |
| RemoveBlanks | RemoveBlanks: frontend/src/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts【F:frontend/src/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts†L3-L34】 | RemoveBlanks: /api/v1/misc/remove-blanks【F:frontend/src/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts†L3-L34】 | Requires backend API for PDF processing |
| RemoveCertificateSign | RemoveCertificateSign: frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts【F:frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts†L2-L25】 | RemoveCertificateSign: /api/v1/security/remove-cert-sign【F:frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts†L2-L25】 | Requires backend API for PDF processing |
| RemoveImage | RemoveImage: frontend/src/hooks/tools/removeImage/useRemoveImageOperation.ts【F:frontend/src/hooks/tools/removeImage/useRemoveImageOperation.ts†L2-L22】 | RemoveImage: /api/v1/general/remove-image-pdf【F:frontend/src/hooks/tools/removeImage/useRemoveImageOperation.ts†L2-L22】 | Requires backend API for PDF processing |
| RemovePages | RemovePages: frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts【F:frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts†L2-L47】 | RemovePages: /api/v1/general/remove-pages【F:frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts†L2-L47】 | Toggle between backend processing and new browser page removal pipeline using pdf-lib【F:frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts†L2-L47】【F:frontend/src/utils/pdfOperations/removePages.ts†L1-L27】 |
| RemovePassword | RemovePassword: frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.ts【F:frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.ts†L2-L26】 | RemovePassword: /api/v1/security/remove-password【F:frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.ts†L2-L26】 | Requires backend API for PDF processing |
| ReorganizePages | ReorganizePages: frontend/src/hooks/tools/reorganizePages/useReorganizePagesOperation.ts【F:frontend/src/hooks/tools/reorganizePages/useReorganizePagesOperation.ts†L2-L28】 | ReorganizePages: /api/v1/general/rearrange-pages【F:frontend/src/hooks/tools/reorganizePages/useReorganizePagesOperation.ts†L2-L28】 | Requires backend API for PDF processing |
| Repair | Repair: frontend/src/hooks/tools/repair/useRepairOperation.ts【F:frontend/src/hooks/tools/repair/useRepairOperation.ts†L2-L25】 | Repair: /api/v1/misc/repair【F:frontend/src/hooks/tools/repair/useRepairOperation.ts†L2-L25】 | Requires backend API for PDF processing |
| ReplaceColor | ReplaceColor: frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts【F:frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts†L2-L34】 | ReplaceColor: /api/v1/misc/replace-invert-pdf【F:frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts†L2-L34】 | Requires backend API for PDF processing |
| Rotate | Rotate: frontend/src/hooks/tools/rotate/useRotateOperation.ts【F:frontend/src/hooks/tools/rotate/useRotateOperation.ts†L1-L35】 | Rotate: /api/v1/general/rotate-pdf【F:frontend/src/hooks/tools/rotate/useRotateOperation.ts†L18-L25】 | Toggle between backend processing and new frontend pdf-lib implementation【F:frontend/src/utils/pdfOperations/rotate.ts†L1-L38】【F:frontend/src/components/tools/rotate/RotateSettings.tsx†L1-L121】 |
| Sanitize | Sanitize: frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts【F:frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts†L2-L35】 | Sanitize: /api/v1/security/sanitize-pdf【F:frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts†L2-L35】 | Requires backend API for PDF processing |
| ScannerImageSplit | ScannerImageSplit: frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts【F:frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts†L3-L60】 | ScannerImageSplit: /api/v1/misc/extract-image-scans【F:frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts†L3-L60】 | Requires backend API for PDF processing |
| Sign | Sign: frontend/src/hooks/tools/sign/useSignOperation.ts【F:frontend/src/hooks/tools/sign/useSignOperation.ts†L2-L55】 | Sign: /api/v1/security/add-signature【F:frontend/src/hooks/tools/sign/useSignOperation.ts†L2-L55】 | Requires backend API for PDF processing |
| SingleLargePage | SingleLargePage: frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts【F:frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts†L2-L25】 | SingleLargePage: /api/v1/general/pdf-to-single-page【F:frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts†L2-L25】 | Requires backend API for PDF processing |
| Split | Split: frontend/src/hooks/tools/split/useSplitOperation.ts【F:frontend/src/hooks/tools/split/useSplitOperation.ts†L3-L96】 | Split: getSplitEndpoint【F:frontend/src/hooks/tools/split/useSplitOperation.ts†L3-L96】 | Requires backend API for PDF processing |
| SwaggerUI | None | N/A | Already frontend-only (documentation viewer) |
| UnlockPdfForms | UnlockPdfForms: frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts【F:frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts†L2-L25】 | UnlockPdfForms: /api/v1/misc/unlock-pdf-forms【F:frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts†L2-L25】 | Requires backend API for PDF processing |
| ValidateSignature | ValidateSignature: frontend/src/hooks/tools/validateSignature/useValidateSignatureOperation.ts【F:frontend/src/hooks/tools/validateSignature/useValidateSignatureOperation.ts†L5-L91】 | ValidateSignature: /api/v1/security/validate-signature【F:frontend/src/hooks/tools/validateSignature/useValidateSignatureOperation.ts†L5-L91】 | Requires backend API for PDF processing |
+11 -1
View File
@@ -3104,6 +3104,9 @@
},
"results": {
"title": "Single Page Results"
},
"settings": {
"title": "Single page options"
}
},
"pageExtracter": {
@@ -4195,7 +4198,14 @@
"used": "used",
"available": "available",
"cancel": "Cancel",
"preview": "Preview"
"preview": "Preview",
"processingMode": {
"label": "Processing mode",
"backend": "Backend",
"frontend": "Browser",
"defaultFrontendDesc": "Process locally in your browser without uploading files.",
"defaultBackendDesc": "Use the server to process files."
}
},
"config": {
"overview": {
@@ -0,0 +1,62 @@
import { Stack, Text, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ProcessingMode } from '@app/types/parameters';
export interface ProcessingModeToggleProps {
value: ProcessingMode;
onChange: (mode: ProcessingMode) => void;
disabled?: boolean;
frontendDescriptionKey?: string;
backendDescriptionKey?: string;
}
/**
* Reusable processing mode toggle component for tools that support both
* frontend (browser-based) and backend (server-based) processing.
*/
const ProcessingModeToggle = ({
value,
onChange,
disabled = false,
frontendDescriptionKey,
backendDescriptionKey
}: ProcessingModeToggleProps) => {
const { t } = useTranslation();
const frontendDesc = frontendDescriptionKey
? t(frontendDescriptionKey, 'Process locally in your browser without uploading files.')
: t('common.processingMode.defaultFrontendDesc', 'Process locally in your browser without uploading files.');
const backendDesc = backendDescriptionKey
? t(backendDescriptionKey, 'Use the server to process files.')
: t('common.processingMode.defaultBackendDesc', 'Use the server to process files.');
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('common.processingMode.label', 'Processing mode')}
</Text>
<SegmentedControl
value={value}
onChange={(s => {onChange(s as ProcessingMode)})}
data={[
{
label: t('common.processingMode.backend', 'Backend'),
value: 'backend'
},
{
label: t('common.processingMode.frontend', 'Browser'),
value: 'frontend'
}
]}
fullWidth
disabled={disabled}
/>
<Text size="xs" c="dimmed">
{value === 'frontend' ? frontendDesc : backendDesc}
</Text>
</Stack>
);
};
export default ProcessingModeToggle;
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { AddPageNumbersParameters } from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
import { Tooltip } from "@app/components/shared/Tooltip";
import PageNumberPreview from "@app/components/tools/addPageNumbers/PageNumberPreview";
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
interface AddPageNumbersPositionSettingsProps {
parameters: AddPageNumbersParameters;
@@ -27,6 +28,12 @@ const AddPageNumbersPositionSettings = ({
return (
<Stack gap="lg">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
{/* Position Selection */}
<Stack gap="md">
<PageNumberPreview
@@ -1,7 +1,9 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { ToolType, useToolOperation, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { AddPageNumbersParameters, defaultParameters } from '@app/components/tools/addPageNumbers/useAddPageNumbersParameters';
import { addPageNumbersClientSide } from '@app/utils/pdfOperations/addPageNumbers';
import { validatePageNumbers } from '@app/utils/pageSelection';
export const buildAddPageNumbersFormData = (parameters: AddPageNumbersParameters, file: File): FormData => {
const formData = new FormData();
@@ -23,7 +25,18 @@ export const addPageNumbersOperationConfig = {
operationType: 'addPageNumbers',
endpoint: '/api/v1/misc/add-page-numbers',
defaultParameters,
} as const;
frontendProcessing: {
process: addPageNumbersClientSide,
shouldUseFrontend: (params) => {
if (params.processingMode !== 'frontend') return false;
const selection = params.pagesToNumber?.trim();
if (!selection) return true;
if (selection.toLowerCase().includes('n')) return false;
return validatePageNumbers(selection);
},
statusMessage: 'Adding page numbers in browser...'
}
} as const satisfies ToolOperationConfig<AddPageNumbersParameters>;
export const useAddPageNumbersOperation = () => {
const { t } = useTranslation();
@@ -34,4 +47,4 @@ export const useAddPageNumbersOperation = () => {
t('addPageNumbers.error.failed', 'An error occurred while adding page numbers to the PDF.')
),
});
};
};
@@ -1,7 +1,7 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters} from '@app/types/parameters';
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface AddPageNumbersParameters extends BaseParameters {
export interface AddPageNumbersParameters extends BaseParameters, ToggleableProcessingParameters {
customMargin: 'small' | 'medium' | 'large' | 'x-large';
position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
fontSize: number;
@@ -19,6 +19,7 @@ export const defaultParameters: AddPageNumbersParameters = {
startingNumber: 1,
pagesToNumber: '',
customText: '',
processingMode: 'backend',
};
export type AddPageNumbersParametersHook = BaseParametersHook<AddPageNumbersParameters>;
@@ -26,9 +27,9 @@ export type AddPageNumbersParametersHook = BaseParametersHook<AddPageNumbersPara
export const useAddPageNumbersParameters = (): AddPageNumbersParametersHook => {
return useBaseParameters<AddPageNumbersParameters>({
defaultParameters,
endpointName: 'add-page-numbers',
endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'add-page-numbers'),
validateFn: (params): boolean => {
return params.fontSize > 0 && params.startingNumber > 0;
},
});
};
};
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { Stack, Textarea, TextInput, Select, Button, Text, Divider } from "@mantine/core";
import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
import styles from "@app/components/tools/addStamp/StampPreview.module.css";
import { getDefaultFontSizeForAlphabet } from "@app/components/tools/addStamp/StampPreviewUtils";
@@ -22,6 +23,11 @@ const StampSetupSettings = ({ parameters, onParameterChange, disabled = false }:
onChange={(e) => onParameterChange('pageNumbers', e.currentTarget.value)}
disabled={disabled}
/>
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
<Divider/>
<div>
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
@@ -2,12 +2,13 @@ import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { AddStampParameters, defaultParameters } from '@app/components/tools/addStamp/useAddStampParameters';
import { addStampClientSide } from '@app/utils/pdfOperations/addStamp';
export const buildAddStampFormData = (parameters: AddStampParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('pageNumbers', parameters.pageNumbers);
formData.append('customMargin', parameters.customMargin || 'medium');
formData.append('customMargin', parameters.customMargin || 'medium');
formData.append('position', String(parameters.position));
const effectiveFontSize = parameters.fontSize;
formData.append('fontSize', String(effectiveFontSize));
@@ -35,6 +36,21 @@ export const addStampOperationConfig = {
operationType: 'addStamp',
endpoint: '/api/v1/misc/add-stamp',
defaultParameters,
frontendProcessing: {
process: addStampClientSide,
shouldUseFrontend: (params: AddStampParameters) => {
if (!params.stampType) return false;
if (params.stampType === 'image') {
const type = params.stampImage?.type || '';
if (!/png|jpe?g/i.test(type)) return false;
}
if (!params.pageNumbers || params.pageNumbers.toLowerCase().includes('n')) {
return false;
}
return true;
},
statusMessage: 'Placing stamp in browser...'
}
} as const;
export const useAddStampOperation = () => {
@@ -1,17 +1,17 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface AddStampParameters extends BaseParameters {
export interface AddStampParameters extends BaseParameters, ToggleableProcessingParameters {
stampType?: 'text' | 'image';
stampText: string;
stampImage?: File;
alphabet: 'roman' | 'arabic' | 'japanese' | 'korean' | 'chinese' | 'thai';
fontSize: number;
rotation: number;
fontSize: number;
rotation: number;
opacity: number;
position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
overrideX: number;
overrideY: number;
overrideY: number;
customMargin: 'small' | 'medium' | 'large' | 'x-large';
customColor: string;
pageNumbers: string;
@@ -32,6 +32,7 @@ export const defaultParameters: AddStampParameters = {
customColor: '#d3d3d3',
pageNumbers: '1',
_activePill: 'fontSize',
processingMode: 'backend',
};
export type AddStampParametersHook = BaseParametersHook<AddStampParameters>;
@@ -39,7 +40,7 @@ export type AddStampParametersHook = BaseParametersHook<AddStampParameters>;
export const useAddStampParameters = (): AddStampParametersHook => {
return useBaseParameters<AddStampParameters>({
defaultParameters,
endpointName: 'add-stamp',
endpointName: (params) => params.processingMode === 'frontend' ? '' : 'add-stamp',
validateFn: (params): boolean => {
if (!params.stampType) return false;
if (params.stampType === 'text') {
@@ -2,6 +2,7 @@ import { Stack, Checkbox, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
import NumberInputWithUnit from "@app/components/tools/shared/NumberInputWithUnit";
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
interface WatermarkFormattingProps {
parameters: AddWatermarkParameters;
@@ -14,6 +15,12 @@ const WatermarkFormatting = ({ parameters, onParameterChange, disabled = false }
return (
<Stack gap="md">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
{/* Size - single row */}
<NumberInputWithUnit
label={t('watermark.settings.size', 'Size')}
@@ -19,6 +19,7 @@ describe('AdjustPageScaleSettings', () => {
const defaultParameters: AdjustPageScaleParameters = {
scaleFactor: 1.0,
pageSize: PageSize.KEEP,
processingMode: 'backend',
};
const mockOnParameterChange = vi.fn();
@@ -46,6 +47,7 @@ describe('AdjustPageScaleSettings', () => {
const customParameters: AdjustPageScaleParameters = {
scaleFactor: 2.5,
pageSize: PageSize.A4,
processingMode: 'backend',
};
render(
@@ -1,6 +1,7 @@
import { Stack, NumberInput, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AdjustPageScaleParameters, PageSize } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
interface AdjustPageScaleSettingsProps {
parameters: AdjustPageScaleParameters;
@@ -26,6 +27,12 @@ const AdjustPageScaleSettings = ({ parameters, onParameterChange, disabled = fal
return (
<Stack gap="md">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
<NumberInput
label={t('adjustPageScale.scaleFactor.label', 'Scale Factor')}
value={parameters.scaleFactor}
@@ -2,6 +2,7 @@ import { Stack, Divider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ChangeMetadataParameters, createCustomMetadataFunctions } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
import { useMetadataExtraction } from "@app/hooks/tools/changeMetadata/useMetadataExtraction";
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
import DeleteAllStep from "@app/components/tools/changeMetadata/steps/DeleteAllStep";
import StandardMetadataStep from "@app/components/tools/changeMetadata/steps/StandardMetadataStep";
import DocumentDatesStep from "@app/components/tools/changeMetadata/steps/DocumentDatesStep";
@@ -36,6 +37,12 @@ const ChangeMetadataSingleStep = ({
return (
<Stack gap="md">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
{/* Delete All */}
<Stack gap="md">
<Text size="sm" fw={500}>
@@ -185,7 +185,7 @@ describe('ChangePermissionsSettings', () => {
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
permissionKeys.forEach(permission => {
expect(mockT).toHaveBeenCalledWith(`changePermissions.permissions.${permission}.label`, permission);
expect(mockT).toHaveBeenCalledWith(`changePermissions.permissions.${permission}.label`, { defaultValue: permission });
});
});
@@ -17,8 +17,8 @@ const ChangePermissionsSettings = ({ parameters, onParameterChange, disabled = f
{(Object.keys(parameters) as Array<keyof ChangePermissionsParameters>).map((key) => (
<Checkbox
key={key}
label={t(`changePermissions.permissions.${key}.label`, key)}
checked={parameters[key]}
label={t(`changePermissions.permissions.${String(key)}.label`, { defaultValue: String(key) })}
checked={parameters[key] as boolean}
onChange={(e) => onParameterChange(key, e.target.checked)}
disabled={disabled}
/>
@@ -15,6 +15,7 @@ import {
} from "@app/utils/cropCoordinates";
import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
interface CropSettingsProps {
parameters: CropParametersHook;
@@ -151,6 +152,11 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
return (
<Stack gap="md" data-tour="crop-settings">
<ProcessingModeToggle
value={parameters.parameters.processingMode}
onChange={(mode) => parameters.updateParameter('processingMode', mode)}
disabled={disabled}
/>
{/* PDF Preview with Crop Selector */}
<Stack gap="xs">
<Group justify="space-between" align="center">
@@ -1,6 +1,7 @@
import { Stack, Text, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { FlattenParameters } from "@app/hooks/tools/flatten/useFlattenParameters";
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
interface FlattenSettingsProps {
parameters: FlattenParameters;
@@ -13,6 +14,14 @@ const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: Fl
return (
<Stack gap="md">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
frontendDescriptionKey="flatten.processingMode.frontendDescription"
backendDescriptionKey="flatten.processingMode.backendDescription"
/>
<Stack gap="sm">
<Checkbox
checked={parameters.flattenOnlyForms}
@@ -32,4 +41,4 @@ const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: Fl
);
};
export default FlattenSettings;
export default FlattenSettings;
@@ -19,6 +19,7 @@ describe('MergeSettings', () => {
const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
processingMode: 'backend',
};
const mockOnParameterChange = vi.fn();
@@ -37,8 +38,8 @@ describe('MergeSettings', () => {
</TestWrapper>
);
// Should render one checkbox for each parameter
const expectedCheckboxCount = Object.keys(defaultParameters).length;
// Should render one checkbox for each parameter (excluding processingMode which is a SegmentedControl)
const expectedCheckboxCount = Object.keys(defaultParameters).filter(k => k !== 'processingMode').length;
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(expectedCheckboxCount);
});
@@ -2,6 +2,7 @@ import React from 'react';
import { Stack, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { MergeParameters } from '@app/hooks/tools/merge/useMergeParameters';
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
interface MergeSettingsProps {
parameters: MergeParameters;
@@ -18,6 +19,12 @@ const MergeSettings: React.FC<MergeSettingsProps> = ({
return (
<Stack gap="md">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
<Checkbox
label={t('merge.removeDigitalSignature', 'Remove digital signature in the merged file?')}
checked={parameters.removeDigitalSignature}
@@ -2,6 +2,7 @@ import { Divider, Select, Stack, Switch } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PageLayoutParameters } from '@app/hooks/tools/pageLayout/usePageLayoutParameters';
import { getPagesPerSheetOptions } from '@app/components/tools/pageLayout/constants';
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
export default function PageLayoutSettings({
parameters,
@@ -22,6 +23,12 @@ export default function PageLayoutSettings({
return (
<Stack gap="sm">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
<Select
label={t('pageLayout.pagesPerSheet', 'Pages per sheet:')}
data={options.map(o => ({ value: String(o.value), label: o.label }))}
@@ -2,6 +2,7 @@ import { Stack, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { RemovePagesParameters } from "@app/hooks/tools/removePages/useRemovePagesParameters";
import { validatePageNumbers } from "@app/utils/pageSelection";
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
interface RemovePagesSettingsProps {
parameters: RemovePagesParameters;
@@ -16,7 +17,6 @@ const RemovePagesSettings = ({ parameters, onParameterChange, disabled = false }
// Allow user to type naturally - don't normalize input in real-time
onParameterChange('pageNumbers', value);
};
console.log('Current pageNumbers input:', parameters.pageNumbers, disabled);
// Check if current input is valid
const isValid = validatePageNumbers(parameters.pageNumbers || '');
@@ -24,6 +24,11 @@ const RemovePagesSettings = ({ parameters, onParameterChange, disabled = false }
return (
<Stack gap="md">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
<TextInput
label={t('removePages.pageNumbers.label', 'Pages to Remove')}
value={parameters.pageNumbers || ''}
@@ -2,6 +2,7 @@ import { Divider, Select, Stack, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ReorganizePagesParameters } from '@app/hooks/tools/reorganizePages/useReorganizePagesParameters';
import { getReorganizePagesModeData } from '@app/components/tools/reorganizePages/constants';
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
export default function ReorganizePagesSettings({
parameters,
@@ -22,6 +23,12 @@ export default function ReorganizePagesSettings({
const selectedMode = modeData.find(mode => mode.value === parameters.customMode) || modeData[0];
return (
<Stack gap="sm">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
<Select
label={t('pdfOrganiser.mode._value', 'Organization mode')}
data={modeData}
@@ -6,6 +6,7 @@ import RotateRightIcon from "@mui/icons-material/RotateRight";
import { RotateParametersHook } from "@app/hooks/tools/rotate/useRotateParameters";
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
interface RotateSettingsProps {
parameters: RotateParametersHook;
@@ -33,6 +34,12 @@ const RotateSettings = ({ parameters, disabled = false }: RotateSettingsProps) =
return (
<Stack gap="md">
<ProcessingModeToggle
value={parameters.parameters.processingMode}
onChange={(mode) => parameters.updateParameter('processingMode', mode)}
disabled={disabled}
/>
{/* Thumbnail Preview Section */}
<Stack gap="xs">
<Text size="sm" fw={500}>
@@ -160,10 +160,10 @@ describe('SanitizeSettings', () => {
);
// Verify that translation keys are being called (just check that it was called, not specific order)
expect(mockT).toHaveBeenCalledWith('sanitize.options.title', expect.any(String));
expect(mockT).toHaveBeenCalledWith('sanitize.options.removeJavaScript', expect.any(String));
expect(mockT).toHaveBeenCalledWith('sanitize.options.removeEmbeddedFiles', expect.any(String));
expect(mockT).toHaveBeenCalledWith('sanitize.options.note', expect.any(String));
expect(mockT).toHaveBeenCalledWith('sanitize.options.title', 'Sanitization Options');
expect(mockT).toHaveBeenCalledWith('sanitize.options.removeJavaScript', { defaultValue: 'removeJavaScript' });
expect(mockT).toHaveBeenCalledWith('sanitize.options.removeEmbeddedFiles', { defaultValue: 'removeEmbeddedFiles' });
expect(mockT).toHaveBeenCalledWith('sanitize.options.note', 'Select the elements you want to remove from the PDF. At least one option must be selected.');
});
test('should not call onParameterChange when disabled', () => {
@@ -13,8 +13,8 @@ const SanitizeSettings = ({ parameters, onParameterChange, disabled = false }: S
const options = (Object.keys(defaultParameters) as Array<keyof SanitizeParameters>).map((key) => ({
key: key,
label: t(`sanitize.options.${key}`, key),
description: t(`sanitize.options.${key}.desc`, `${key} from the PDF`),
label: t(`sanitize.options.${String(key)}`, { defaultValue: String(key) }),
description: t(`sanitize.options.${String(key)}.desc`, { defaultValue: `${String(key)} from the PDF` }),
default: defaultParameters[key],
}));
@@ -28,7 +28,7 @@ const SanitizeSettings = ({ parameters, onParameterChange, disabled = false }: S
{options.map((option) => (
<Checkbox
key={option.key}
checked={parameters[option.key]}
checked={parameters[option.key] as boolean}
onChange={(event) => onParameterChange(option.key, event.currentTarget.checked)}
disabled={disabled}
label={
@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text } from '@mantine/core';
import { SingleLargePageParameters } from '@app/hooks/tools/singleLargePage/useSingleLargePageParameters';
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
interface SingleLargePageSettingsProps {
parameters: SingleLargePageParameters;
@@ -8,15 +10,21 @@ interface SingleLargePageSettingsProps {
disabled?: boolean;
}
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = (_) => {
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({ parameters, onParameterChange, disabled = false }) => {
const { t } = useTranslation();
return (
<div className="single-large-page-settings">
<p className="text-muted">
<Stack gap="sm">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
<Text size="sm" c="dimmed">
{t('pdfToSinglePage.description', 'This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights.')}
</p>
</div>
</Text>
</Stack>
);
};
@@ -3,6 +3,7 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { useTranslation } from 'react-i18next';
import { SPLIT_METHODS } from '@app/constants/splitConstants';
import { SplitParameters } from '@app/hooks/tools/split/useSplitParameters';
import ProcessingModeToggle from "@app/components/shared/ProcessingModeToggle";
export interface SplitSettingsProps {
parameters: SplitParameters;
@@ -148,6 +149,12 @@ const SplitSettings = ({
return (
<Stack gap="md">
<ProcessingModeToggle
value={parameters.processingMode}
onChange={(mode) => onParameterChange('processingMode', mode)}
disabled={disabled}
/>
{/* Method-Specific Form */}
{parameters.method === SPLIT_METHODS.BY_PAGES && renderByPagesForm()}
{parameters.method === SPLIT_METHODS.BY_SECTIONS && renderBySectionsForm()}
@@ -47,10 +47,13 @@ describe('useAddPasswordOperation', () => {
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
supportsFrontendProcessing: false,
evaluateShouldUseFrontend: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(mockToolOperationReturn.evaluateShouldUseFrontend as any).mockReturnValue(false);
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { AddWatermarkParameters, defaultParameters } from '@app/hooks/tools/addWatermark/useAddWatermarkParameters';
import { addWatermarkClientSide } from '@app/utils/pdfOperations/addWatermark';
// Static function that can be used by both the hook and automation executor
export const buildAddWatermarkFormData = (parameters: AddWatermarkParameters, file: File): FormData => {
@@ -40,6 +41,19 @@ export const addWatermarkOperationConfig = {
operationType: 'watermark',
endpoint: '/api/v1/security/add-watermark',
defaultParameters,
frontendProcessing: {
process: addWatermarkClientSide,
shouldUseFrontend: (params: AddWatermarkParameters) => {
if (params.convertPDFToImage) return false;
if (!params.watermarkType) return false;
if (params.watermarkType === 'image') {
const type = params.watermarkImage?.type || '';
return /png|jpe?g/i.test(type);
}
return true;
},
statusMessage: 'Adding watermark in browser...'
}
} as const;
export const useAddWatermarkOperation = () => {
@@ -1,7 +1,7 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface AddWatermarkParameters extends BaseParameters {
export interface AddWatermarkParameters extends BaseParameters, ToggleableProcessingParameters {
watermarkType?: 'text' | 'image';
watermarkText: string;
watermarkImage?: File;
@@ -25,7 +25,8 @@ export const defaultParameters: AddWatermarkParameters = {
heightSpacer: 50,
alphabet: 'roman',
customColor: '#d3d3d3',
convertPDFToImage: false
convertPDFToImage: false,
processingMode: 'backend',
};
export type AddWatermarkParametersHook = BaseParametersHook<AddWatermarkParameters>;
@@ -33,7 +34,7 @@ export type AddWatermarkParametersHook = BaseParametersHook<AddWatermarkParamete
export const useAddWatermarkParameters = (): AddWatermarkParametersHook => {
return useBaseParameters({
defaultParameters: defaultParameters,
endpointName: 'add-watermark',
endpointName: (params) => params.processingMode === 'frontend' ? '' : 'add-watermark',
validateFn: (params): boolean => {
if (!params.watermarkType) {
return false;
@@ -1,7 +1,8 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { useToolOperation, ToolType, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { AdjustPageScaleParameters, defaultParameters } from '@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
import { adjustPageScaleClientSide } from '@app/utils/pdfOperations/adjustPageScale';
export const buildAdjustPageScaleFormData = (parameters: AdjustPageScaleParameters, file: File): FormData => {
const formData = new FormData();
@@ -17,7 +18,12 @@ export const adjustPageScaleOperationConfig = {
operationType: 'scalePages',
endpoint: '/api/v1/general/scale-pages',
defaultParameters,
} as const;
frontendProcessing: {
process: adjustPageScaleClientSide,
shouldUseFrontend: (params: AdjustPageScaleParameters) => params.processingMode === 'frontend',
statusMessage: 'Scaling pages in browser...'
}
} as const satisfies ToolOperationConfig<AdjustPageScaleParameters>;
export const useAdjustPageScaleOperation = () => {
const { t } = useTranslation();
@@ -1,4 +1,4 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export enum PageSize {
@@ -14,7 +14,7 @@ export enum PageSize {
LEGAL = 'LEGAL'
}
export interface AdjustPageScaleParameters extends BaseParameters {
export interface AdjustPageScaleParameters extends BaseParameters, ToggleableProcessingParameters {
scaleFactor: number;
pageSize: PageSize;
}
@@ -22,6 +22,7 @@ export interface AdjustPageScaleParameters extends BaseParameters {
export const defaultParameters: AdjustPageScaleParameters = {
scaleFactor: 1.0,
pageSize: PageSize.KEEP,
processingMode: 'backend',
};
export type AdjustPageScaleParametersHook = BaseParametersHook<AdjustPageScaleParameters>;
@@ -29,7 +30,7 @@ export type AdjustPageScaleParametersHook = BaseParametersHook<AdjustPageScalePa
export const useAdjustPageScaleParameters = (): AdjustPageScaleParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'scale-pages',
endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'scale-pages'),
validateFn: (params) => {
return params.scaleFactor > 0;
},
@@ -5,7 +5,7 @@ import { AutoRenameParameters, defaultParameters } from '@app/hooks/tools/autoRe
export const getFormData = ((parameters: AutoRenameParameters) =>
Object.entries(parameters).map(([key, value]) =>
[key, value.toString()]
[key, String(value)]
) as string[][]
);
@@ -18,6 +18,7 @@ describe('buildChangeMetadataFormData', () => {
trapped: TrappedStatus.UNKNOWN,
customMetadata: [],
deleteAll: false,
processingMode: 'backend',
};
test.each([
@@ -1,7 +1,8 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { useToolOperation, ToolType, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { ChangeMetadataParameters, defaultParameters } from '@app/hooks/tools/changeMetadata/useChangeMetadataParameters';
import { changeMetadataClientSide } from '@app/utils/pdfOperations/changeMetadata';
// Helper function to format Date object to string
const formatDateForBackend = (date: Date | null): string => {
@@ -58,7 +59,12 @@ export const changeMetadataOperationConfig = {
operationType: 'changeMetadata',
endpoint: '/api/v1/misc/update-metadata',
defaultParameters,
} as const;
frontendProcessing: {
process: changeMetadataClientSide,
shouldUseFrontend: (params: ChangeMetadataParameters) => params.processingMode === 'frontend',
statusMessage: 'Updating metadata in browser...'
}
} as const satisfies ToolOperationConfig<ChangeMetadataParameters>;
export const useChangeMetadataOperation = () => {
const { t } = useTranslation();
@@ -1,8 +1,8 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { TrappedStatus, CustomMetadataEntry } from '@app/types/metadata';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface ChangeMetadataParameters extends BaseParameters {
export interface ChangeMetadataParameters extends BaseParameters, ToggleableProcessingParameters {
// Standard PDF metadata fields
title: string;
author: string;
@@ -37,6 +37,7 @@ export const defaultParameters: ChangeMetadataParameters = {
trapped: TrappedStatus.UNKNOWN,
customMetadata: [],
deleteAll: false,
processingMode: 'backend',
};
// Global counter for custom metadata IDs
@@ -117,7 +118,7 @@ export type ChangeMetadataParametersHook = BaseParametersHook<ChangeMetadataPara
export const useChangeMetadataParameters = (): ChangeMetadataParametersHook => {
const base = useBaseParameters({
defaultParameters,
endpointName: 'update-metadata',
endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'update-metadata'),
validateFn: validateParameters,
});
@@ -46,10 +46,13 @@ describe('useChangePermissionsOperation', () => {
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
supportsFrontendProcessing: false,
evaluateShouldUseFrontend: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(mockToolOperationReturn.evaluateShouldUseFrontend as any).mockReturnValue(false);
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
@@ -97,7 +100,7 @@ describe('useChangePermissionsOperation', () => {
expect(formData.get('fileInput')).toBe(testFile);
(Object.keys(testParameters) as Array<keyof ChangePermissionsParameters>).forEach(key => {
expect(formData.get(key), `Parameter ${key} should be set correctly`).toBe(testParameters[key].toString());
expect(formData.get(String(key)), `Parameter ${String(key)} should be set correctly`).toBe(String(testParameters[key]));
});
});
@@ -5,7 +5,7 @@ import { ChangePermissionsParameters, defaultParameters } from '@app/hooks/tools
export const getFormData = ((parameters: ChangePermissionsParameters) =>
Object.entries(parameters).map(([key, value]) =>
[key, value.toString()]
[key, String(value)]
) as string[][]
);
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { CropParameters, defaultParameters } from '@app/hooks/tools/crop/useCropParameters';
import { cropPdfClientSide } from '@app/utils/pdfOperations/crop';
// Static configuration that can be used by both the hook and automation executor
export const buildCropFormData = (parameters: CropParameters, file: File): FormData => {
@@ -25,6 +26,11 @@ export const cropOperationConfig = {
operationType: 'crop',
endpoint: '/api/v1/general/crop',
defaultParameters,
frontendProcessing: {
process: cropPdfClientSide,
shouldUseFrontend: () => true,
statusMessage: 'Cropping PDF in browser...'
}
} as const;
export const useCropOperation = () => {
@@ -1,15 +1,16 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { useCallback } from 'react';
import { Rectangle, PDFBounds, constrainCropAreaToPDF, createFullPDFCropArea, roundCropArea, isRectangle } from '@app/utils/cropCoordinates';
import { DEFAULT_CROP_AREA } from '@app/constants/cropConstants';
export interface CropParameters extends BaseParameters {
export interface CropParameters extends BaseParameters, ToggleableProcessingParameters {
cropArea: Rectangle;
}
export const defaultParameters: CropParameters = {
cropArea: DEFAULT_CROP_AREA,
processingMode: 'backend',
};
export type CropParametersHook = BaseParametersHook<CropParameters> & {
@@ -30,7 +31,7 @@ export type CropParametersHook = BaseParametersHook<CropParameters> & {
export const useCropParameters = (): CropParametersHook => {
const baseHook = useBaseParameters({
defaultParameters,
endpointName: 'crop',
endpointName: (params) => params.processingMode === 'frontend' ? '' : 'crop',
validateFn: (params) => {
const rect = params.cropArea;
// Basic validation - coordinates and dimensions must be positive
@@ -1,7 +1,8 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { ToolType, useToolOperation, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { FlattenParameters, defaultParameters } from '@app/hooks/tools/flatten/useFlattenParameters';
import { flattenPdfClientSide } from '@app/utils/pdfOperations/flatten';
// Static function that can be used by both the hook and automation executor
export const buildFlattenFormData = (parameters: FlattenParameters, file: File): FormData => {
@@ -17,9 +18,14 @@ export const flattenOperationConfig = {
buildFormData: buildFlattenFormData,
operationType: 'flatten',
endpoint: '/api/v1/misc/flatten',
multiFileEndpoint: false,
defaultParameters,
} as const;
frontendProcessing: {
process: flattenPdfClientSide,
shouldUseFrontend: (params: FlattenParameters) =>
params.processingMode === 'frontend' && params.flattenOnlyForms,
statusMessage: 'Flattening PDF forms in browser...'
}
} as const satisfies ToolOperationConfig<FlattenParameters>;
export const useFlattenOperation = () => {
const { t } = useTranslation();
@@ -1,12 +1,13 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface FlattenParameters extends BaseParameters {
export interface FlattenParameters extends BaseParameters, ToggleableProcessingParameters {
flattenOnlyForms: boolean;
}
export const defaultParameters: FlattenParameters = {
flattenOnlyForms: false,
processingMode: 'backend',
};
export type FlattenParametersHook = BaseParametersHook<FlattenParameters>;
@@ -14,6 +15,6 @@ export type FlattenParametersHook = BaseParametersHook<FlattenParameters>;
export const useFlattenParameters = (): FlattenParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'flatten',
endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'flatten'),
});
};
};
@@ -47,11 +47,14 @@ describe('useMergeOperation', () => {
cancelOperation: vi.fn(),
undoOperation: function (): Promise<void> {
throw new Error('Function not implemented.');
}
},
supportsFrontendProcessing: false,
evaluateShouldUseFrontend: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(mockToolOperationReturn.evaluateShouldUseFrontend as any).mockReturnValue(false);
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
@@ -65,7 +68,8 @@ describe('useMergeOperation', () => {
];
const parameters: MergeParameters = {
removeDigitalSignature: true,
generateTableOfContents: false
generateTableOfContents: false,
processingMode: 'backend'
};
const formData = config.buildFormData(parameters, mockFiles);
@@ -102,7 +106,8 @@ describe('useMergeOperation', () => {
// Test case 1: All options disabled
const params1: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false
generateTableOfContents: false,
processingMode: 'backend'
};
const formData1 = config.buildFormData(params1, mockFiles);
expect(formData1.get('removeCertSign')).toBe('false');
@@ -111,7 +116,8 @@ describe('useMergeOperation', () => {
// Test case 2: All options enabled
const params2: MergeParameters = {
removeDigitalSignature: true,
generateTableOfContents: true
generateTableOfContents: true,
processingMode: 'backend'
};
const formData2 = config.buildFormData(params2, mockFiles);
expect(formData2.get('removeCertSign')).toBe('true');
@@ -1,7 +1,8 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { MergeParameters } from '@app/hooks/tools/merge/useMergeParameters';
import { MergeParameters, defaultParameters } from '@app/hooks/tools/merge/useMergeParameters';
import { mergePdfClientSide } from '@app/utils/pdfOperations/merge';
const buildFormData = (parameters: MergeParameters, files: File[]): FormData => {
const formData = new FormData();
@@ -26,6 +27,18 @@ export const mergeOperationConfig: ToolOperationConfig<MergeParameters> = {
operationType: 'merge',
endpoint: '/api/v1/general/merge-pdfs',
filePrefix: 'merged_',
defaultParameters,
frontendProcessing: {
process: mergePdfClientSide,
shouldUseFrontend: (params: MergeParameters) => {
if (params.processingMode !== 'frontend') return false;
// Browser processing doesn't support these advanced features
if (params.removeDigitalSignature) return false;
if (params.generateTableOfContents) return false;
return true;
},
statusMessage: 'Merging PDFs in browser...'
}
};
export const useMergeOperation = () => {
@@ -1,7 +1,7 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { BaseParametersHook, useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
export interface MergeParameters extends BaseParameters {
export interface MergeParameters extends BaseParameters, ToggleableProcessingParameters {
removeDigitalSignature: boolean;
generateTableOfContents: boolean;
};
@@ -9,6 +9,7 @@ export interface MergeParameters extends BaseParameters {
export const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
processingMode: 'backend',
};
export type MergeParametersHook = BaseParametersHook<MergeParameters>;
@@ -16,6 +17,6 @@ export type MergeParametersHook = BaseParametersHook<MergeParameters>;
export const useMergeParameters = (): MergeParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "merge-pdfs",
endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'merge-pdfs'),
});
};
@@ -1,7 +1,8 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { ToolType, useToolOperation, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { PageLayoutParameters, defaultParameters } from '@app/hooks/tools/pageLayout/usePageLayoutParameters';
import { pageLayoutClientSide } from '@app/utils/pdfOperations/pageLayout';
export const buildPageLayoutFormData = (parameters: PageLayoutParameters, file: File): FormData => {
const formData = new FormData();
@@ -17,7 +18,12 @@ export const pageLayoutOperationConfig = {
operationType: 'pageLayout',
endpoint: '/api/v1/general/multi-page-layout',
defaultParameters,
} as const;
frontendProcessing: {
process: pageLayoutClientSide,
shouldUseFrontend: (params) => params.processingMode === 'frontend',
statusMessage: 'Creating multi-page layout in browser...'
}
} as const satisfies ToolOperationConfig<PageLayoutParameters>;
export const usePageLayoutOperation = () => {
const { t } = useTranslation();
@@ -1,7 +1,7 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface PageLayoutParameters extends BaseParameters {
export interface PageLayoutParameters extends BaseParameters, ToggleableProcessingParameters {
pagesPerSheet: number;
addBorder: boolean;
}
@@ -9,6 +9,7 @@ export interface PageLayoutParameters extends BaseParameters {
export const defaultParameters: PageLayoutParameters = {
pagesPerSheet: 4,
addBorder: false,
processingMode: 'backend',
};
export type PageLayoutParametersHook = BaseParametersHook<PageLayoutParameters>;
@@ -16,7 +17,7 @@ export type PageLayoutParametersHook = BaseParametersHook<PageLayoutParameters>;
export const usePageLayoutParameters = (): PageLayoutParametersHook => {
return useBaseParameters<PageLayoutParameters>({
defaultParameters,
endpointName: 'multi-page-layout',
endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'multi-page-layout'),
});
};
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemovePagesParameters, defaultParameters } from '@app/hooks/tools/removePages/useRemovePagesParameters';
import { removePagesClientSide } from '@app/utils/pdfOperations/removePages';
// import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
export const buildRemovePagesFormData = (parameters: RemovePagesParameters, file: File): FormData => {
@@ -18,6 +19,21 @@ export const removePagesOperationConfig = {
operationType: 'removePages',
endpoint: '/api/v1/general/remove-pages',
defaultParameters,
frontendProcessing: {
process: removePagesClientSide,
shouldUseFrontend: (params: RemovePagesParameters) => {
const raw = params.pageNumbers?.trim();
if (!raw) return false;
const parts = raw.replace(/\s+/g, '').split(',').filter(Boolean);
return parts.every((part) => {
const token = part.toLowerCase();
if (token === 'all') return true;
if (token.includes('n')) return false;
return /^\d+$/.test(token) || /^\d+-\d+$/.test(token) || /^\d+-$/.test(token);
});
},
statusMessage: 'Removing pages in browser...'
}
} as const satisfies ToolOperationConfig<RemovePagesParameters>;
export const useRemovePagesOperation = () => {
@@ -1,13 +1,14 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { validatePageNumbers } from '@app/utils/pageSelection';
export interface RemovePagesParameters extends BaseParameters {
export interface RemovePagesParameters extends BaseParameters, ToggleableProcessingParameters {
pageNumbers: string; // comma-separated page numbers or ranges (e.g., "1,3,5-8")
}
export const defaultParameters: RemovePagesParameters = {
pageNumbers: '',
processingMode: 'backend',
};
export type RemovePagesParametersHook = BaseParametersHook<RemovePagesParameters>;
@@ -15,7 +16,7 @@ export type RemovePagesParametersHook = BaseParametersHook<RemovePagesParameters
export const useRemovePagesParameters = (): RemovePagesParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-pages',
endpointName: (params) => params.processingMode === 'frontend' ? '' : 'remove-pages',
validateFn: (p) => validatePageNumbers(p.pageNumbers),
});
};
@@ -45,10 +45,13 @@ describe('useRemovePasswordOperation', () => {
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
supportsFrontendProcessing: false,
evaluateShouldUseFrontend: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(mockToolOperationReturn.evaluateShouldUseFrontend as any).mockReturnValue(false);
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { ToolOperationConfig, ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { ReorganizePagesParameters } from '@app/hooks/tools/reorganizePages/useReorganizePagesParameters';
import { reorganizePagesClientSide } from '@app/utils/pdfOperations/reorganizePages';
const buildFormData = (parameters: ReorganizePagesParameters, file: File): FormData => {
const formData = new FormData();
@@ -21,6 +22,18 @@ export const reorganizePagesOperationConfig: ToolOperationConfig<ReorganizePages
buildFormData,
operationType: 'reorganizePages',
endpoint: '/api/v1/general/rearrange-pages',
frontendProcessing: {
process: reorganizePagesClientSide,
shouldUseFrontend: (params) => {
if (params.processingMode !== 'frontend') return false;
if (!params.customMode || params.customMode === '' || params.customMode === 'CUSTOM') {
if (!params.pageNumbers.trim()) return true;
return !params.pageNumbers.toLowerCase().includes('n');
}
return true;
},
statusMessage: 'Reordering pages in browser...'
}
};
export const useReorganizePagesOperation = () => {
@@ -1,6 +1,8 @@
import { useState } from 'react';
export interface ReorganizePagesParameters {
import { ToggleableProcessingParameters } from '@app/types/parameters';
export interface ReorganizePagesParameters extends ToggleableProcessingParameters {
customMode: string; // empty string means custom order using pageNumbers
pageNumbers: string; // e.g. "1,3,2,4-6"
}
@@ -8,6 +10,7 @@ export interface ReorganizePagesParameters {
export const defaultReorganizePagesParameters: ReorganizePagesParameters = {
customMode: '',
pageNumbers: '',
processingMode: 'backend',
};
export const useReorganizePagesParameters = () => {
@@ -46,10 +46,13 @@ describe('useRotateOperation', () => {
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
supportsFrontendProcessing: false,
evaluateShouldUseFrontend: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(mockToolOperationReturn.evaluateShouldUseFrontend as any).mockReturnValue(false);
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
@@ -68,7 +71,7 @@ describe('useRotateOperation', () => {
const callArgs = getToolConfig();
const testParameters: RotateParameters = { angle };
const testParameters: RotateParameters = { angle, processingMode: 'backend' };
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = callArgs.buildFormData(testParameters, testFile);
@@ -98,4 +101,12 @@ describe('useRotateOperation', () => {
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
test('should expose frontend processing handler', () => {
renderHook(() => useRotateOperation());
const callArgs = getToolConfig();
expect(callArgs.frontendProcessing).toBeDefined();
expect(typeof callArgs.frontendProcessing?.process).toBe('function');
});
});
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RotateParameters, defaultParameters, normalizeAngle } from '@app/hooks/tools/rotate/useRotateParameters';
import { rotatePdfClientSide } from '@app/utils/pdfOperations/rotate';
// Static configuration that can be used by both the hook and automation executor
export const buildRotateFormData = (parameters: RotateParameters, file: File): FormData => {
@@ -19,6 +20,9 @@ export const rotateOperationConfig = {
operationType: 'rotate',
endpoint: '/api/v1/general/rotate-pdf',
defaultParameters,
frontendProcessing: {
process: rotatePdfClientSide,
}
} as const;
export const useRotateOperation = () => {
@@ -8,6 +8,7 @@ describe('useRotateParameters', () => {
expect(result.current.parameters).toEqual(defaultParameters);
expect(result.current.parameters.angle).toBe(0);
expect(result.current.parameters.processingMode).toBe('backend');
expect(result.current.hasRotation).toBe(false);
});
@@ -135,6 +136,12 @@ describe('useRotateParameters', () => {
const { result } = renderHook(() => useRotateParameters());
expect(result.current.getEndpointName()).toBe('rotate-pdf');
act(() => {
result.current.updateParameter('processingMode', 'frontend');
});
expect(result.current.getEndpointName()).toBe('');
});
test('should detect rotation state correctly', () => {
@@ -1,4 +1,4 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { useMemo, useCallback } from 'react';
@@ -7,12 +7,13 @@ export const normalizeAngle = (angle: number): number => {
return ((angle % 360) + 360) % 360;
};
export interface RotateParameters extends BaseParameters {
export interface RotateParameters extends BaseParameters, ToggleableProcessingParameters {
angle: number; // Current rotation angle (0, 90, 180, 270)
}
export const defaultParameters: RotateParameters = {
angle: 0,
processingMode: 'backend',
};
export type RotateParametersHook = BaseParametersHook<RotateParameters> & {
@@ -25,7 +26,7 @@ export type RotateParametersHook = BaseParametersHook<RotateParameters> & {
export const useRotateParameters = (): RotateParametersHook => {
const baseHook = useBaseParameters({
defaultParameters,
endpointName: 'rotate-pdf',
endpointName: (params) => params.processingMode === 'frontend' ? '' : 'rotate-pdf',
validateFn: (params) => {
// Angle must be a multiple of 90
return params.angle % 90 === 0;
@@ -52,7 +52,13 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
const operation = useOperation();
// Endpoint validation using parameters hook
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(params.getEndpointName());
const { enabled: rawEndpointEnabled, loading: rawEndpointLoading } = useEndpointEnabled(params.getEndpointName());
const supportsFrontend = operation.supportsFrontendProcessing;
const usingFrontend = supportsFrontend ? operation.evaluateShouldUseFrontend(params.parameters) : false;
const endpointEnabled = usingFrontend ? true : (rawEndpointEnabled ?? false);
const endpointLoading = usingFrontend ? false : rawEndpointLoading;
// Reset results when parameters change
useEffect(() => {
@@ -12,6 +12,7 @@ import { ResponseHandler } from '@app/utils/toolResponseProcessor';
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
import { ToolOperation } from '@app/types/file';
import { ToolId } from '@app/types/toolId';
import { ProcessingMode } from '@app/types/parameters';
// Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler };
@@ -55,6 +56,14 @@ interface BaseToolOperationConfig<TParams> {
/** Default parameter values for automation */
defaultParameters?: TParams;
frontendProcessing?: FrontendProcessingConfig<TParams>;
}
export interface FrontendProcessingConfig<TParams> {
process: (params: TParams, files: File[]) => Promise<File[]>;
shouldUseFrontend?: (params: TParams) => boolean;
statusMessage?: string;
}
export interface SingleFileToolOperationConfig<TParams> extends BaseToolOperationConfig<TParams> {
@@ -124,6 +133,9 @@ export interface ToolOperationHook<TParams = void> {
clearError: () => void;
cancelOperation: () => void;
undoOperation: () => Promise<void>;
supportsFrontendProcessing: boolean;
evaluateShouldUseFrontend: (params: TParams) => boolean;
}
// Re-export for backwards compatibility
@@ -145,7 +157,7 @@ export const useToolOperation = <TParams>(
config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => {
const { t } = useTranslation();
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
const { consumeFiles, undoConsumeFiles, selectors } = useFileContext();
// Composed hooks
const { state, actions } = useToolState();
@@ -160,6 +172,21 @@ export const useToolOperation = <TParams>(
outputFileIds: FileId[];
} | null>(null);
const supportsFrontendProcessing = Boolean(config.frontendProcessing);
const evaluateShouldUseFrontend = useCallback((params: TParams): boolean => {
if (!config.frontendProcessing) return false;
if (config.frontendProcessing.shouldUseFrontend) {
try {
return config.frontendProcessing.shouldUseFrontend(params);
} catch (_error) {
return false;
}
}
const mode = (params as Record<string, unknown> & { processingMode?: ProcessingMode }).processingMode;
return mode === 'frontend';
}, [config]);
const executeOperation = useCallback(async (
params: TParams,
selectedFiles: StirlingFile[]
@@ -208,85 +235,93 @@ export const useToolOperation = <TParams>(
try {
let processedFiles: File[];
let successSourceIds: string[] = [];
let successSourceIds: string[] = [];
// Use original files directly (no PDF metadata injection - history stored in IndexedDB)
const filesForAPI = extractFiles(validFiles);
switch (config.toolType) {
case ToolType.singleFile: {
// Individual file processing - separate API call per file
const apiCallsConfig: ApiCallsConfig<TParams> = {
endpoint: config.endpoint,
buildFormData: config.buildFormData,
filePrefix: config.filePrefix,
responseHandler: config.responseHandler,
preserveBackendFilename: config.preserveBackendFilename
};
console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length });
const result = await processFiles(
params,
filesForAPI,
apiCallsConfig,
actions.setProgress,
actions.setStatus,
fileActions.markFileError as any
);
processedFiles = result.outputFiles;
successSourceIds = result.successSourceIds as any;
console.debug('[useToolOperation] Multi-file results', { outputFiles: processedFiles.length, successSources: result.successSourceIds.length });
break;
}
case ToolType.multiFile: {
// Multi-file processing - single API call with all files
actions.setStatus('Processing files...');
const formData = config.buildFormData(params, filesForAPI);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const shouldUseFrontend = evaluateShouldUseFrontend(params);
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
if (shouldUseFrontend && config.frontendProcessing) {
actions.setStatus(config.frontendProcessing.statusMessage ?? 'Processing files in browser...');
processedFiles = await config.frontendProcessing.process(params, filesForAPI);
successSourceIds = validFiles.map(f => (f as any).fileId) as any;
} else {
switch (config.toolType) {
case ToolType.singleFile: {
// Individual file processing - separate API call per file
const apiCallsConfig: ApiCallsConfig<TParams> = {
endpoint: config.endpoint,
buildFormData: config.buildFormData,
filePrefix: config.filePrefix,
responseHandler: config.responseHandler,
preserveBackendFilename: config.preserveBackendFilename
};
console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length });
const result = await processFiles(
params,
filesForAPI,
apiCallsConfig,
actions.setProgress,
actions.setStatus,
fileActions.markFileError as any
);
processedFiles = result.outputFiles;
successSourceIds = result.successSourceIds as any;
console.debug('[useToolOperation] Multi-file results', { outputFiles: processedFiles.length, successSources: result.successSourceIds.length });
break;
}
case ToolType.multiFile: {
// Multi-file processing - single API call with all files
actions.setStatus('Processing files...');
const formData = config.buildFormData(params, filesForAPI);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, filesForAPI);
} else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - add prefix to first original filename
const filename = `${config.filePrefix}${filesForAPI[0]?.name || 'document.pdf'}`;
const singleFile = new File([response.data], filename, { type: 'application/pdf' });
processedFiles = [singleFile];
} else {
// Default: assume ZIP response for multi-file endpoints
// Note: extractZipFiles will check preferences.autoUnzip setting
processedFiles = await extractZipFiles(response.data);
}
// Assume all inputs succeeded together unless server provided an error earlier
successSourceIds = validFiles.map(f => (f as any).fileId) as any;
break;
}
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
case ToolType.custom: {
actions.setStatus('Processing files...');
processedFiles = await config.customProcessor(params, filesForAPI);
// Try to map outputs back to inputs by filename (before extension)
const inputBaseNames = new Map<string, string>();
for (const f of validFiles) {
const base = (f.name || '').replace(/\.[^.]+$/, '').toLowerCase();
inputBaseNames.set(base, (f as any).fileId);
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, filesForAPI);
} else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - add prefix to first original filename
const filename = `${config.filePrefix}${filesForAPI[0]?.name || 'document.pdf'}`;
const singleFile = new File([response.data], filename, { type: 'application/pdf' });
processedFiles = [singleFile];
} else {
// Default: assume ZIP response for multi-file endpoints
// Note: extractZipFiles will check preferences.autoUnzip setting
processedFiles = await extractZipFiles(response.data);
}
// Assume all inputs succeeded together unless server provided an error earlier
successSourceIds = validFiles.map(f => (f as any).fileId) as any;
break;
}
const mappedSuccess: string[] = [];
for (const out of processedFiles) {
const base = (out.name || '').replace(/\.[^.]+$/, '').toLowerCase();
const id = inputBaseNames.get(base);
if (id) mappedSuccess.push(id);
case ToolType.custom: {
actions.setStatus('Processing files...');
processedFiles = await config.customProcessor(params, filesForAPI);
// Try to map outputs back to inputs by filename (before extension)
const inputBaseNames = new Map<string, string>();
for (const f of validFiles) {
const base = (f.name || '').replace(/\.[^.]+$/, '').toLowerCase();
inputBaseNames.set(base, (f as any).fileId);
}
const mappedSuccess: string[] = [];
for (const out of processedFiles) {
const base = (out.name || '').replace(/\.[^.]+$/, '').toLowerCase();
const id = inputBaseNames.get(base);
if (id) mappedSuccess.push(id);
}
// Fallback to naive alignment if names don't match
if (mappedSuccess.length === 0) {
successSourceIds = validFiles.slice(0, processedFiles.length).map(f => (f as any).fileId) as any;
} else {
successSourceIds = mappedSuccess as any;
}
break;
}
// Fallback to naive alignment if names don't match
if (mappedSuccess.length === 0) {
successSourceIds = validFiles.slice(0, processedFiles.length).map(f => (f as any).fileId) as any;
} else {
successSourceIds = mappedSuccess as any;
}
break;
}
}
@@ -441,7 +476,7 @@ export const useToolOperation = <TParams>(
actions.setLoading(false);
actions.setProgress(null);
}
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles]);
}, [t, config, actions, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, evaluateShouldUseFrontend]);
const cancelOperation = useCallback(() => {
cancelApiCalls();
@@ -530,6 +565,9 @@ export const useToolOperation = <TParams>(
resetResults,
clearError: actions.clearError,
cancelOperation,
undoOperation
undoOperation,
supportsFrontendProcessing,
evaluateShouldUseFrontend
};
};
@@ -1,7 +1,8 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { ToolType, useToolOperation, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { SingleLargePageParameters, defaultParameters } from '@app/hooks/tools/singleLargePage/useSingleLargePageParameters';
import { singleLargePageClientSide } from '@app/utils/pdfOperations/singleLargePage';
// Static function that can be used by both the hook and automation executor
export const buildSingleLargePageFormData = (_parameters: SingleLargePageParameters, file: File): FormData => {
@@ -17,7 +18,12 @@ export const singleLargePageOperationConfig = {
operationType: 'pdfToSinglePage',
endpoint: '/api/v1/general/pdf-to-single-page',
defaultParameters,
} as const;
frontendProcessing: {
process: singleLargePageClientSide,
shouldUseFrontend: (params) => params.processingMode === 'frontend',
statusMessage: 'Merging pages into a single page in browser...'
}
} as const satisfies ToolOperationConfig<SingleLargePageParameters>;
export const useSingleLargePageOperation = () => {
const { t } = useTranslation();
@@ -1,12 +1,13 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface SingleLargePageParameters extends BaseParameters {
export interface SingleLargePageParameters extends BaseParameters, ToggleableProcessingParameters {
// Extends BaseParameters - ready for future parameter additions if needed
}
export const defaultParameters: SingleLargePageParameters = {
// No parameters needed
processingMode: 'backend',
};
export type SingleLargePageParametersHook = BaseParametersHook<SingleLargePageParameters>;
@@ -14,6 +15,6 @@ export type SingleLargePageParametersHook = BaseParametersHook<SingleLargePagePa
export const useSingleLargePageParameters = (): SingleLargePageParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'pdf-to-single-page',
endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'pdf-to-single-page'),
});
};
};
@@ -3,8 +3,10 @@ import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { SplitParameters, defaultParameters } from '@app/hooks/tools/split/useSplitParameters';
import { SPLIT_METHODS } from '@app/constants/splitConstants';
import { SPLIT_METHODS, type SplitMethod } from '@app/constants/splitConstants';
import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
import { splitPdfClientSide } from '@app/utils/pdfOperations/split';
import { validatePageNumbers } from '@app/utils/pageSelection';
// Static functions that can be used by both the hook and automation executor
export const buildSplitFormData = (parameters: SplitParameters, file: File): FormData => {
@@ -74,7 +76,43 @@ export const splitOperationConfig = {
operationType: 'split',
endpoint: getSplitEndpoint,
defaultParameters,
} as const;
frontendProcessing: {
process: splitPdfClientSide,
shouldUseFrontend: (params: SplitParameters) => {
if (params.processingMode !== 'frontend') return false;
// Check if method supports browser processing
const browserMethods = [
SPLIT_METHODS.BY_PAGES,
SPLIT_METHODS.BY_PAGE_COUNT,
SPLIT_METHODS.BY_DOC_COUNT,
SPLIT_METHODS.BY_SIZE
] as SplitMethod[];
if (!params.method || !browserMethods.includes(params.method)) return false;
// Method-specific validation
switch (params.method) {
case SPLIT_METHODS.BY_PAGES: {
const token = params.pages?.trim();
if (!token) return true; // Empty means split all pages
if (token.toLowerCase().includes('n')) return false; // "n-2" syntax not supported
return validatePageNumbers(token);
}
case SPLIT_METHODS.BY_PAGE_COUNT:
case SPLIT_METHODS.BY_DOC_COUNT:
case SPLIT_METHODS.BY_SIZE: {
const value = parseInt(params.splitValue, 10);
return !isNaN(value) && value > 0;
}
default:
return false;
}
},
statusMessage: 'Splitting PDF in browser...'
}
} as const satisfies ToolOperationConfig<SplitParameters>;
export const useSplitOperation = () => {
const { t } = useTranslation();
@@ -1,8 +1,8 @@
import { SPLIT_METHODS, ENDPOINTS, type SplitMethod } from '@app/constants/splitConstants';
import { BaseParameters } from '@app/types/parameters';
import { BaseParameters, ToggleableProcessingParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface SplitParameters extends BaseParameters {
export interface SplitParameters extends BaseParameters, ToggleableProcessingParameters {
method: SplitMethod | '';
pages: string;
hDiv: string;
@@ -28,12 +28,14 @@ export const defaultParameters: SplitParameters = {
includeMetadata: false,
allowDuplicates: false,
duplexMode: false,
processingMode: 'backend',
};
export const useSplitParameters = (): SplitParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: (params) => {
if (params.processingMode === 'frontend') return '';
if (!params.method) return ENDPOINTS[SPLIT_METHODS.BY_PAGES];
return ENDPOINTS[params.method as SplitMethod];
},
@@ -212,6 +212,8 @@ export const useValidateSignatureOperation = (): ValidateSignatureOperationHook
clearError,
cancelOperation,
undoOperation,
supportsFrontendProcessing: false,
evaluateShouldUseFrontend: () => false,
results,
}),
[
+27 -50
View File
@@ -1,42 +1,25 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFileSelection } from "@app/contexts/FileContext";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
import { useAddPageNumbersParameters } from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
import { useAddPageNumbersOperation } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
import AddPageNumbersPositionSettings from "@app/components/tools/addPageNumbers/AddPageNumbersPositionSettings";
import AddPageNumbersAppearanceSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const AddPageNumbers = (props: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const params = useAddPageNumbersParameters();
const operation = useAddPageNumbersOperation();
const base = useBaseTool(
'addPageNumbers',
useAddPageNumbersParameters,
useAddPageNumbersOperation,
props
);
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-page-numbers");
useEffect(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [params.parameters]);
const handleExecute = async () => {
try {
await operation.executeOperation(params.parameters, selectedFiles);
if (operation.files && onComplete) {
onComplete(operation.files);
}
} catch (error: any) {
onError?.(error?.message || t("addPageNumbers.error.failed", "Add page numbers operation failed"));
}
};
const hasFiles = selectedFiles.length > 0;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
const hasFiles = base.hasFiles;
const hasResults = base.hasResults;
enum AddPageNumbersStep {
NONE = 'none',
@@ -48,13 +31,10 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
noneValue: AddPageNumbersStep.NONE,
initialStep: AddPageNumbersStep.POSITION_AND_PAGES,
stateConditions: {
hasFiles,
hasResults
hasFiles: base.hasFiles,
hasResults: base.hasResults
},
afterResults: () => {
operation.resetResults();
onPreviewFile?.(null);
}
afterResults: base.handleSettingsReset
});
const getSteps = () => {
@@ -68,10 +48,10 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
isVisible: hasFiles || hasResults,
content: (
<AddPageNumbersPositionSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
file={selectedFiles[0] || null}
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
file={base.selectedFiles[0] || null}
showQuickGrid={true}
/>
),
@@ -85,9 +65,9 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
isVisible: hasFiles || hasResults,
content: (
<AddPageNumbersAppearanceSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
});
@@ -97,7 +77,7 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
return createToolFlow({
files: {
selectedFiles,
selectedFiles: base.selectedFiles,
isCollapsed: hasResults,
},
steps: getSteps(),
@@ -105,22 +85,19 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
text: t('addPageNumbers.submit', 'Add Page Numbers'),
isVisible: !hasResults,
loadingText: t('loading'),
onClick: handleExecute,
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: hasResults,
operation: operation,
operation: base.operation,
title: t('addPageNumbers.results.title', 'Page Number Results'),
onFileClick: (file) => onPreviewFile?.(file),
onUndo: async () => {
await operation.undoOperation();
onPreviewFile?.(null);
},
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
AddPageNumbers.tool = () => useAddPageNumbersOperation;
export default AddPageNumbers as ToolComponent;
export default AddPageNumbers as ToolComponent;
+5 -1
View File
@@ -16,7 +16,11 @@ const ReorganizePages = ({ onPreviewFile, onComplete, onError }: BaseToolProps)
const params = useReorganizePagesParameters();
const operation = useReorganizePagesOperation();
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("rearrange-pages");
const { enabled: rawEndpointEnabled, loading: rawEndpointLoading } = useEndpointEnabled("rearrange-pages");
const usingFrontend = operation.evaluateShouldUseFrontend(params.parameters);
const endpointEnabled = usingFrontend ? true : (rawEndpointEnabled ?? false);
const endpointLoading = usingFrontend ? false : rawEndpointLoading;
useEffect(() => {
operation.resetResults();
+15 -1
View File
@@ -4,6 +4,7 @@ import { useSingleLargePageParameters } from "@app/hooks/tools/singleLargePage/u
import { useSingleLargePageOperation } from "@app/hooks/tools/singleLargePage/useSingleLargePageOperation";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
import SingleLargePageSettings from "@app/components/tools/singleLargePage/SingleLargePageSettings";
const SingleLargePage = (props: BaseToolProps) => {
const { t } = useTranslation();
@@ -20,7 +21,20 @@ const SingleLargePage = (props: BaseToolProps) => {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [],
steps: [
{
title: t('pdfToSinglePage.settings.title', 'Single page options'),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: (
<SingleLargePageSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
}
],
executeButton: {
text: t("pdfToSinglePage.submit", "Convert To Single Page"),
isVisible: !base.hasResults,
+7 -1
View File
@@ -3,4 +3,10 @@
// Base interface that all tool parameters should extend
// Provides a foundation for adding common properties across all tools
// Examples of future additions: userId, sessionId, commonFlags, etc.
export type BaseParameters = object
export type BaseParameters = Record<string, unknown>;
export type ProcessingMode = 'backend' | 'frontend';
export interface ToggleableProcessingParameters {
processingMode: ProcessingMode;
}
+124
View File
@@ -21,3 +21,127 @@ export const validatePageNumbers = (pageNumbers: string): boolean => {
);
});
};
const normalizeToken = (token: string) => token.trim().toLowerCase();
const RANGE_REGEX = /^(\d+)-(\d+)$/;
const OPEN_RANGE_REGEX = /^(\d+)-$/;
export const resolvePageNumbers = (
rawInput: string,
totalPages: number
): number[] | null => {
if (!rawInput.trim()) return [];
const normalized = rawInput.replace(/\s+/g, '');
const parts = normalized.split(',').filter(Boolean);
if (parts.length === 0) return [];
const selected = new Set<number>();
for (const part of parts) {
const token = normalizeToken(part);
if (token.includes('n')) {
return null;
}
if (token === 'all') {
for (let i = 0; i < totalPages; i += 1) {
selected.add(i);
}
continue;
}
if (/^\d+$/.test(token)) {
const pageIndex = parseInt(token, 10) - 1;
if (Number.isFinite(pageIndex) && pageIndex >= 0 && pageIndex < totalPages) {
selected.add(pageIndex);
}
continue;
}
const rangeMatch = token.match(RANGE_REGEX);
if (rangeMatch) {
const start = parseInt(rangeMatch[1], 10);
const end = parseInt(rangeMatch[2], 10);
if (end < start) {
return null;
}
for (let page = start; page <= end && page <= totalPages; page += 1) {
selected.add(page - 1);
}
continue;
}
const openRangeMatch = token.match(OPEN_RANGE_REGEX);
if (openRangeMatch) {
const start = parseInt(openRangeMatch[1], 10);
for (let page = start; page <= totalPages; page += 1) {
selected.add(page - 1);
}
continue;
}
return null;
}
return Array.from(selected).sort((a, b) => a - b);
};
export const resolvePageOrderSequence = (
rawInput: string,
totalPages: number
): number[] | null => {
if (!rawInput.trim()) return [];
const parts = rawInput.split(',').map(part => part.trim()).filter(Boolean);
if (parts.length === 0) return [];
const order: number[] = [];
for (const part of parts) {
const token = part.toLowerCase();
if (token.includes('n')) {
return null;
}
if (token === 'all') {
for (let i = 0; i < totalPages; i += 1) {
order.push(i);
}
continue;
}
if (/^\d+$/.test(token)) {
const idx = parseInt(token, 10) - 1;
if (idx >= 0 && idx < totalPages) {
order.push(idx);
}
continue;
}
const rangeMatch = token.match(RANGE_REGEX);
if (rangeMatch) {
const start = parseInt(rangeMatch[1], 10);
const end = parseInt(rangeMatch[2], 10);
if (end < start) return null;
for (let page = start; page <= end && page <= totalPages; page += 1) {
order.push(page - 1);
}
continue;
}
const openRangeMatch = token.match(OPEN_RANGE_REGEX);
if (openRangeMatch) {
const start = parseInt(openRangeMatch[1], 10);
for (let page = start; page <= totalPages; page += 1) {
order.push(page - 1);
}
continue;
}
return null;
}
return order;
};
@@ -0,0 +1,113 @@
import { PDFDocument, StandardFonts } from 'pdf-lib';
import type { AddPageNumbersParameters } from '@app/components/tools/addPageNumbers/useAddPageNumbersParameters';
import { resolvePageNumbers } from '@app/utils/pageSelection';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
const PDF_MIME_TYPE = 'application/pdf';
const FONT_MAP: Record<AddPageNumbersParameters['fontType'], StandardFonts> = {
Times: StandardFonts.TimesRoman,
Helvetica: StandardFonts.Helvetica,
Courier: StandardFonts.Courier,
};
const MARGIN_MAP: Record<AddPageNumbersParameters['customMargin'], number> = {
small: 0.02,
medium: 0.035,
large: 0.05,
'x-large': 0.075,
};
const formatText = (
template: string,
pageNumber: number,
totalPages: number,
filename: string
) => {
const base = template && template.trim().length > 0 ? template : '{n}';
return base
.replace('{n}', String(pageNumber))
.replace('{total}', String(totalPages))
.replace('{filename}', filename);
};
export async function addPageNumbersClientSide(
params: AddPageNumbersParameters,
files: File[]
): Promise<File[]> {
return Promise.all(
files.map(async (file) => {
const bytes = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(bytes, { ignoreEncryption: true });
const font = await pdfDoc.embedFont(FONT_MAP[params.fontType]);
const totalPages = pdfDoc.getPageCount();
const baseName = file.name.replace(/\.[^.]+$/, '');
const marginFactor = MARGIN_MAP[params.customMargin] ?? MARGIN_MAP.medium;
const targetPages = params.pagesToNumber?.trim()
? resolvePageNumbers(params.pagesToNumber, totalPages)
: Array.from({ length: totalPages }, (_, idx) => idx);
if (targetPages === null) {
throw new Error('Invalid page selection for numbering');
}
const pageSet = new Set(targetPages);
let pageNumberValue = params.startingNumber ?? 1;
for (let index = 0; index < totalPages; index += 1) {
if (pageSet.size > 0 && !pageSet.has(index)) continue;
const page = pdfDoc.getPage(index);
const pageWidth = page.getWidth();
const pageHeight = page.getHeight();
const text = formatText(params.customText, pageNumberValue, totalPages, baseName);
const fontSize = params.fontSize;
let x = 0;
let y = 0;
if (params.position === 5) {
const textWidth = font.widthOfTextAtSize(text, fontSize);
const textHeight = font.heightAtSize(fontSize);
x = (pageWidth - textWidth) / 2;
y = (pageHeight - textHeight) / 2;
} else {
const position = params.position;
const xGroup = (position - 1) % 3;
const yGroup = 2 - Math.floor((position - 1) / 3);
if (xGroup === 0) {
x = marginFactor * pageWidth;
} else if (xGroup === 1) {
const textWidth = font.widthOfTextAtSize(text, fontSize);
x = pageWidth / 2 - textWidth / 2;
} else {
const textWidth = font.widthOfTextAtSize(text, fontSize);
x = pageWidth - marginFactor * pageWidth - textWidth;
}
if (yGroup === 0) {
y = marginFactor * pageHeight;
} else if (yGroup === 1) {
const textHeight = font.heightAtSize(fontSize);
y = pageHeight / 2 - textHeight / 2;
} else {
y = pageHeight - marginFactor * pageHeight - fontSize;
}
}
page.drawText(text, {
x,
y,
size: fontSize,
font,
});
pageNumberValue += 1;
}
const updatedBytes = await pdfDoc.save();
return createFileFromApiResponse(updatedBytes, { 'content-type': PDF_MIME_TYPE }, `${baseName}_numbersAdded.pdf`);
})
);
}
@@ -0,0 +1,196 @@
import { degrees, PDFDocument, PDFPage, rgb } from 'pdf-lib';
import type { AddStampParameters } from '@app/components/tools/addStamp/useAddStampParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import { resolvePageNumbers } from '@app/utils/pageSelection';
import { loadFontForAlphabet } from '@app/utils/pdfOperations/fontCache';
const PDF_MIME_TYPE = 'application/pdf';
const DEFAULT_STAMP_COLOR = rgb(0.83, 0.83, 0.83);
const MARGIN_FACTORS: Record<AddStampParameters['customMargin'], number> = {
small: 0.02,
medium: 0.035,
large: 0.05,
'x-large': 0.075,
};
const parseStampColor = (input: string | undefined) => {
if (!input) return DEFAULT_STAMP_COLOR;
let hex = input.trim();
if (!hex.startsWith('#')) {
hex = `#${hex}`;
}
if (hex.length === 4) {
hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
}
if (!/^#([0-9a-f]{6})$/i.test(hex)) {
return DEFAULT_STAMP_COLOR;
}
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
return rgb(r, g, b);
};
const clampOpacity = (value: number | undefined) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return 0.5;
}
return Math.max(0, Math.min(1, value / 100));
};
const getMargin = (pageWidth: number, pageHeight: number, marginKey: AddStampParameters['customMargin']) => {
const factor = MARGIN_FACTORS[marginKey] ?? MARGIN_FACTORS.medium;
return factor * ((pageWidth + pageHeight) / 2);
};
const computePositionX = (
pageWidth: number,
contentWidth: number,
position: number,
margin: number
) => {
switch (position % 3) {
case 1:
return margin;
case 2:
return (pageWidth - contentWidth) / 2;
case 0:
return pageWidth - contentWidth - margin;
default:
return margin;
}
};
const computePositionY = (
pageHeight: number,
contentHeight: number,
position: number,
margin: number
) => {
const row = Math.floor((position - 1) / 3);
switch (row) {
case 0:
return pageHeight - contentHeight - margin;
case 1:
return (pageHeight - contentHeight) / 2;
case 2:
return margin;
default:
return margin;
}
};
async function drawTextStamp(
pdfDoc: PDFDocument,
page: PDFPage,
params: AddStampParameters,
opacity: number
) {
const fontSize = params.fontSize > 0 ? params.fontSize : 12;
const font = await loadFontForAlphabet(pdfDoc, params.alphabet);
const lines = (params.stampText || '').split(/\r?\n/);
const lineHeight = font.heightAtSize(fontSize);
const blockHeight = lineHeight * Math.max(1, lines.length);
const blockWidth = lines.reduce((max, line) => Math.max(max, font.widthOfTextAtSize(line, fontSize)), 0);
const { width: pageWidth, height: pageHeight } = page.getSize();
const margin = getMargin(pageWidth, pageHeight, params.customMargin);
const baseX = params.overrideX >= 0 ? params.overrideX : computePositionX(pageWidth, blockWidth, params.position, margin);
const baseY = params.overrideY >= 0 ? params.overrideY : computePositionY(pageHeight, blockHeight, params.position, margin);
page.drawText(lines.join('\n'), {
x: baseX,
y: baseY,
size: fontSize,
font,
color: parseStampColor(params.customColor),
lineHeight,
rotate: degrees(params.rotation ?? 0),
opacity,
});
}
async function drawImageStamp(
pdfDoc: PDFDocument,
page: PDFPage,
params: AddStampParameters,
opacity: number
) {
const stampImage = params.stampImage;
if (!stampImage) return;
const bytes = new Uint8Array(await stampImage.arrayBuffer());
const isPng = stampImage.type.includes('png');
const isJpg = stampImage.type.includes('jpg') || stampImage.type.includes('jpeg');
const embedded = isPng
? await pdfDoc.embedPng(bytes)
: isJpg
? await pdfDoc.embedJpg(bytes)
: null;
if (!embedded) {
throw new Error('Unsupported stamp image type for browser processing');
}
const aspectRatio = embedded.width / embedded.height || 1;
const desiredHeight = params.fontSize > 0 ? params.fontSize : embedded.height;
const desiredWidth = desiredHeight * aspectRatio;
const { width: pageWidth, height: pageHeight } = page.getSize();
const margin = getMargin(pageWidth, pageHeight, params.customMargin);
const baseX = params.overrideX >= 0 ? params.overrideX : computePositionX(pageWidth, desiredWidth, params.position, margin);
const baseY = params.overrideY >= 0 ? params.overrideY : computePositionY(pageHeight, desiredHeight, params.position, margin);
page.drawImage(embedded, {
x: baseX,
y: baseY,
width: desiredWidth,
height: desiredHeight,
rotate: degrees(params.rotation ?? 0),
opacity,
});
}
export async function addStampClientSide(
params: AddStampParameters,
files: File[]
): Promise<File[]> {
const opacity = clampOpacity(params.opacity);
return Promise.all(files.map(async (file) => {
const bytes = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(bytes, { ignoreEncryption: true });
const targetPages = resolvePageNumbers(params.pageNumbers || '', pdfDoc.getPageCount());
if (targetPages === null) {
throw new Error('Page selection is not supported in browser mode');
}
if (targetPages.length === 0) {
return createFileFromApiResponse(bytes, { 'content-type': PDF_MIME_TYPE }, file.name);
}
for (const pageIndex of targetPages) {
const page = pdfDoc.getPage(pageIndex);
if (!page) continue;
if (params.stampType === 'image') {
await drawImageStamp(pdfDoc, page, params, opacity);
} else {
await drawTextStamp(pdfDoc, page, params, opacity);
}
}
const pdfBytes = await pdfDoc.save();
return createFileFromApiResponse(pdfBytes, { 'content-type': PDF_MIME_TYPE }, file.name);
}));
}
@@ -0,0 +1,161 @@
import { degrees, PDFDocument, rgb } from 'pdf-lib';
import type { AddWatermarkParameters } from '@app/hooks/tools/addWatermark/useAddWatermarkParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import { loadFontForAlphabet } from '@app/utils/pdfOperations/fontCache';
const PDF_MIME_TYPE = 'application/pdf';
const DEFAULT_WATERMARK_COLOR = rgb(0.83, 0.83, 0.83);
const toRadians = (degreesValue: number) => (degreesValue * Math.PI) / 180;
const parseHexColor = (input: string | undefined) => {
if (!input) return DEFAULT_WATERMARK_COLOR;
let hex = input.trim();
if (!hex.startsWith('#')) {
hex = `#${hex}`;
}
if (hex.length === 4) {
hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
}
if (!/^#([0-9a-f]{6})$/i.test(hex)) {
return DEFAULT_WATERMARK_COLOR;
}
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
return rgb(r, g, b);
};
const clampOpacity = (opacity: number | undefined) => {
if (typeof opacity !== 'number' || Number.isNaN(opacity)) return 0.5;
return Math.max(0, Math.min(1, opacity / 100));
};
async function applyTextWatermark(
pdfDoc: PDFDocument,
params: AddWatermarkParameters,
opacity: number
) {
const font = await loadFontForAlphabet(pdfDoc, params.alphabet);
const lines = (params.watermarkText || '').split(/\r?\n/);
const fontSize = params.fontSize > 0 ? params.fontSize : 12;
const widths = lines.map(line => font.widthOfTextAtSize(line || '', fontSize));
const maxLineWidth = widths.reduce((max, width) => Math.max(max, width), 0);
const lineHeight = font.heightAtSize(fontSize);
const blockHeight = lineHeight * Math.max(1, lines.length);
const tileWidth = maxLineWidth + (params.widthSpacer ?? 0);
const tileHeight = blockHeight + (params.heightSpacer ?? 0);
const rad = toRadians(params.rotation ?? 0);
const rotatedWidth = Math.abs(tileWidth * Math.cos(rad)) + Math.abs(tileHeight * Math.sin(rad));
const rotatedHeight = Math.abs(tileWidth * Math.sin(rad)) + Math.abs(tileHeight * Math.cos(rad));
const color = parseHexColor(params.customColor);
pdfDoc.getPages().forEach(page => {
const { width: pageWidth, height: pageHeight } = page.getSize();
const columns = Math.ceil(pageWidth / Math.max(rotatedWidth, 1)) + 1;
const rows = Math.ceil(pageHeight / Math.max(rotatedHeight, 1)) + 1;
for (let row = 0; row <= rows; row += 1) {
for (let column = 0; column <= columns; column += 1) {
const x = column * rotatedWidth;
const y = row * rotatedHeight;
page.drawText(lines.join('\n'), {
x,
y,
size: fontSize,
font,
color,
rotate: degrees(params.rotation ?? 0),
lineHeight,
opacity,
});
}
}
});
}
async function applyImageWatermark(
pdfDoc: PDFDocument,
params: AddWatermarkParameters,
opacity: number
) {
const watermarkImage = params.watermarkImage;
if (!watermarkImage) return;
const imageBytes = new Uint8Array(await watermarkImage.arrayBuffer());
const isPng = watermarkImage.type.includes('png');
const isJpg = watermarkImage.type.includes('jpg') || watermarkImage.type.includes('jpeg');
const image = isPng
? await pdfDoc.embedPng(imageBytes)
: isJpg
? await pdfDoc.embedJpg(imageBytes)
: null;
if (!image) {
throw new Error('Unsupported watermark image type for browser processing');
}
const aspectRatio = image.width / image.height;
const desiredHeight = params.fontSize > 0 ? params.fontSize : image.height;
const desiredWidth = desiredHeight * (aspectRatio || 1);
const tileWidth = desiredWidth + (params.widthSpacer ?? 0);
const tileHeight = desiredHeight + (params.heightSpacer ?? 0);
const rad = toRadians(params.rotation ?? 0);
const rotatedWidth = Math.abs(tileWidth * Math.cos(rad)) + Math.abs(tileHeight * Math.sin(rad));
const rotatedHeight = Math.abs(tileWidth * Math.sin(rad)) + Math.abs(tileHeight * Math.cos(rad));
pdfDoc.getPages().forEach(page => {
const { width: pageWidth, height: pageHeight } = page.getSize();
const columns = Math.ceil(pageWidth / Math.max(rotatedWidth, 1)) + 1;
const rows = Math.ceil(pageHeight / Math.max(rotatedHeight, 1)) + 1;
for (let row = 0; row <= rows; row += 1) {
for (let column = 0; column <= columns; column += 1) {
const x = column * rotatedWidth;
const y = row * rotatedHeight;
page.drawImage(image, {
x,
y,
width: desiredWidth,
height: desiredHeight,
rotate: degrees(params.rotation ?? 0),
opacity,
});
}
}
});
}
export async function addWatermarkClientSide(
params: AddWatermarkParameters,
files: File[]
): Promise<File[]> {
const opacity = clampOpacity(params.opacity);
return Promise.all(files.map(async (file) => {
const bytes = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(bytes, { ignoreEncryption: true });
if (params.watermarkType === 'image') {
await applyImageWatermark(pdfDoc, params, opacity);
} else {
await applyTextWatermark(pdfDoc, params, opacity);
}
const pdfBytes = await pdfDoc.save();
return createFileFromApiResponse(pdfBytes, { 'content-type': PDF_MIME_TYPE }, file.name);
}));
}
@@ -0,0 +1,70 @@
import { PDFDocument } from 'pdf-lib';
import type { AdjustPageScaleParameters, PageSize } from '@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
const PDF_MIME_TYPE = 'application/pdf';
const PAGE_DIMENSIONS: Record<PageSize, [number, number]> = {
KEEP: [0, 0],
A0: [2383.94, 3370.39],
A1: [1683.78, 2383.94],
A2: [1190.55, 1683.78],
A3: [841.89, 1190.55],
A4: [595.28, 841.89],
A5: [419.53, 595.28],
A6: [297.64, 419.53],
LETTER: [612, 792],
LEGAL: [612, 1008],
};
const getTargetSize = (pageSize: PageSize, firstPage: [number, number]): [number, number] => {
if (pageSize === 'KEEP') {
return firstPage;
}
return PAGE_DIMENSIONS[pageSize];
};
export async function adjustPageScaleClientSide(
params: AdjustPageScaleParameters,
files: File[]
): Promise<File[]> {
return Promise.all(
files.map(async (file) => {
const bytes = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(bytes, { ignoreEncryption: true });
const output = await PDFDocument.create();
const [firstPage] = pdfDoc.getPages();
const firstSize: [number, number] = [firstPage.getWidth(), firstPage.getHeight()];
const targetSize = getTargetSize(params.pageSize, firstSize);
for (let i = 0; i < pdfDoc.getPageCount(); i += 1) {
const page = pdfDoc.getPage(i);
const embedded = await output.embedPage(page);
const [targetWidth, targetHeight] = targetSize;
const pageWidth = page.getWidth();
const pageHeight = page.getHeight();
const scaleWidth = targetWidth / pageWidth;
const scaleHeight = targetHeight / pageHeight;
const scale = Math.min(scaleWidth, scaleHeight) * params.scaleFactor;
const newPage = output.addPage([targetWidth, targetHeight]);
const drawWidth = pageWidth * scale;
const drawHeight = pageHeight * scale;
const x = (targetWidth - drawWidth) / 2;
const y = (targetHeight - drawHeight) / 2;
newPage.drawPage(embedded, {
x,
y,
width: drawWidth,
height: drawHeight,
});
}
const scaledBytes = await output.save();
const baseName = file.name.replace(/\.[^.]+$/, '');
return createFileFromApiResponse(scaledBytes, { 'content-type': PDF_MIME_TYPE }, `${baseName}_scaled.pdf`);
})
);
}
@@ -0,0 +1,122 @@
import { PDFDocument, PDFName, PDFString, PDFDict } from 'pdf-lib';
import type { ChangeMetadataParameters } from '@app/hooks/tools/changeMetadata/useChangeMetadataParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
const PDF_MIME_TYPE = 'application/pdf';
const STANDARD_KEYS = new Set([
'Title',
'Author',
'Subject',
'Keywords',
'Creator',
'Producer',
'CreationDate',
'ModDate',
'Trapped',
]);
const formatPdfDate = (date: Date): string => {
const pad = (value: number, length = 2) => value.toString().padStart(length, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
const offset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(offset) / 60);
const offsetMinutes = Math.abs(offset) % 60;
const sign = offset <= 0 ? '+' : '-';
return `D:${year}${month}${day}${hours}${minutes}${seconds}${sign}${pad(offsetHours)}'${pad(offsetMinutes)}'`;
};
const ensureInfoDict = (pdfDoc: PDFDocument): PDFDict => {
const infoRef = pdfDoc.context.trailerInfo.Info;
let info = infoRef ? pdfDoc.context.lookup(infoRef, PDFDict) : undefined;
if (!info) {
info = pdfDoc.context.obj({});
pdfDoc.context.trailerInfo.Info = info;
}
return info;
};
const setInfoString = (info: PDFDict, key: string, value?: string | null) => {
const trimmedKey = typeof key === 'string' ? key.trim() : key;
if (!trimmedKey) return;
try {
const pdfKey = PDFName.of(trimmedKey);
const trimmedValue = typeof value === 'string' ? value.trim() : value;
if (trimmedValue && trimmedValue.length > 0) {
info.set(pdfKey, PDFString.of(trimmedValue));
} else {
info.delete(pdfKey);
}
} catch {
// Ignore invalid custom metadata keys that cannot be represented as PDF names
}
};
const setInfoDate = (info: PDFDict, key: string, value: Date | null) => {
const pdfKey = PDFName.of(key);
if (value) {
info.set(pdfKey, PDFString.of(formatPdfDate(value)));
} else {
info.delete(pdfKey);
}
};
export async function changeMetadataClientSide(
params: ChangeMetadataParameters,
files: File[]
): Promise<File[]> {
return Promise.all(
files.map(async (file) => {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer, { ignoreEncryption: true });
let info = ensureInfoDict(pdfDoc);
if (params.deleteAll) {
info = pdfDoc.context.obj({});
pdfDoc.context.trailerInfo.Info = info;
const catalogDict = (pdfDoc.catalog as any)?.dict;
if (catalogDict) {
catalogDict.delete(PDFName.of('Metadata'));
catalogDict.delete(PDFName.of('PieceInfo'));
}
}
if (!params.deleteAll) {
setInfoString(info, 'Title', params.title);
setInfoString(info, 'Author', params.author);
setInfoString(info, 'Subject', params.subject);
setInfoString(info, 'Keywords', params.keywords);
setInfoString(info, 'Creator', params.creator);
setInfoString(info, 'Producer', params.producer);
setInfoString(info, 'Trapped', params.trapped && params.trapped !== ('UNKNOWN' as any) ? params.trapped : undefined);
setInfoDate(info, 'CreationDate', params.creationDate);
setInfoDate(info, 'ModDate', params.modificationDate);
// Remove any prior custom entries before adding the new set
const existingKeys = Array.from(info.keys());
existingKeys
.filter((name) => {
const key = name.toString().replace('/', '');
return !STANDARD_KEYS.has(key);
})
.forEach((name) => info.delete(name));
for (const entry of params.customMetadata) {
if (entry.key.trim() && entry.value.trim()) {
setInfoString(info, entry.key, entry.value);
}
}
}
const pdfBytes = await pdfDoc.save();
return createFileFromApiResponse(pdfBytes, { 'content-type': PDF_MIME_TYPE }, `${file.name.replace(/\.[^.]+$/, '')}_metadata.pdf`);
})
);
}
@@ -0,0 +1,44 @@
import { PDFDocument } from 'pdf-lib';
import type { CropParameters } from '@app/hooks/tools/crop/useCropParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
const PDF_MIME_TYPE = 'application/pdf';
export async function cropPdfClientSide(
params: CropParameters,
files: File[]
): Promise<File[]> {
const { cropArea } = params;
return Promise.all(files.map(async (file) => {
const sourceBytes = await file.arrayBuffer();
const sourceDoc = await PDFDocument.load(sourceBytes, { ignoreEncryption: true });
const outputDoc = await PDFDocument.create();
const left = cropArea.x;
const bottom = cropArea.y;
const right = cropArea.x + cropArea.width;
const top = cropArea.y + cropArea.height;
for (let index = 0; index < sourceDoc.getPageCount(); index += 1) {
const page = sourceDoc.getPage(index);
const embedded = await outputDoc.embedPage(page, {
left,
bottom,
right,
top,
});
const newPage = outputDoc.addPage([cropArea.width, cropArea.height]);
newPage.drawPage(embedded, {
x: 0,
y: 0,
width: cropArea.width,
height: cropArea.height,
});
}
const croppedBytes = await outputDoc.save();
return createFileFromApiResponse(croppedBytes, { 'content-type': PDF_MIME_TYPE }, file.name);
}));
}
@@ -0,0 +1,37 @@
import { PDFDocument } from 'pdf-lib';
import type { FlattenParameters } from '@app/hooks/tools/flatten/useFlattenParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
const PDF_MIME_TYPE = 'application/pdf';
export async function flattenPdfClientSide(
params: FlattenParameters,
files: File[]
): Promise<File[]> {
// Frontend processing only supports flattening forms (not full page rasterization)
// The shouldUseFrontend check in the operation config ensures this is only called
// when flattenOnlyForms is true, but we verify here for safety
if (!params.flattenOnlyForms) {
throw new Error('Frontend flattening only supports form flattening. Use backend for full flattening.');
}
return Promise.all(
files.map(async (file) => {
const sourceBytes = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(sourceBytes, { ignoreEncryption: true });
const form = pdfDoc.getForm();
if (form) {
try {
form.updateFieldAppearances();
} catch {
// ignore appearance update errors - flatten will continue regardless
}
form.flatten();
}
const pdfBytes = await pdfDoc.save();
return createFileFromApiResponse(pdfBytes, { 'content-type': PDF_MIME_TYPE }, file.name);
})
);
}
@@ -0,0 +1,72 @@
import { PDFFont, PDFDocument, StandardFonts } from 'pdf-lib';
type FontSource =
| { type: 'standard'; name: StandardFonts }
| { type: 'remote'; url: string };
const FONT_SOURCES: Record<string, FontSource> = {
roman: { type: 'standard', name: StandardFonts.Helvetica },
arabic: { type: 'remote', url: '/static/fonts/NotoSansArabic-Regular.ttf' },
japanese: { type: 'remote', url: '/static/fonts/Meiryo.ttf' },
korean: { type: 'remote', url: '/static/fonts/malgun.ttf' },
chinese: { type: 'remote', url: '/static/fonts/SimSun.ttf' },
thai: { type: 'remote', url: '/static/fonts/NotoSansThai-Regular.ttf' },
};
const fontBytesCache = new Map<string, Uint8Array>();
const embeddedFontCache = new WeakMap<PDFDocument, Map<string, Promise<PDFFont>>>();
const FALLBACK_FONT = { type: 'standard', name: StandardFonts.Helvetica } as const;
async function fetchFontBytes(url: string): Promise<Uint8Array> {
const cached = fontBytesCache.get(url);
if (cached) {
return cached;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch font from ${url}`);
}
const arrayBuffer = await response.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
fontBytesCache.set(url, bytes);
return bytes;
}
export async function loadFontForAlphabet(
pdfDoc: PDFDocument,
alphabet: string | undefined
): Promise<PDFFont> {
const key = alphabet && FONT_SOURCES[alphabet] ? alphabet : 'roman';
const source = FONT_SOURCES[key] ?? FALLBACK_FONT;
let perDocCache = embeddedFontCache.get(pdfDoc);
if (!perDocCache) {
perDocCache = new Map<string, Promise<PDFFont>>();
embeddedFontCache.set(pdfDoc, perDocCache);
}
const cacheKey = source.type === 'standard' ? source.name : source.url;
const existing = perDocCache.get(cacheKey);
if (existing) {
return existing;
}
let fontPromise: Promise<PDFFont>;
if (source.type === 'standard') {
fontPromise = pdfDoc.embedFont(source.name, { subset: true });
} else {
fontPromise = fetchFontBytes(source.url)
.then(bytes => pdfDoc.embedFont(bytes, { subset: true }))
.catch(async () => {
// Fall back to a standard font if remote font loading fails
return pdfDoc.embedFont(StandardFonts.Helvetica, { subset: true });
});
}
perDocCache.set(cacheKey, fontPromise);
return fontPromise;
}
@@ -0,0 +1,44 @@
import { PDFDocument } from 'pdf-lib';
import type { MergeParameters } from '@app/hooks/tools/merge/useMergeParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
const PDF_MIME_TYPE = 'application/pdf';
export async function mergePdfClientSide(
_params: MergeParameters, // Cant use params in browser implementation
files: File[]
): Promise<File[]> {
if (files.length === 0) {
throw new Error('No files provided for merge');
}
// Create a new PDF document
const mergedPdf = await PDFDocument.create();
// Copy all pages from each input PDF in order
for (const file of files) {
const bytes = await file.arrayBuffer();
const sourcePdf = await PDFDocument.load(bytes, { ignoreEncryption: true });
// Copy all pages from this PDF
const pageIndices = Array.from({ length: sourcePdf.getPageCount() }, (_, i) => i);
const copiedPages = await mergedPdf.copyPages(sourcePdf, pageIndices);
// Add all copied pages to the merged document
copiedPages.forEach(page => mergedPdf.addPage(page));
}
// Note: Browser implementation doesn't support:
// - removeDigitalSignature (requires crypto operations)
// - generateTableOfContents (requires bookmark manipulation which pdf-lib doesn't support well)
// Save the merged PDF
const mergedBytes = await mergedPdf.save();
// Generate output filename
const outputName = files.length === 1
? `${files[0].name.replace(/\.[^.]+$/, '')}_merged.pdf`
: 'merged.pdf';
return [createFileFromApiResponse(mergedBytes, { 'content-type': PDF_MIME_TYPE }, outputName)];
}
@@ -0,0 +1,79 @@
import { PDFDocument, rgb } from 'pdf-lib';
import type { PageLayoutParameters } from '@app/hooks/tools/pageLayout/usePageLayoutParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
const PDF_MIME_TYPE = 'application/pdf';
const A4_WIDTH = 595.28;
const A4_HEIGHT = 841.89;
const getGrid = (pagesPerSheet: number): { columns: number; rows: number } => {
if (pagesPerSheet === 2 || pagesPerSheet === 3) {
return { columns: pagesPerSheet, rows: 1 };
}
const size = Math.sqrt(pagesPerSheet);
return { columns: size, rows: size };
};
export async function pageLayoutClientSide(
params: PageLayoutParameters,
files: File[]
): Promise<File[]> {
return Promise.all(
files.map(async (file) => {
const bytes = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(bytes, { ignoreEncryption: true });
const output = await PDFDocument.create();
const { columns, rows } = getGrid(params.pagesPerSheet);
const cellWidth = A4_WIDTH / columns;
const cellHeight = A4_HEIGHT / rows;
let currentPage = output.addPage([A4_WIDTH, A4_HEIGHT]);
let cellIndex = 0;
for (let i = 0; i < pdfDoc.getPageCount(); i += 1) {
if (cellIndex > 0 && cellIndex % params.pagesPerSheet === 0) {
currentPage = output.addPage([A4_WIDTH, A4_HEIGHT]);
cellIndex = 0;
}
const page = pdfDoc.getPage(i);
const embedded = await output.embedPage(page);
const sourceWidth = page.getWidth();
const sourceHeight = page.getHeight();
const colIndex = cellIndex % columns;
const rowIndex = Math.floor(cellIndex / columns);
const scale = Math.min(cellWidth / sourceWidth, cellHeight / sourceHeight);
const drawWidth = sourceWidth * scale;
const drawHeight = sourceHeight * scale;
const x = colIndex * cellWidth + (cellWidth - drawWidth) / 2;
const y = A4_HEIGHT - (rowIndex + 1) * cellHeight + (cellHeight - drawHeight) / 2;
currentPage.drawPage(embedded, {
x,
y,
width: drawWidth,
height: drawHeight,
});
if (params.addBorder) {
currentPage.drawRectangle({
x: colIndex * cellWidth,
y: A4_HEIGHT - (rowIndex + 1) * cellHeight,
width: cellWidth,
height: cellHeight,
borderWidth: 1.5,
borderColor: rgb(0, 0, 0),
});
}
cellIndex += 1;
}
const mergedBytes = await output.save();
const baseName = file.name.replace(/\.[^.]+$/, '');
return createFileFromApiResponse(mergedBytes, { 'content-type': PDF_MIME_TYPE }, `${baseName}_layoutChanged.pdf`);
})
);
}
@@ -0,0 +1,35 @@
import { PDFDocument } from 'pdf-lib';
import type { RemovePagesParameters } from '@app/hooks/tools/removePages/useRemovePagesParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import { resolvePageNumbers } from '@app/utils/pageSelection';
const PDF_MIME_TYPE = 'application/pdf';
export async function removePagesClientSide(
params: RemovePagesParameters,
files: File[]
): Promise<File[]> {
return Promise.all(files.map(async (file) => {
const bytes = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(bytes, { ignoreEncryption: true });
const pageCount = pdfDoc.getPageCount();
const toRemove = resolvePageNumbers(params.pageNumbers || '', pageCount);
if (toRemove === null) {
throw new Error('Page selection is not supported in browser mode');
}
if (toRemove.length === 0) {
return createFileFromApiResponse(bytes, { 'content-type': PDF_MIME_TYPE }, file.name);
}
const sorted = Array.from(new Set(toRemove)).filter(index => index >= 0 && index < pageCount).sort((a, b) => b - a);
sorted.forEach((index) => {
pdfDoc.removePage(index);
});
const outputBytes = await pdfDoc.save();
return createFileFromApiResponse(outputBytes, { 'content-type': PDF_MIME_TYPE }, file.name);
}));
}
@@ -0,0 +1,165 @@
import { PDFDocument } from 'pdf-lib';
import type { ReorganizePagesParameters } from '@app/hooks/tools/reorganizePages/useReorganizePagesParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import { resolvePageOrderSequence } from '@app/utils/pageSelection';
const PDF_MIME_TYPE = 'application/pdf';
type ModeHandler = (totalPages: number, rawOrder: string) => number[];
const clamp = (value: number, max: number) => {
if (value < 0) return 0;
if (value >= max) return max - 1;
return value;
};
const reverseOrder: ModeHandler = (total) => {
const result: number[] = [];
for (let i = total - 1; i >= 0; i -= 1) {
result.push(i);
}
return result;
};
const duplexSort: ModeHandler = (total) => {
const result: number[] = [];
const half = Math.ceil(total / 2);
for (let i = 0; i < half; i += 1) {
result.push(i);
const mirror = total - i - 1;
if (mirror >= half) {
result.push(mirror);
}
}
return result;
};
const bookletSort: ModeHandler = (total) => {
const result: number[] = [];
const limit = Math.floor(total / 2);
for (let i = 0; i < limit; i += 1) {
result.push(i);
result.push(clamp(total - i - 1, total));
}
if (total % 2 === 1) {
result.push(limit);
}
return result;
};
const sideStitchBooklet: ModeHandler = (total) => {
const result: number[] = [];
const signatures = Math.ceil(total / 4);
for (let sig = 0; sig < signatures; sig += 1) {
const base = sig * 4;
result.push(clamp(base + 3, total));
result.push(clamp(base, total));
result.push(clamp(base + 1, total));
result.push(clamp(base + 2, total));
}
return result;
};
const oddEvenSplit: ModeHandler = (total) => {
const result: number[] = [];
for (let i = 0; i < total; i += 2) result.push(i);
for (let i = 1; i < total; i += 2) result.push(i);
return result;
};
const oddEvenMerge: ModeHandler = (total) => {
const result: number[] = [];
const oddCount = Math.ceil(total / 2);
for (let i = 0; i < oddCount; i += 1) {
result.push(i);
const evenIndex = oddCount + i;
if (evenIndex < total) {
result.push(evenIndex);
}
}
return result;
};
const removeFirst: ModeHandler = (total) => {
const result: number[] = [];
for (let i = 1; i < total; i += 1) result.push(i);
return result;
};
const removeLast: ModeHandler = (total) => {
const result: number[] = [];
for (let i = 0; i < total - 1; i += 1) result.push(i);
return result;
};
const removeFirstAndLast: ModeHandler = (total) => {
const result: number[] = [];
for (let i = 1; i < total - 1; i += 1) result.push(i);
return result;
};
const duplicateMode: ModeHandler = (total, rawOrder) => {
const duplicates = Math.max(1, Number.parseInt(rawOrder.trim(), 10) || 2);
const result: number[] = [];
for (let i = 0; i < total; i += 1) {
for (let j = 0; j < duplicates; j += 1) {
result.push(i);
}
}
return result;
};
const MODE_HANDLERS: Record<string, ModeHandler> = {
REVERSE_ORDER: reverseOrder,
DUPLEX_SORT: duplexSort,
BOOKLET_SORT: bookletSort,
SIDE_STITCH_BOOKLET_SORT: sideStitchBooklet,
ODD_EVEN_SPLIT: oddEvenSplit,
ODD_EVEN_MERGE: oddEvenMerge,
REMOVE_FIRST: removeFirst,
REMOVE_LAST: removeLast,
REMOVE_FIRST_AND_LAST: removeFirstAndLast,
DUPLICATE: duplicateMode,
};
const resolveOrder = (params: ReorganizePagesParameters, totalPages: number): number[] => {
const mode = params.customMode;
if (!mode || mode.toUpperCase() === 'CUSTOM') {
const resolved = resolvePageOrderSequence(params.pageNumbers, totalPages);
if (!resolved) {
throw new Error('Invalid page order');
}
return resolved.length > 0 ? resolved : Array.from({ length: totalPages }, (_, idx) => idx);
}
const handler = MODE_HANDLERS[mode.toUpperCase()];
if (!handler) {
throw new Error('Unsupported reorganize mode');
}
return handler(totalPages, params.pageNumbers || '');
};
export async function reorganizePagesClientSide(
params: ReorganizePagesParameters,
files: File[]
): Promise<File[]> {
return Promise.all(
files.map(async (file) => {
const bytes = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(bytes, { ignoreEncryption: true });
const totalPages = pdfDoc.getPageCount();
const order = resolveOrder(params, totalPages);
const output = await PDFDocument.create();
for (const index of order) {
if (index < 0 || index >= totalPages) continue;
const [copied] = await output.copyPages(pdfDoc, [index]);
output.addPage(copied);
}
const pdfBytes = await output.save();
const baseName = file.name.replace(/\.[^.]+$/, '');
return createFileFromApiResponse(pdfBytes, { 'content-type': PDF_MIME_TYPE }, `${baseName}_rearranged.pdf`);
})
);
}
@@ -0,0 +1,38 @@
import { PDFDocument, degrees } from 'pdf-lib';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import type { RotateParameters } from '@app/hooks/tools/rotate/useRotateParameters';
import { normalizeAngle } from '@app/hooks/tools/rotate/useRotateParameters';
const PDF_MIME_TYPE = 'application/pdf';
export async function rotatePdfClientSide(params: RotateParameters, files: File[]): Promise<File[]> {
const angle = normalizeAngle(params.angle);
if (angle === 0) {
// No rotation requested - return copies so downstream history treats as processed files
return Promise.all(files.map(async (file) => {
const buffer = await file.arrayBuffer();
const copy = new File([buffer], file.name, {
type: file.type || PDF_MIME_TYPE,
lastModified: Date.now(),
});
return copy;
}));
}
return Promise.all(files.map(async (file) => {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer, { ignoreEncryption: true });
const pages = pdfDoc.getPages();
for (const page of pages) {
const currentRotation = page.getRotation().angle;
const nextRotation = (currentRotation + angle) % 360;
page.setRotation(degrees(nextRotation));
}
const pdfBytes = await pdfDoc.save();
const rotatedFile = createFileFromApiResponse(pdfBytes, { 'content-type': PDF_MIME_TYPE }, file.name);
return rotatedFile;
}));
}
@@ -0,0 +1,47 @@
import { PDFDocument } from 'pdf-lib';
import type { SingleLargePageParameters } from '@app/hooks/tools/singleLargePage/useSingleLargePageParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
const PDF_MIME_TYPE = 'application/pdf';
export async function singleLargePageClientSide(
_params: SingleLargePageParameters,
files: File[]
): Promise<File[]> {
return Promise.all(
files.map(async (file) => {
const bytes = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(bytes, { ignoreEncryption: true });
const output = await PDFDocument.create();
let totalHeight = 0;
let maxWidth = 0;
const pages = pdfDoc.getPages();
for (const page of pages) {
totalHeight += page.getHeight();
maxWidth = Math.max(maxWidth, page.getWidth());
}
const newPage = output.addPage([maxWidth, totalHeight]);
let yOffset = totalHeight;
for (let i = 0; i < pages.length; i += 1) {
const page = pages[i];
const embedded = await output.embedPage(page);
const height = page.getHeight();
const width = page.getWidth();
yOffset -= height;
newPage.drawPage(embedded, {
x: 0,
y: yOffset,
width,
height,
});
}
const mergedBytes = await output.save();
const baseName = file.name.replace(/\.[^.]+$/, '');
return createFileFromApiResponse(mergedBytes, { 'content-type': PDF_MIME_TYPE }, `${baseName}_singlePage.pdf`);
})
);
}
@@ -0,0 +1,191 @@
import { PDFDocument } from 'pdf-lib';
import type { SplitParameters } from '@app/hooks/tools/split/useSplitParameters';
import { resolvePageNumbers, validatePageNumbers } from '@app/utils/pageSelection';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import { SPLIT_METHODS } from '@app/constants/splitConstants';
const PDF_MIME_TYPE = 'application/pdf';
const getSplitPointsByPages = (pages: string, totalPages: number): number[] | null => {
if (!pages.trim()) {
return Array.from({ length: totalPages }, (_, idx) => idx);
}
if (!validatePageNumbers(pages)) {
return null;
}
const resolved = resolvePageNumbers(pages, totalPages);
if (!resolved) {
return null;
}
const sorted = Array.from(new Set(resolved)).sort((a, b) => a - b);
if (sorted[sorted.length - 1] !== totalPages - 1) {
sorted.push(totalPages - 1);
}
return sorted;
};
const getSplitPointsByPageCount = (pagesPerDoc: number, totalPages: number): number[] => {
const splitPoints: number[] = [];
for (let i = pagesPerDoc - 1; i < totalPages; i += pagesPerDoc) {
splitPoints.push(i);
}
// Ensure the last page is included
if (splitPoints[splitPoints.length - 1] !== totalPages - 1) {
splitPoints.push(totalPages - 1);
}
return splitPoints;
};
const getSplitPointsByDocCount = (docCount: number, totalPages: number): number[] => {
const pagesPerDoc = Math.ceil(totalPages / docCount);
return getSplitPointsByPageCount(pagesPerDoc, totalPages);
};
const getSplitPoints = (params: SplitParameters, totalPages: number): number[] | null => {
switch (params.method) {
case SPLIT_METHODS.BY_PAGES:
return getSplitPointsByPages(params.pages, totalPages);
case SPLIT_METHODS.BY_PAGE_COUNT: {
const pagesPerDoc = parseInt(params.splitValue, 10);
if (isNaN(pagesPerDoc) || pagesPerDoc <= 0) {
throw new Error('Invalid pages per document count');
}
return getSplitPointsByPageCount(pagesPerDoc, totalPages);
}
case SPLIT_METHODS.BY_DOC_COUNT: {
const docCount = parseInt(params.splitValue, 10);
if (isNaN(docCount) || docCount <= 0) {
throw new Error('Invalid document count');
}
return getSplitPointsByDocCount(docCount, totalPages);
}
default:
throw new Error(`Unsupported split method for browser processing: ${params.method}`);
}
};
async function splitPdfBySize(
params: SplitParameters,
files: File[]
): Promise<File[]> {
// Parse size threshold in bytes (backend expects bytes)
const maxSizeBytes = parseInt(params.splitValue, 10);
if (isNaN(maxSizeBytes) || maxSizeBytes <= 0) {
throw new Error('Invalid size threshold');
}
return Promise.all(
files.flatMap(async (file) => {
const bytes = await file.arrayBuffer();
const sourcePdf = await PDFDocument.load(bytes, { ignoreEncryption: true });
const totalPages = sourcePdf.getPageCount();
const outputs: File[] = [];
let currentDoc = await PDFDocument.create();
let partIndex = 1;
for (let i = 0; i < totalPages; i++) {
// Copy the current page to the working document
const [copiedPage] = await currentDoc.copyPages(sourcePdf, [i]);
currentDoc.addPage(copiedPage);
// Check current document size
const currentBytes = await currentDoc.save();
const currentSize = currentBytes.byteLength;
// If size exceeds threshold, save this document (excluding current page if multiple pages)
if (currentSize > maxSizeBytes && currentDoc.getPageCount() > 1) {
// Remove the last page that pushed us over
currentDoc.removePage(currentDoc.getPageCount() - 1);
// Save the document without the oversized page
const finalBytes = await currentDoc.save();
const baseName = file.name.replace(/\.[^.]+$/, '');
const outputName = `${baseName}_${partIndex}.pdf`;
outputs.push(
createFileFromApiResponse(finalBytes, { 'content-type': PDF_MIME_TYPE }, outputName)
);
// Start new document with the page that was removed
currentDoc = await PDFDocument.create();
const [pageToAdd] = await currentDoc.copyPages(sourcePdf, [i]);
currentDoc.addPage(pageToAdd);
partIndex++;
}
}
// Save the final document if it has pages
if (currentDoc.getPageCount() > 0) {
const finalBytes = await currentDoc.save();
const baseName = file.name.replace(/\.[^.]+$/, '');
const outputName = `${baseName}_${partIndex}.pdf`;
outputs.push(
createFileFromApiResponse(finalBytes, { 'content-type': PDF_MIME_TYPE }, outputName)
);
}
return outputs;
})
).then((nested) => nested.flat());
}
export async function splitPdfClientSide(
params: SplitParameters,
files: File[]
): Promise<File[]> {
// Handle BY_SIZE method separately as it requires incremental size checking
if (params.method === SPLIT_METHODS.BY_SIZE) {
return splitPdfBySize(params, files);
}
// Handle BY_PAGES, BY_PAGE_COUNT, and BY_DOC_COUNT using split points
return Promise.all(
files.flatMap(async (file) => {
const bytes = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(bytes, { ignoreEncryption: true });
const totalPages = pdfDoc.getPageCount();
const splitPoints = getSplitPoints(params, totalPages);
if (!splitPoints || splitPoints.length === 0) {
throw new Error('Invalid page selection for split');
}
const outputs: File[] = [];
let previous = 0;
let partIndex = 1;
for (const splitPoint of splitPoints) {
if (splitPoint < previous) continue;
const segment = await PDFDocument.create();
const indexes: number[] = [];
for (let i = previous; i <= Math.min(splitPoint, totalPages - 1); i += 1) {
indexes.push(i);
}
if (indexes.length === 0) {
continue;
}
const copiedPages = await segment.copyPages(pdfDoc, indexes);
copiedPages.forEach((p) => segment.addPage(p));
const segmentBytes = await segment.save();
const baseName = file.name.replace(/\.[^.]+$/, '');
const outputName = `${baseName}_${partIndex}.pdf`;
outputs.push(
createFileFromApiResponse(segmentBytes, { 'content-type': PDF_MIME_TYPE }, outputName)
);
previous = splitPoint + 1;
partIndex += 1;
}
return outputs;
})
).then((nested) => nested.flat());
}