diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index 99fb34b0d..2f7337b9c 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -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 diff --git a/docs/frontend-tool-backend-analysis.md b/docs/frontend-tool-backend-analysis.md new file mode 100644 index 000000000..4bcacbe2e --- /dev/null +++ b/docs/frontend-tool-backend-analysis.md @@ -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. diff --git a/docs/frontend-tool-dependency.md b/docs/frontend-tool-dependency.md new file mode 100644 index 000000000..e2d3c5f9c --- /dev/null +++ b/docs/frontend-tool-dependency.md @@ -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 | diff --git a/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx b/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx index 2907ff498..c427dd44b 100644 --- a/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx +++ b/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx @@ -2,7 +2,7 @@ * AddPageNumbersPositionSettings - Position & Pages step */ -import { Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core"; +import { Stack, TextInput, NumberInput, Divider, Text, SegmentedControl } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { AddPageNumbersParameters } from "./useAddPageNumbersParameters"; import { Tooltip } from "../../shared/Tooltip"; @@ -27,6 +27,27 @@ const AddPageNumbersPositionSettings = ({ return ( + + + {t('addPageNumbers.processingMode.label', 'Processing mode')} + + onParameterChange('processingMode', value as AddPageNumbersParameters['processingMode'])} + data={[ + { label: t('addPageNumbers.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('addPageNumbers.processingMode.frontend', 'Browser'), value: 'frontend' } + ]} + fullWidth + disabled={disabled} + /> + + {parameters.processingMode === 'frontend' + ? t('addPageNumbers.processingMode.frontendDescription', 'Stamp page numbers locally (page lists only).') + : t('addPageNumbers.processingMode.backendDescription', 'Use the server for formula-based selection and heavy PDFs.')} + + + {/* Position Selection */} { 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; export const useAddPageNumbersOperation = () => { const { t } = useTranslation(); diff --git a/frontend/src/components/tools/addPageNumbers/useAddPageNumbersParameters.ts b/frontend/src/components/tools/addPageNumbers/useAddPageNumbersParameters.ts index ca5c1e2e1..b6d4d742f 100644 --- a/frontend/src/components/tools/addPageNumbers/useAddPageNumbersParameters.ts +++ b/frontend/src/components/tools/addPageNumbers/useAddPageNumbersParameters.ts @@ -1,7 +1,7 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, type BaseParametersHook } from '../../../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; @@ -26,7 +27,7 @@ export type AddPageNumbersParametersHook = BaseParametersHook { return useBaseParameters({ defaultParameters, - endpointName: 'add-page-numbers', + endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'add-page-numbers'), validateFn: (params): boolean => { return params.fontSize > 0 && params.startingNumber > 0; }, diff --git a/frontend/src/components/tools/addStamp/StampSetupSettings.tsx b/frontend/src/components/tools/addStamp/StampSetupSettings.tsx index 8d13073a5..ed436a979 100644 --- a/frontend/src/components/tools/addStamp/StampSetupSettings.tsx +++ b/frontend/src/components/tools/addStamp/StampSetupSettings.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Stack, Textarea, TextInput, Select, Button, Text, Divider } from "@mantine/core"; +import { Stack, Textarea, TextInput, Select, Button, Text, Divider, SegmentedControl } from "@mantine/core"; import { AddStampParameters } from "./useAddStampParameters"; import ButtonSelector from "../../shared/ButtonSelector"; import styles from "./StampPreview.module.css"; @@ -22,6 +22,23 @@ const StampSetupSettings = ({ parameters, onParameterChange, disabled = false }: onChange={(e) => onParameterChange('pageNumbers', e.currentTarget.value)} disabled={disabled} /> + + {t('addStamp.processingMode.label', 'Processing mode')} + onParameterChange('processingMode', value as 'backend' | 'frontend')} + data={[ + { label: t('addStamp.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('addStamp.processingMode.frontend', 'Browser'), value: 'frontend' }, + ]} + disabled={disabled} + /> + + {parameters.processingMode === 'frontend' + ? t('addStamp.processingMode.frontendDescription', 'Stamp selected pages directly in your browser.') + : t('addStamp.processingMode.backendDescription', 'Use the server to stamp pages (required for custom page formulas).')} + +
{t('AddStampRequest.stampType', 'Stamp Type')} diff --git a/frontend/src/components/tools/addStamp/useAddStampOperation.ts b/frontend/src/components/tools/addStamp/useAddStampOperation.ts index c41c440fc..4901644d1 100644 --- a/frontend/src/components/tools/addStamp/useAddStampOperation.ts +++ b/frontend/src/components/tools/addStamp/useAddStampOperation.ts @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { ToolType, useToolOperation } from '../../../hooks/tools/shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { AddStampParameters, defaultParameters } from './useAddStampParameters'; +import { addStampClientSide } from '../../../utils/pdfOperations/addStamp'; export const buildAddStampFormData = (parameters: AddStampParameters, file: File): FormData => { const formData = new FormData(); @@ -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 = () => { diff --git a/frontend/src/components/tools/addStamp/useAddStampParameters.ts b/frontend/src/components/tools/addStamp/useAddStampParameters.ts index 3df4900f8..0c2ee4fb2 100644 --- a/frontend/src/components/tools/addStamp/useAddStampParameters.ts +++ b/frontend/src/components/tools/addStamp/useAddStampParameters.ts @@ -1,7 +1,7 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, type BaseParametersHook } from '../../../hooks/tools/shared/useBaseParameters'; -export interface AddStampParameters extends BaseParameters { +export interface AddStampParameters extends BaseParameters, ToggleableProcessingParameters { stampType?: 'text' | 'image'; stampText: string; stampImage?: File; @@ -32,6 +32,7 @@ export const defaultParameters: AddStampParameters = { customColor: '#d3d3d3', pageNumbers: '1', _activePill: 'fontSize', + processingMode: 'backend', }; export type AddStampParametersHook = BaseParametersHook; @@ -39,7 +40,7 @@ export type AddStampParametersHook = BaseParametersHook; export const useAddStampParameters = (): AddStampParametersHook => { return useBaseParameters({ defaultParameters, - endpointName: 'add-stamp', + endpointName: (params) => params.processingMode === 'frontend' ? '' : 'add-stamp', validateFn: (params): boolean => { if (!params.stampType) return false; if (params.stampType === 'text') { diff --git a/frontend/src/components/tools/addWatermark/WatermarkFormatting.tsx b/frontend/src/components/tools/addWatermark/WatermarkFormatting.tsx index 4ebe62356..f58b77c80 100644 --- a/frontend/src/components/tools/addWatermark/WatermarkFormatting.tsx +++ b/frontend/src/components/tools/addWatermark/WatermarkFormatting.tsx @@ -1,4 +1,4 @@ -import { Stack, Checkbox, Group } from "@mantine/core"; +import { Stack, Checkbox, Group, SegmentedControl, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters"; import NumberInputWithUnit from "../shared/NumberInputWithUnit"; @@ -14,6 +14,26 @@ const WatermarkFormatting = ({ parameters, onParameterChange, disabled = false } return ( + + + {t("watermark.processingMode.label", "Processing mode")} + + onParameterChange('processingMode', value as 'backend' | 'frontend')} + data={[ + { label: t('watermark.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('watermark.processingMode.frontend', 'Browser'), value: 'frontend' }, + ]} + disabled={disabled} + /> + + {parameters.processingMode === 'frontend' + ? t('watermark.processingMode.frontendDescription', 'Process the watermark directly in your browser without uploading files.') + : t('watermark.processingMode.backendDescription', 'Use the server to apply the watermark (recommended for large files or flattening).')} + + + {/* Size - single row */} + + + {t('adjustPageScale.processingMode.label', 'Processing mode')} + + onParameterChange('processingMode', value as AdjustPageScaleParameters['processingMode'])} + data={[ + { label: t('adjustPageScale.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('adjustPageScale.processingMode.frontend', 'Browser'), value: 'frontend' } + ]} + fullWidth + disabled={disabled} + /> + + {parameters.processingMode === 'frontend' + ? t('adjustPageScale.processingMode.frontendDescription', 'Resize pages locally using pdf-lib.') + : t('adjustPageScale.processingMode.backendDescription', 'Use the server for complex scaling workflows.')} + + + + + + {t('changeMetadata.processingMode.label', 'Processing mode')} + + onParameterChange('processingMode', value as ChangeMetadataParameters['processingMode'])} + data={[ + { label: t('changeMetadata.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('changeMetadata.processingMode.frontend', 'Browser'), value: 'frontend' } + ]} + fullWidth + disabled={disabled} + /> + + {parameters.processingMode === 'frontend' + ? t('changeMetadata.processingMode.frontendDescription', 'Update standard and custom metadata without uploading the document.') + : t('changeMetadata.processingMode.backendDescription', 'Use the server for metadata changes and legacy XMP cleanup.')} + + + {/* Delete All */} diff --git a/frontend/src/components/tools/crop/CropSettings.tsx b/frontend/src/components/tools/crop/CropSettings.tsx index 9643b39ae..b883ad312 100644 --- a/frontend/src/components/tools/crop/CropSettings.tsx +++ b/frontend/src/components/tools/crop/CropSettings.tsx @@ -1,5 +1,5 @@ import { useMemo, useState, useEffect } from "react"; -import { Stack, Text, Box, Group, ActionIcon, Center, Alert } from "@mantine/core"; +import { Stack, Text, Box, Group, ActionIcon, Center, Alert, SegmentedControl } from "@mantine/core"; import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; import { CropParametersHook } from "../../../hooks/tools/crop/useCropParameters"; @@ -151,6 +151,23 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { return ( + + {t('crop.processingMode.label', 'Processing mode')} + parameters.updateParameter('processingMode', value as 'backend' | 'frontend')} + data={[ + { label: t('crop.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('crop.processingMode.frontend', 'Browser'), value: 'frontend' }, + ]} + disabled={disabled} + /> + + {parameters.parameters.processingMode === 'frontend' + ? t('crop.processingMode.frontendDescription', 'Crop each page locally without uploading the PDF.') + : t('crop.processingMode.backendDescription', 'Use the server to crop pages (recommended for very large files).')} + + {/* PDF Preview with Crop Selector */} diff --git a/frontend/src/components/tools/flatten/FlattenSettings.tsx b/frontend/src/components/tools/flatten/FlattenSettings.tsx index 8386ad493..daec19247 100644 --- a/frontend/src/components/tools/flatten/FlattenSettings.tsx +++ b/frontend/src/components/tools/flatten/FlattenSettings.tsx @@ -1,4 +1,4 @@ -import { Stack, Text, Checkbox } from "@mantine/core"; +import { Stack, Text, Checkbox, SegmentedControl } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { FlattenParameters } from "../../../hooks/tools/flatten/useFlattenParameters"; @@ -13,6 +13,27 @@ const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: Fl return ( + + + {t('flatten.processingMode.label', 'Processing mode')} + + onParameterChange('processingMode', value as FlattenParameters['processingMode'])} + data={[ + { label: t('flatten.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('flatten.processingMode.frontend', 'Browser'), value: 'frontend' } + ]} + fullWidth + disabled={disabled} + /> + + {parameters.processingMode === 'frontend' + ? t('flatten.processingMode.frontendDescription', 'Flatten form fields directly in your browser (forms only).') + : t('flatten.processingMode.backendDescription', 'Use the server for full flattening, including rasterising pages.')} + + + + + + {t('pageLayout.processingMode.label', 'Processing mode')} + + onParameterChange('processingMode', value as PageLayoutParameters['processingMode'])} + data={[ + { label: t('pageLayout.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('pageLayout.processingMode.frontend', 'Browser'), value: 'frontend' } + ]} + fullWidth + disabled={disabled} + /> + + {parameters.processingMode === 'frontend' + ? t('pageLayout.processingMode.frontendDescription', 'Lay out pages directly in your browser for quick previews.') + : t('pageLayout.processingMode.backendDescription', 'Use the server for large documents or advanced layouts.')} + + + + + + {t("rotate.processingMode.label", "Processing mode")} + + parameters.updateParameter('processingMode', value as 'backend' | 'frontend')} + data={[ + { label: t('rotate.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('rotate.processingMode.frontend', 'Browser'), value: 'frontend' }, + ]} + fullWidth + disabled={disabled} + /> + + {parameters.parameters.processingMode === 'frontend' + ? t('rotate.processingMode.frontendDescription', 'Process the rotation directly in your browser without uploading files.') + : t('rotate.processingMode.backendDescription', 'Use the server to perform the rotation (recommended for large files).')} + + + {/* Thumbnail Preview Section */} diff --git a/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx b/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx index 11bf7009f..098b13692 100644 --- a/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx +++ b/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { SegmentedControl, Stack, Text } from '@mantine/core'; import { SingleLargePageParameters } from '../../../hooks/tools/singleLargePage/useSingleLargePageParameters'; interface SingleLargePageSettingsProps { @@ -8,15 +9,36 @@ interface SingleLargePageSettingsProps { disabled?: boolean; } -const SingleLargePageSettings: React.FC = (_) => { +const SingleLargePageSettings: React.FC = ({ parameters, onParameterChange, disabled = false }) => { const { t } = useTranslation(); return ( -
-

+ + + + {t('pdfToSinglePage.processingMode.label', 'Processing mode')} + + onParameterChange('processingMode', value as SingleLargePageParameters['processingMode'])} + data={[ + { label: t('pdfToSinglePage.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('pdfToSinglePage.processingMode.frontend', 'Browser'), value: 'frontend' } + ]} + fullWidth + disabled={disabled} + /> + + {parameters.processingMode === 'frontend' + ? t('pdfToSinglePage.processingMode.frontendDescription', 'Merge pages locally without uploading the document.') + : t('pdfToSinglePage.processingMode.backendDescription', 'Use the server for extremely large PDFs or scripted workflows.')} + + + + {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.')} -

-
+
+
); }; diff --git a/frontend/src/components/tools/split/SplitSettings.tsx b/frontend/src/components/tools/split/SplitSettings.tsx index ca20c24cc..1738aa863 100644 --- a/frontend/src/components/tools/split/SplitSettings.tsx +++ b/frontend/src/components/tools/split/SplitSettings.tsx @@ -1,4 +1,4 @@ -import { Stack, TextInput, Checkbox, Anchor, Text } from '@mantine/core'; +import { Stack, TextInput, Checkbox, Anchor, Text, SegmentedControl } from '@mantine/core'; import LocalIcon from '../../shared/LocalIcon'; import { useTranslation } from 'react-i18next'; import { SPLIT_METHODS } from '../../../constants/splitConstants'; @@ -148,6 +148,27 @@ const SplitSettings = ({ return ( + + + {t('split.processingMode.label', 'Processing mode')} + + onParameterChange('processingMode', value as SplitParameters['processingMode'])} + data={[ + { label: t('split.processingMode.backend', 'Backend'), value: 'backend' }, + { label: t('split.processingMode.frontend', 'Browser'), value: 'frontend' } + ]} + fullWidth + disabled={disabled} + /> + + {parameters.processingMode === 'frontend' + ? t('split.processingMode.frontendDescription', 'Split the selected ranges without uploading your PDF (page list only).') + : t('split.processingMode.backendDescription', 'Use the server for advanced split modes and very large files.')} + + + {/* Method-Specific Form */} {parameters.method === SPLIT_METHODS.BY_PAGES && renderByPagesForm()} {parameters.method === SPLIT_METHODS.BY_SECTIONS && renderBySectionsForm()} diff --git a/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts b/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts index fe254aac5..deb2d1778 100644 --- a/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts +++ b/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts @@ -47,10 +47,13 @@ describe('useAddPasswordOperation', () => { clearError: vi.fn(), cancelOperation: vi.fn(), undoOperation: vi.fn(), + supportsFrontendProcessing: false, + evaluateShouldUseFrontend: vi.fn(() => false), }; beforeEach(() => { vi.clearAllMocks(); + mockToolOperationReturn.evaluateShouldUseFrontend.mockReturnValue(false); mockUseToolOperation.mockReturnValue(mockToolOperationReturn); }); diff --git a/frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts b/frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts index d718a1762..1318f4e0b 100644 --- a/frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts +++ b/frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { AddWatermarkParameters, defaultParameters } from './useAddWatermarkParameters'; +import { addWatermarkClientSide } from '../../../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 = () => { diff --git a/frontend/src/hooks/tools/addWatermark/useAddWatermarkParameters.ts b/frontend/src/hooks/tools/addWatermark/useAddWatermarkParameters.ts index 5b9a25da6..097e53193 100644 --- a/frontend/src/hooks/tools/addWatermark/useAddWatermarkParameters.ts +++ b/frontend/src/hooks/tools/addWatermark/useAddWatermarkParameters.ts @@ -1,7 +1,7 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../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; @@ -33,7 +34,7 @@ export type AddWatermarkParametersHook = BaseParametersHook { return useBaseParameters({ defaultParameters: defaultParameters, - endpointName: 'add-watermark', + endpointName: (params) => params.processingMode === 'frontend' ? '' : 'add-watermark', validateFn: (params): boolean => { if (!params.watermarkType) { return false; diff --git a/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts b/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts index 458e7fda1..9cdd397d2 100644 --- a/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts +++ b/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts @@ -1,7 +1,8 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation, ToolType } from '../shared/useToolOperation'; +import { useToolOperation, ToolType, ToolOperationConfig } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { AdjustPageScaleParameters, defaultParameters } from './useAdjustPageScaleParameters'; +import { adjustPageScaleClientSide } from '../../../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; export const useAdjustPageScaleOperation = () => { const { t } = useTranslation(); diff --git a/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleParameters.ts b/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleParameters.ts index 108d7d3ea..299b8ea6d 100644 --- a/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleParameters.ts +++ b/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleParameters.ts @@ -1,4 +1,4 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../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; @@ -29,7 +30,7 @@ export type AdjustPageScaleParametersHook = BaseParametersHook { return useBaseParameters({ defaultParameters, - endpointName: 'scale-pages', + endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'scale-pages'), validateFn: (params) => { return params.scaleFactor > 0; }, diff --git a/frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.test.ts b/frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.test.ts index 8836189ac..51cab545b 100644 --- a/frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.test.ts +++ b/frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.test.ts @@ -18,6 +18,7 @@ describe('buildChangeMetadataFormData', () => { trapped: TrappedStatus.UNKNOWN, customMetadata: [], deleteAll: false, + processingMode: 'backend', }; test.each([ diff --git a/frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.ts b/frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.ts index 1f1c8695b..0471db1a4 100644 --- a/frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.ts +++ b/frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.ts @@ -1,7 +1,8 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation, ToolType } from '../shared/useToolOperation'; +import { useToolOperation, ToolType, ToolOperationConfig } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { ChangeMetadataParameters, defaultParameters } from './useChangeMetadataParameters'; +import { changeMetadataClientSide } from '../../../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; export const useChangeMetadataOperation = () => { const { t } = useTranslation(); diff --git a/frontend/src/hooks/tools/changeMetadata/useChangeMetadataParameters.ts b/frontend/src/hooks/tools/changeMetadata/useChangeMetadataParameters.ts index 0523166de..3f81b8006 100644 --- a/frontend/src/hooks/tools/changeMetadata/useChangeMetadataParameters.ts +++ b/frontend/src/hooks/tools/changeMetadata/useChangeMetadataParameters.ts @@ -1,8 +1,8 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { TrappedStatus, CustomMetadataEntry } from '../../../types/metadata'; import { useBaseParameters, BaseParametersHook } from '../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 { const base = useBaseParameters({ defaultParameters, - endpointName: 'update-metadata', + endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'update-metadata'), validateFn: validateParameters, }); diff --git a/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts b/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts index b85f5533f..54d62ad53 100644 --- a/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts +++ b/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts @@ -46,10 +46,13 @@ describe('useChangePermissionsOperation', () => { clearError: vi.fn(), cancelOperation: vi.fn(), undoOperation: vi.fn(), + supportsFrontendProcessing: false, + evaluateShouldUseFrontend: vi.fn(() => false), }; beforeEach(() => { vi.clearAllMocks(); + mockToolOperationReturn.evaluateShouldUseFrontend.mockReturnValue(false); mockUseToolOperation.mockReturnValue(mockToolOperationReturn); }); diff --git a/frontend/src/hooks/tools/crop/useCropOperation.ts b/frontend/src/hooks/tools/crop/useCropOperation.ts index 452b3ddf1..801f09488 100644 --- a/frontend/src/hooks/tools/crop/useCropOperation.ts +++ b/frontend/src/hooks/tools/crop/useCropOperation.ts @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useToolOperation, ToolType } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { CropParameters, defaultParameters } from './useCropParameters'; +import { cropPdfClientSide } from '../../../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 = () => { diff --git a/frontend/src/hooks/tools/crop/useCropParameters.ts b/frontend/src/hooks/tools/crop/useCropParameters.ts index d16cb5af5..4008e1880 100644 --- a/frontend/src/hooks/tools/crop/useCropParameters.ts +++ b/frontend/src/hooks/tools/crop/useCropParameters.ts @@ -1,15 +1,16 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; import { useCallback } from 'react'; import { Rectangle, PDFBounds, constrainCropAreaToPDF, createFullPDFCropArea, roundCropArea, isRectangle } from '../../../utils/cropCoordinates'; import { DEFAULT_CROP_AREA } from '../../../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 & { @@ -30,7 +31,7 @@ export type CropParametersHook = BaseParametersHook & { 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 diff --git a/frontend/src/hooks/tools/flatten/useFlattenOperation.ts b/frontend/src/hooks/tools/flatten/useFlattenOperation.ts index e2b687434..123157f84 100644 --- a/frontend/src/hooks/tools/flatten/useFlattenOperation.ts +++ b/frontend/src/hooks/tools/flatten/useFlattenOperation.ts @@ -1,7 +1,8 @@ import { useTranslation } from 'react-i18next'; -import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { FlattenParameters, defaultParameters } from './useFlattenParameters'; +import { flattenPdfClientSide } from '../../../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; export const useFlattenOperation = () => { const { t } = useTranslation(); diff --git a/frontend/src/hooks/tools/flatten/useFlattenParameters.ts b/frontend/src/hooks/tools/flatten/useFlattenParameters.ts index 98c5b9655..24c448e45 100644 --- a/frontend/src/hooks/tools/flatten/useFlattenParameters.ts +++ b/frontend/src/hooks/tools/flatten/useFlattenParameters.ts @@ -1,12 +1,13 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../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; @@ -14,6 +15,6 @@ export type FlattenParametersHook = BaseParametersHook; export const useFlattenParameters = (): FlattenParametersHook => { return useBaseParameters({ defaultParameters, - endpointName: 'flatten', + endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'flatten'), }); }; \ No newline at end of file diff --git a/frontend/src/hooks/tools/merge/useMergeOperation.test.ts b/frontend/src/hooks/tools/merge/useMergeOperation.test.ts index 4d41c5162..9ca60a1a7 100644 --- a/frontend/src/hooks/tools/merge/useMergeOperation.test.ts +++ b/frontend/src/hooks/tools/merge/useMergeOperation.test.ts @@ -47,11 +47,14 @@ describe('useMergeOperation', () => { cancelOperation: vi.fn(), undoOperation: function (): Promise { throw new Error('Function not implemented.'); - } + }, + supportsFrontendProcessing: false, + evaluateShouldUseFrontend: vi.fn(() => false), }; beforeEach(() => { vi.clearAllMocks(); + mockToolOperationReturn.evaluateShouldUseFrontend.mockReturnValue(false); mockUseToolOperation.mockReturnValue(mockToolOperationReturn); }); diff --git a/frontend/src/hooks/tools/pageLayout/usePageLayoutOperation.ts b/frontend/src/hooks/tools/pageLayout/usePageLayoutOperation.ts index b96aeaaf8..bfc57b783 100644 --- a/frontend/src/hooks/tools/pageLayout/usePageLayoutOperation.ts +++ b/frontend/src/hooks/tools/pageLayout/usePageLayoutOperation.ts @@ -1,7 +1,8 @@ import { useTranslation } from 'react-i18next'; -import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { PageLayoutParameters, defaultParameters } from './usePageLayoutParameters'; +import { pageLayoutClientSide } from '../../../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; export const usePageLayoutOperation = () => { const { t } = useTranslation(); diff --git a/frontend/src/hooks/tools/pageLayout/usePageLayoutParameters.ts b/frontend/src/hooks/tools/pageLayout/usePageLayoutParameters.ts index 13cbc860c..ba6bd7b67 100644 --- a/frontend/src/hooks/tools/pageLayout/usePageLayoutParameters.ts +++ b/frontend/src/hooks/tools/pageLayout/usePageLayoutParameters.ts @@ -1,7 +1,7 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../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; @@ -16,7 +17,7 @@ export type PageLayoutParametersHook = BaseParametersHook; export const usePageLayoutParameters = (): PageLayoutParametersHook => { return useBaseParameters({ defaultParameters, - endpointName: 'multi-page-layout', + endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'multi-page-layout'), }); }; diff --git a/frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts b/frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts index aa9f5d574..1f9754df7 100644 --- a/frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts +++ b/frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts @@ -3,6 +3,7 @@ import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useTo import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { RemovePagesParameters, defaultParameters } from './useRemovePagesParameters'; // import { useToolResources } from '../shared/useToolResources'; +import { removePagesClientSide } from '../../../utils/pdfOperations/removePages'; export const buildRemovePagesFormData = (parameters: RemovePagesParameters, file: File): FormData => { const formData = new 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; export const useRemovePagesOperation = () => { diff --git a/frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts b/frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts index 31484f54e..0f2b5f9c9 100644 --- a/frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts +++ b/frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts @@ -1,13 +1,14 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; import { validatePageNumbers } from '../../../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; @@ -15,7 +16,7 @@ export type RemovePagesParametersHook = BaseParametersHook { return useBaseParameters({ defaultParameters, - endpointName: 'remove-pages', + endpointName: (params) => params.processingMode === 'frontend' ? '' : 'remove-pages', validateFn: (p) => validatePageNumbers(p.pageNumbers), }); }; diff --git a/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts b/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts index d3dc93f7b..cbaf9efa4 100644 --- a/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts +++ b/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts @@ -45,10 +45,13 @@ describe('useRemovePasswordOperation', () => { clearError: vi.fn(), cancelOperation: vi.fn(), undoOperation: vi.fn(), + supportsFrontendProcessing: false, + evaluateShouldUseFrontend: vi.fn(() => false), }; beforeEach(() => { vi.clearAllMocks(); + mockToolOperationReturn.evaluateShouldUseFrontend.mockReturnValue(false); mockUseToolOperation.mockReturnValue(mockToolOperationReturn); }); diff --git a/frontend/src/hooks/tools/reorganizePages/useReorganizePagesOperation.ts b/frontend/src/hooks/tools/reorganizePages/useReorganizePagesOperation.ts index ca5af1327..bcc73f86e 100644 --- a/frontend/src/hooks/tools/reorganizePages/useReorganizePagesOperation.ts +++ b/frontend/src/hooks/tools/reorganizePages/useReorganizePagesOperation.ts @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { ToolOperationConfig, ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { ReorganizePagesParameters } from './useReorganizePagesParameters'; +import { reorganizePagesClientSide } from '../../../utils/pdfOperations/reorganizePages'; const buildFormData = (parameters: ReorganizePagesParameters, file: File): FormData => { const formData = new FormData(); @@ -21,6 +22,18 @@ export const reorganizePagesOperationConfig: ToolOperationConfig { + 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 = () => { diff --git a/frontend/src/hooks/tools/reorganizePages/useReorganizePagesParameters.ts b/frontend/src/hooks/tools/reorganizePages/useReorganizePagesParameters.ts index 799ed26df..a3e4f776c 100644 --- a/frontend/src/hooks/tools/reorganizePages/useReorganizePagesParameters.ts +++ b/frontend/src/hooks/tools/reorganizePages/useReorganizePagesParameters.ts @@ -1,6 +1,8 @@ import { useState } from 'react'; -export interface ReorganizePagesParameters { +import { ToggleableProcessingParameters } from '../../../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 = () => { diff --git a/frontend/src/hooks/tools/rotate/useRotateOperation.test.ts b/frontend/src/hooks/tools/rotate/useRotateOperation.test.ts index 53370ca5a..8bcfcfbb2 100644 --- a/frontend/src/hooks/tools/rotate/useRotateOperation.test.ts +++ b/frontend/src/hooks/tools/rotate/useRotateOperation.test.ts @@ -46,10 +46,13 @@ describe('useRotateOperation', () => { clearError: vi.fn(), cancelOperation: vi.fn(), undoOperation: vi.fn(), + supportsFrontendProcessing: false, + evaluateShouldUseFrontend: vi.fn(() => false), }; beforeEach(() => { vi.clearAllMocks(); + mockToolOperationReturn.evaluateShouldUseFrontend.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'); + }); }); diff --git a/frontend/src/hooks/tools/rotate/useRotateOperation.ts b/frontend/src/hooks/tools/rotate/useRotateOperation.ts index 3399d8b21..4626da18d 100644 --- a/frontend/src/hooks/tools/rotate/useRotateOperation.ts +++ b/frontend/src/hooks/tools/rotate/useRotateOperation.ts @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useToolOperation, ToolType } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { RotateParameters, defaultParameters, normalizeAngle } from './useRotateParameters'; +import { rotatePdfClientSide } from '../../../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 = () => { diff --git a/frontend/src/hooks/tools/rotate/useRotateParameters.test.ts b/frontend/src/hooks/tools/rotate/useRotateParameters.test.ts index 6d3393fcc..715d0e14c 100644 --- a/frontend/src/hooks/tools/rotate/useRotateParameters.test.ts +++ b/frontend/src/hooks/tools/rotate/useRotateParameters.test.ts @@ -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', () => { diff --git a/frontend/src/hooks/tools/rotate/useRotateParameters.ts b/frontend/src/hooks/tools/rotate/useRotateParameters.ts index dff87bb8a..171d2eadf 100644 --- a/frontend/src/hooks/tools/rotate/useRotateParameters.ts +++ b/frontend/src/hooks/tools/rotate/useRotateParameters.ts @@ -1,4 +1,4 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../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 & { @@ -25,7 +26,7 @@ export type RotateParametersHook = BaseParametersHook & { 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; diff --git a/frontend/src/hooks/tools/shared/useBaseTool.ts b/frontend/src/hooks/tools/shared/useBaseTool.ts index 56174a73a..fb1a36d00 100644 --- a/frontend/src/hooks/tools/shared/useBaseTool.ts +++ b/frontend/src/hooks/tools/shared/useBaseTool.ts @@ -52,7 +52,13 @@ export function useBaseTool { diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index f5e575a14..2f388ef13 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -12,6 +12,7 @@ import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions'; import { ToolOperation } from '../../../types/file'; import { ToolId } from '../../../types/toolId'; +import { ProcessingMode } from '../../../types/parameters'; // Re-export for backwards compatibility export type { ProcessingProgress, ResponseHandler }; @@ -55,6 +56,14 @@ interface BaseToolOperationConfig { /** Default parameter values for automation */ defaultParameters?: TParams; + + frontendProcessing?: FrontendProcessingConfig; +} + +export interface FrontendProcessingConfig { + process: (params: TParams, files: File[]) => Promise; + shouldUseFrontend?: (params: TParams) => boolean; + statusMessage?: string; } export interface SingleFileToolOperationConfig extends BaseToolOperationConfig { @@ -124,6 +133,9 @@ export interface ToolOperationHook { clearError: () => void; cancelOperation: () => void; undoOperation: () => Promise; + + supportsFrontendProcessing: boolean; + evaluateShouldUseFrontend: (params: TParams) => boolean; } // Re-export for backwards compatibility @@ -145,7 +157,7 @@ export const useToolOperation = ( config: ToolOperationConfig ): ToolOperationHook => { 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 = ( 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 & { processingMode?: ProcessingMode }).processingMode; + return mode === 'frontend'; + }, [config]); + const executeOperation = useCallback(async ( params: TParams, selectedFiles: StirlingFile[] @@ -208,85 +235,93 @@ export const useToolOperation = ( 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 = { - 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 = { + 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(); - 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(); + 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 = ( 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 = ( resetResults, clearError: actions.clearError, cancelOperation, - undoOperation + undoOperation, + + supportsFrontendProcessing, + evaluateShouldUseFrontend }; }; diff --git a/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts index ab51c0cc4..aa7b8c79a 100644 --- a/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts +++ b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts @@ -1,7 +1,8 @@ import { useTranslation } from 'react-i18next'; -import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters'; +import { singleLargePageClientSide } from '../../../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; export const useSingleLargePageOperation = () => { const { t } = useTranslation(); diff --git a/frontend/src/hooks/tools/singleLargePage/useSingleLargePageParameters.ts b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageParameters.ts index df401b1a4..ac0393a78 100644 --- a/frontend/src/hooks/tools/singleLargePage/useSingleLargePageParameters.ts +++ b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageParameters.ts @@ -1,12 +1,13 @@ -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../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; @@ -14,6 +15,6 @@ export type SingleLargePageParametersHook = BaseParametersHook { return useBaseParameters({ defaultParameters, - endpointName: 'pdf-to-single-page', + endpointName: (params) => (params.processingMode === 'frontend' ? '' : 'pdf-to-single-page'), }); }; \ No newline at end of file diff --git a/frontend/src/hooks/tools/split/useSplitOperation.ts b/frontend/src/hooks/tools/split/useSplitOperation.ts index 955149868..6baa9665c 100644 --- a/frontend/src/hooks/tools/split/useSplitOperation.ts +++ b/frontend/src/hooks/tools/split/useSplitOperation.ts @@ -5,6 +5,8 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { SplitParameters, defaultParameters } from './useSplitParameters'; import { SPLIT_METHODS } from '../../../constants/splitConstants'; import { useToolResources } from '../shared/useToolResources'; +import { splitPdfClientSide } from '../../../utils/pdfOperations/split'; +import { validatePageNumbers } from '../../../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,19 @@ export const splitOperationConfig = { operationType: 'split', endpoint: getSplitEndpoint, defaultParameters, -} as const; + frontendProcessing: { + process: splitPdfClientSide, + shouldUseFrontend: (params: SplitParameters) => { + if (params.processingMode !== 'frontend') return false; + if (params.method !== SPLIT_METHODS.BY_PAGES) return false; + const token = params.pages?.trim(); + if (!token) return true; + if (token.toLowerCase().includes('n')) return false; + return validatePageNumbers(token); + }, + statusMessage: 'Splitting PDF in browser...' + } +} as const satisfies ToolOperationConfig; export const useSplitOperation = () => { const { t } = useTranslation(); diff --git a/frontend/src/hooks/tools/split/useSplitParameters.ts b/frontend/src/hooks/tools/split/useSplitParameters.ts index 8d77604e5..08b389e57 100644 --- a/frontend/src/hooks/tools/split/useSplitParameters.ts +++ b/frontend/src/hooks/tools/split/useSplitParameters.ts @@ -1,8 +1,8 @@ import { SPLIT_METHODS, ENDPOINTS, type SplitMethod } from '../../../constants/splitConstants'; -import { BaseParameters } from '../../../types/parameters'; +import { BaseParameters, ToggleableProcessingParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../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]; }, diff --git a/frontend/src/tools/AddPageNumbers.tsx b/frontend/src/tools/AddPageNumbers.tsx index 57566750b..a084c2ad7 100644 --- a/frontend/src/tools/AddPageNumbers.tsx +++ b/frontend/src/tools/AddPageNumbers.tsx @@ -1,42 +1,25 @@ -import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { useFileSelection } from "../contexts/FileContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "../types/tool"; -import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useAddPageNumbersParameters } from "../components/tools/addPageNumbers/useAddPageNumbersParameters"; import { useAddPageNumbersOperation } from "../components/tools/addPageNumbers/useAddPageNumbersOperation"; import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps"; import AddPageNumbersPositionSettings from "../components/tools/addPageNumbers/AddPageNumbersPositionSettings"; import AddPageNumbersAppearanceSettings from "../components/tools/addPageNumbers/AddPageNumbersAppearanceSettings"; +import { useBaseTool } from "../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: ( ), @@ -85,9 +65,9 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = isVisible: hasFiles || hasResults, content: ( ), }); @@ -97,7 +77,7 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = return createToolFlow({ files: { - selectedFiles, + selectedFiles: base.selectedFiles, isCollapsed: hasResults, }, steps: getSteps(), @@ -105,18 +85,15 @@ 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, }, }); }; diff --git a/frontend/src/tools/ReorganizePages.tsx b/frontend/src/tools/ReorganizePages.tsx index e2a68e883..0eb356f58 100644 --- a/frontend/src/tools/ReorganizePages.tsx +++ b/frontend/src/tools/ReorganizePages.tsx @@ -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(); diff --git a/frontend/src/tools/SingleLargePage.tsx b/frontend/src/tools/SingleLargePage.tsx index d31836feb..a1008d8f6 100644 --- a/frontend/src/tools/SingleLargePage.tsx +++ b/frontend/src/tools/SingleLargePage.tsx @@ -4,6 +4,7 @@ import { useSingleLargePageParameters } from "../hooks/tools/singleLargePage/use import { useSingleLargePageOperation } from "../hooks/tools/singleLargePage/useSingleLargePageOperation"; import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; import { BaseToolProps, ToolComponent } from "../types/tool"; +import SingleLargePageSettings from "../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: ( + + ), + } + ], executeButton: { text: t("pdfToSinglePage.submit", "Convert To Single Page"), isVisible: !base.hasResults, diff --git a/frontend/src/types/parameters.ts b/frontend/src/types/parameters.ts index 91355cb81..ab29b0fb2 100644 --- a/frontend/src/types/parameters.ts +++ b/frontend/src/types/parameters.ts @@ -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; + +export type ProcessingMode = 'backend' | 'frontend'; + +export interface ToggleableProcessingParameters { + processingMode: ProcessingMode; +} diff --git a/frontend/src/utils/pageSelection.ts b/frontend/src/utils/pageSelection.ts index 4909581bb..d4c754224 100644 --- a/frontend/src/utils/pageSelection.ts +++ b/frontend/src/utils/pageSelection.ts @@ -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(); + + 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; +}; diff --git a/frontend/src/utils/pdfOperations/addPageNumbers.test.ts b/frontend/src/utils/pdfOperations/addPageNumbers.test.ts new file mode 100644 index 000000000..8991cbb07 --- /dev/null +++ b/frontend/src/utils/pdfOperations/addPageNumbers.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { addPageNumbersClientSide } from './addPageNumbers'; +import type { AddPageNumbersParameters } from '../../components/tools/addPageNumbers/useAddPageNumbersParameters'; + +async function createPdf(): Promise { + const doc = await PDFDocument.create(); + doc.addPage([300, 400]); + doc.addPage([300, 400]); + const bytes = await doc.save(); + return new File([bytes], 'numbers.pdf', { type: 'application/pdf' }); +} + +describe('addPageNumbersClientSide', () => { + it('adds page numbers to selected pages', async () => { + const file = await createPdf(); + const params = { + customMargin: 'medium', + position: 8, + fontSize: 12, + fontType: 'Times', + startingNumber: 1, + pagesToNumber: '1', + customText: '{n} of {total}', + processingMode: 'frontend' + } as AddPageNumbersParameters; + + const [result] = await addPageNumbersClientSide(params, [file]); + const doc = await PDFDocument.load(await result.arrayBuffer()); + expect(doc.getPageCount()).toBe(2); + // ensure document saved successfully and filename includes suffix + expect(result.name).toContain('numbersAdded'); + }); +}); diff --git a/frontend/src/utils/pdfOperations/addPageNumbers.ts b/frontend/src/utils/pdfOperations/addPageNumbers.ts new file mode 100644 index 000000000..925c9f34b --- /dev/null +++ b/frontend/src/utils/pdfOperations/addPageNumbers.ts @@ -0,0 +1,113 @@ +import { PDFDocument, StandardFonts } from 'pdf-lib'; +import type { AddPageNumbersParameters } from '../../components/tools/addPageNumbers/useAddPageNumbersParameters'; +import { resolvePageNumbers } from '../pageSelection'; +import { createFileFromApiResponse } from '../fileResponseUtils'; + +const PDF_MIME_TYPE = 'application/pdf'; + +const FONT_MAP: Record = { + Times: StandardFonts.TimesRoman, + Helvetica: StandardFonts.Helvetica, + Courier: StandardFonts.Courier, +}; + +const MARGIN_MAP: Record = { + 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 { + 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`); + }) + ); +} diff --git a/frontend/src/utils/pdfOperations/addStamp.test.ts b/frontend/src/utils/pdfOperations/addStamp.test.ts new file mode 100644 index 000000000..65096a477 --- /dev/null +++ b/frontend/src/utils/pdfOperations/addStamp.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { addStampClientSide } from './addStamp'; +import type { AddStampParameters } from '../../components/tools/addStamp/useAddStampParameters'; + +async function createPdf(): Promise { + const pdf = await PDFDocument.create(); + const page = pdf.addPage([300, 300]); + page.drawText('Page 1', { x: 20, y: 260, size: 18 }); + const bytes = await pdf.save(); + return new File([bytes], 'stamp.pdf', { type: 'application/pdf' }); +} + +describe('addStampClientSide', () => { + it('stamps the requested page without removing pages', async () => { + const input = await createPdf(); + const params: AddStampParameters = { + stampType: 'text', + stampText: 'Approved', + alphabet: 'roman', + fontSize: 36, + rotation: 0, + opacity: 50, + position: 5, + overrideX: -1, + overrideY: -1, + customMargin: 'medium', + customColor: '#ff0000', + pageNumbers: '1', + _activePill: 'fontSize', + processingMode: 'frontend', + }; + + const [output] = await addStampClientSide(params, [input]); + const doc = await PDFDocument.load(await output.arrayBuffer()); + + expect(doc.getPageCount()).toBe(1); + expect(output.name).toBe('stamp.pdf'); + }); +}); diff --git a/frontend/src/utils/pdfOperations/addStamp.ts b/frontend/src/utils/pdfOperations/addStamp.ts new file mode 100644 index 000000000..629c21d08 --- /dev/null +++ b/frontend/src/utils/pdfOperations/addStamp.ts @@ -0,0 +1,196 @@ +import { degrees, PDFDocument, PDFPage, rgb } from 'pdf-lib'; +import type { AddStampParameters } from '../../components/tools/addStamp/useAddStampParameters'; +import { createFileFromApiResponse } from '../fileResponseUtils'; +import { resolvePageNumbers } from '../pageSelection'; +import { loadFontForAlphabet } from './fontCache'; + +const PDF_MIME_TYPE = 'application/pdf'; + +const DEFAULT_STAMP_COLOR = rgb(0.83, 0.83, 0.83); + +const MARGIN_FACTORS: Record = { + 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 { + 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); + })); +} diff --git a/frontend/src/utils/pdfOperations/addWatermark.test.ts b/frontend/src/utils/pdfOperations/addWatermark.test.ts new file mode 100644 index 000000000..6cb267c3e --- /dev/null +++ b/frontend/src/utils/pdfOperations/addWatermark.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { addWatermarkClientSide } from './addWatermark'; +import type { AddWatermarkParameters } from '../../hooks/tools/addWatermark/useAddWatermarkParameters'; + +async function createSamplePdf(): Promise { + const pdf = await PDFDocument.create(); + const page = pdf.addPage([400, 400]); + page.drawText('Sample content', { x: 50, y: 350, size: 18 }); + const bytes = await pdf.save(); + return new File([bytes], 'sample.pdf', { type: 'application/pdf' }); +} + +describe('addWatermarkClientSide', () => { + it('returns a processed PDF with the same number of pages', async () => { + const input = await createSamplePdf(); + const params: AddWatermarkParameters = { + watermarkType: 'text', + watermarkText: 'Watermark', + fontSize: 24, + rotation: 0, + opacity: 50, + widthSpacer: 40, + heightSpacer: 40, + alphabet: 'roman', + customColor: '#000000', + convertPDFToImage: false, + processingMode: 'frontend', + }; + + const [output] = await addWatermarkClientSide(params, [input]); + const resultDoc = await PDFDocument.load(await output.arrayBuffer()); + + expect(resultDoc.getPageCount()).toBe(1); + expect(output.type).toBe('application/pdf'); + }); +}); diff --git a/frontend/src/utils/pdfOperations/addWatermark.ts b/frontend/src/utils/pdfOperations/addWatermark.ts new file mode 100644 index 000000000..10503cf7e --- /dev/null +++ b/frontend/src/utils/pdfOperations/addWatermark.ts @@ -0,0 +1,161 @@ +import { degrees, PDFDocument, rgb } from 'pdf-lib'; +import type { AddWatermarkParameters } from '../../hooks/tools/addWatermark/useAddWatermarkParameters'; +import { createFileFromApiResponse } from '../fileResponseUtils'; +import { loadFontForAlphabet } from './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 { + 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); + })); +} diff --git a/frontend/src/utils/pdfOperations/adjustPageScale.test.ts b/frontend/src/utils/pdfOperations/adjustPageScale.test.ts new file mode 100644 index 000000000..f8cd33cb0 --- /dev/null +++ b/frontend/src/utils/pdfOperations/adjustPageScale.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { adjustPageScaleClientSide } from './adjustPageScale'; +import type { AdjustPageScaleParameters } from '../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters'; +import { PageSize } from '../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters'; + +async function createPdf(): Promise { + const doc = await PDFDocument.create(); + doc.addPage([300, 400]); + const bytes = await doc.save(); + return new File([bytes], 'scale.pdf', { type: 'application/pdf' }); +} + +describe('adjustPageScaleClientSide', () => { + it('scales a page to the requested size', async () => { + const file = await createPdf(); + const params = { + scaleFactor: 1, + pageSize: PageSize.A4, + processingMode: 'frontend' + } as AdjustPageScaleParameters; + + const [result] = await adjustPageScaleClientSide(params, [file]); + const doc = await PDFDocument.load(await result.arrayBuffer()); + const page = doc.getPage(0); + expect(page.getWidth()).toBeCloseTo(595.28, 2); + expect(page.getHeight()).toBeCloseTo(841.89, 2); + }); +}); diff --git a/frontend/src/utils/pdfOperations/adjustPageScale.ts b/frontend/src/utils/pdfOperations/adjustPageScale.ts new file mode 100644 index 000000000..8530bb5a8 --- /dev/null +++ b/frontend/src/utils/pdfOperations/adjustPageScale.ts @@ -0,0 +1,70 @@ +import { PDFDocument } from 'pdf-lib'; +import type { AdjustPageScaleParameters, PageSize } from '../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters'; +import { createFileFromApiResponse } from '../fileResponseUtils'; + +const PDF_MIME_TYPE = 'application/pdf'; + +const PAGE_DIMENSIONS: Record = { + 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 { + 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`); + }) + ); +} diff --git a/frontend/src/utils/pdfOperations/changeMetadata.test.ts b/frontend/src/utils/pdfOperations/changeMetadata.test.ts new file mode 100644 index 000000000..a938f9fc4 --- /dev/null +++ b/frontend/src/utils/pdfOperations/changeMetadata.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument, PDFName, PDFDict } from 'pdf-lib'; +import { changeMetadataClientSide } from './changeMetadata'; +import type { ChangeMetadataParameters } from '../../hooks/tools/changeMetadata/useChangeMetadataParameters'; + +async function createEmptyPdf(): Promise { + const pdf = await PDFDocument.create(); + pdf.addPage([200, 200]); + const bytes = await pdf.save(); + return new File([bytes], 'sample.pdf', { type: 'application/pdf' }); +} + +describe('changeMetadataClientSide', () => { + it('applies standard metadata fields', async () => { + const file = await createEmptyPdf(); + const params = { + title: 'Browser Title', + author: 'Frontend Author', + subject: 'Metadata Test', + keywords: 'stirling,pdf', + creator: 'Browser', + producer: 'Browser', + creationDate: new Date(Date.UTC(2024, 0, 1, 12, 0, 0)), + modificationDate: new Date(Date.UTC(2024, 0, 2, 12, 0, 0)), + trapped: 'True', + customMetadata: [{ id: '1', key: 'CustomKey', value: 'CustomValue' }], + deleteAll: false, + processingMode: 'frontend' + } as ChangeMetadataParameters; + + const [result] = await changeMetadataClientSide(params, [file]); + const doc = await PDFDocument.load(await result.arrayBuffer()); + const info = (doc.context.lookup(doc.context.trailer.get(PDFName.of('Info')), PDFDict) as PDFDict); + + expect(info.get(PDFName.of('Title'))?.value).toContain('Browser Title'); + expect(info.get(PDFName.of('Author'))?.value).toContain('Frontend Author'); + expect(info.get(PDFName.of('CustomKey'))?.value).toContain('CustomValue'); + }); +}); diff --git a/frontend/src/utils/pdfOperations/changeMetadata.ts b/frontend/src/utils/pdfOperations/changeMetadata.ts new file mode 100644 index 000000000..25d45d7a5 --- /dev/null +++ b/frontend/src/utils/pdfOperations/changeMetadata.ts @@ -0,0 +1,122 @@ +import { PDFDocument, PDFName, PDFString, PDFDict } from 'pdf-lib'; +import type { ChangeMetadataParameters } from '../../hooks/tools/changeMetadata/useChangeMetadataParameters'; +import { createFileFromApiResponse } from '../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.trailer.get(PDFName.of('Info')); + let info = infoRef ? pdfDoc.context.lookup(infoRef, PDFDict) : undefined; + if (!info) { + info = pdfDoc.context.obj({}); + pdfDoc.context.trailer.set(PDFName.of('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 { + 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.trailer.set(PDFName.of('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' ? 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`); + }) + ); +} diff --git a/frontend/src/utils/pdfOperations/crop.test.ts b/frontend/src/utils/pdfOperations/crop.test.ts new file mode 100644 index 000000000..6f47700c0 --- /dev/null +++ b/frontend/src/utils/pdfOperations/crop.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { cropPdfClientSide } from './crop'; +import type { CropParameters } from '../../hooks/tools/crop/useCropParameters'; + +async function createPdf(width: number, height: number): Promise { + const pdf = await PDFDocument.create(); + pdf.addPage([width, height]); + const bytes = await pdf.save(); + return new File([bytes], 'crop.pdf', { type: 'application/pdf' }); +} + +describe('cropPdfClientSide', () => { + it('crops the page to the requested dimensions', async () => { + const input = await createPdf(400, 400); + const params: CropParameters = { + cropArea: { x: 50, y: 60, width: 200, height: 150 }, + processingMode: 'frontend', + }; + + const [output] = await cropPdfClientSide(params, [input]); + const doc = await PDFDocument.load(await output.arrayBuffer()); + const [page] = doc.getPages(); + + expect(page.getWidth()).toBeCloseTo(200, 3); + expect(page.getHeight()).toBeCloseTo(150, 3); + }); +}); diff --git a/frontend/src/utils/pdfOperations/crop.ts b/frontend/src/utils/pdfOperations/crop.ts new file mode 100644 index 000000000..df5b3d9e6 --- /dev/null +++ b/frontend/src/utils/pdfOperations/crop.ts @@ -0,0 +1,44 @@ +import { PDFDocument } from 'pdf-lib'; +import type { CropParameters } from '../../hooks/tools/crop/useCropParameters'; +import { createFileFromApiResponse } from '../fileResponseUtils'; + +const PDF_MIME_TYPE = 'application/pdf'; + +export async function cropPdfClientSide( + params: CropParameters, + files: File[] +): Promise { + 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); + })); +} diff --git a/frontend/src/utils/pdfOperations/flatten.test.ts b/frontend/src/utils/pdfOperations/flatten.test.ts new file mode 100644 index 000000000..30ddd4db9 --- /dev/null +++ b/frontend/src/utils/pdfOperations/flatten.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { flattenPdfClientSide } from './flatten'; +import type { FlattenParameters } from '../../hooks/tools/flatten/useFlattenParameters'; + +async function createFormPdf(): Promise { + const pdfDoc = await PDFDocument.create(); + const page = pdfDoc.addPage([200, 200]); + const form = pdfDoc.getForm(); + const textField = form.createTextField('name'); + textField.setText('Stirling'); + textField.addToPage(page, { x: 20, y: 120, width: 160, height: 24 }); + const bytes = await pdfDoc.save(); + return new File([bytes], 'form.pdf', { type: 'application/pdf' }); +} + +describe('flattenPdfClientSide', () => { + it('flattens interactive form fields', async () => { + const file = await createFormPdf(); + const params = { flattenOnlyForms: true, processingMode: 'frontend' } as FlattenParameters; + + const [flattened] = await flattenPdfClientSide(params, [file]); + const doc = await PDFDocument.load(await flattened.arrayBuffer()); + const form = doc.getForm(); + + expect(form.getFields().length).toBe(0); + }); +}); diff --git a/frontend/src/utils/pdfOperations/flatten.ts b/frontend/src/utils/pdfOperations/flatten.ts new file mode 100644 index 000000000..9ff7c3181 --- /dev/null +++ b/frontend/src/utils/pdfOperations/flatten.ts @@ -0,0 +1,30 @@ +import { PDFDocument } from 'pdf-lib'; +import type { FlattenParameters } from '../../hooks/tools/flatten/useFlattenParameters'; +import { createFileFromApiResponse } from '../fileResponseUtils'; + +const PDF_MIME_TYPE = 'application/pdf'; + +export async function flattenPdfClientSide( + params: FlattenParameters, + files: File[] +): Promise { + 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); + }) + ); +} diff --git a/frontend/src/utils/pdfOperations/fontCache.ts b/frontend/src/utils/pdfOperations/fontCache.ts new file mode 100644 index 000000000..afa02855e --- /dev/null +++ b/frontend/src/utils/pdfOperations/fontCache.ts @@ -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 = { + 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(); + +const embeddedFontCache = new WeakMap>>(); + +const FALLBACK_FONT: FontSource = { type: 'standard', name: StandardFonts.Helvetica }; + +async function fetchFontBytes(url: string): Promise { + 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 { + 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>(); + 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; + 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(FALLBACK_FONT.name, { subset: true }); + }); + } + + perDocCache.set(cacheKey, fontPromise); + return fontPromise; +} diff --git a/frontend/src/utils/pdfOperations/pageLayout.test.ts b/frontend/src/utils/pdfOperations/pageLayout.test.ts new file mode 100644 index 000000000..ecd379c4c --- /dev/null +++ b/frontend/src/utils/pdfOperations/pageLayout.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { pageLayoutClientSide } from './pageLayout'; +import type { PageLayoutParameters } from '../../hooks/tools/pageLayout/usePageLayoutParameters'; + +async function createPdf(pages: number): Promise { + const doc = await PDFDocument.create(); + for (let i = 0; i < pages; i += 1) { + doc.addPage([200, 200]); + } + const bytes = await doc.save(); + return new File([bytes], 'layout.pdf', { type: 'application/pdf' }); +} + +describe('pageLayoutClientSide', () => { + it('creates an A4 page with multiple pages per sheet', async () => { + const file = await createPdf(2); + const params = { + pagesPerSheet: 2, + addBorder: true, + processingMode: 'frontend' + } as PageLayoutParameters; + + const [result] = await pageLayoutClientSide(params, [file]); + const doc = await PDFDocument.load(await result.arrayBuffer()); + const page = doc.getPage(0); + expect(page.getWidth()).toBeCloseTo(595.28, 2); + expect(page.getHeight()).toBeCloseTo(841.89, 2); + }); +}); diff --git a/frontend/src/utils/pdfOperations/pageLayout.ts b/frontend/src/utils/pdfOperations/pageLayout.ts new file mode 100644 index 000000000..37374151c --- /dev/null +++ b/frontend/src/utils/pdfOperations/pageLayout.ts @@ -0,0 +1,79 @@ +import { PDFDocument, rgb } from 'pdf-lib'; +import type { PageLayoutParameters } from '../../hooks/tools/pageLayout/usePageLayoutParameters'; +import { createFileFromApiResponse } from '../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 { + 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`); + }) + ); +} diff --git a/frontend/src/utils/pdfOperations/removePages.test.ts b/frontend/src/utils/pdfOperations/removePages.test.ts new file mode 100644 index 000000000..e34898171 --- /dev/null +++ b/frontend/src/utils/pdfOperations/removePages.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { removePagesClientSide } from './removePages'; +import type { RemovePagesParameters } from '../../hooks/tools/removePages/useRemovePagesParameters'; + +async function createPdf(pages: number): Promise { + const pdf = await PDFDocument.create(); + for (let i = 0; i < pages; i += 1) { + const page = pdf.addPage([300, 300]); + page.drawText(`Page ${i + 1}`, { x: 20, y: 260, size: 12 }); + } + const bytes = await pdf.save(); + return new File([bytes], 'remove.pdf', { type: 'application/pdf' }); +} + +describe('removePagesClientSide', () => { + it('removes selected pages', async () => { + const input = await createPdf(3); + const params: RemovePagesParameters = { + pageNumbers: '2', + processingMode: 'frontend', + }; + + const [output] = await removePagesClientSide(params, [input]); + const doc = await PDFDocument.load(await output.arrayBuffer()); + + expect(doc.getPageCount()).toBe(2); + }); +}); diff --git a/frontend/src/utils/pdfOperations/removePages.ts b/frontend/src/utils/pdfOperations/removePages.ts new file mode 100644 index 000000000..1fea2369b --- /dev/null +++ b/frontend/src/utils/pdfOperations/removePages.ts @@ -0,0 +1,35 @@ +import { PDFDocument } from 'pdf-lib'; +import type { RemovePagesParameters } from '../../hooks/tools/removePages/useRemovePagesParameters'; +import { createFileFromApiResponse } from '../fileResponseUtils'; +import { resolvePageNumbers } from '../pageSelection'; + +const PDF_MIME_TYPE = 'application/pdf'; + +export async function removePagesClientSide( + params: RemovePagesParameters, + files: File[] +): Promise { + 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); + })); +} diff --git a/frontend/src/utils/pdfOperations/reorganizePages.test.ts b/frontend/src/utils/pdfOperations/reorganizePages.test.ts new file mode 100644 index 000000000..90087f754 --- /dev/null +++ b/frontend/src/utils/pdfOperations/reorganizePages.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { reorganizePagesClientSide } from './reorganizePages'; +import type { ReorganizePagesParameters } from '../../hooks/tools/reorganizePages/useReorganizePagesParameters'; + +async function createPdf(pages: number): Promise { + const doc = await PDFDocument.create(); + for (let i = 0; i < pages; i += 1) { + doc.addPage([200 + i * 20, 200]); + } + const bytes = await doc.save(); + return new File([bytes], 'reorder.pdf', { type: 'application/pdf' }); +} + +describe('reorganizePagesClientSide', () => { + it('reorders pages using a custom order', async () => { + const file = await createPdf(3); + const params = { + customMode: '', + pageNumbers: '3,1,2', + processingMode: 'frontend' + } as ReorganizePagesParameters; + + const [result] = await reorganizePagesClientSide(params, [file]); + const doc = await PDFDocument.load(await result.arrayBuffer()); + expect(doc.getPageCount()).toBe(3); + expect(doc.getPage(0).getWidth()).toBeCloseTo(200 + 2 * 20); + }); + + it('supports reverse order mode', async () => { + const file = await createPdf(2); + const params = { + customMode: 'REVERSE_ORDER', + pageNumbers: '', + processingMode: 'frontend' + } as ReorganizePagesParameters; + + const [result] = await reorganizePagesClientSide(params, [file]); + const doc = await PDFDocument.load(await result.arrayBuffer()); + expect(doc.getPageCount()).toBe(2); + }); +}); diff --git a/frontend/src/utils/pdfOperations/reorganizePages.ts b/frontend/src/utils/pdfOperations/reorganizePages.ts new file mode 100644 index 000000000..f4233f3bf --- /dev/null +++ b/frontend/src/utils/pdfOperations/reorganizePages.ts @@ -0,0 +1,165 @@ +import { PDFDocument } from 'pdf-lib'; +import type { ReorganizePagesParameters } from '../../hooks/tools/reorganizePages/useReorganizePagesParameters'; +import { createFileFromApiResponse } from '../fileResponseUtils'; +import { resolvePageOrderSequence } from '../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 = { + 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 { + 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`); + }) + ); +} diff --git a/frontend/src/utils/pdfOperations/rotate.test.ts b/frontend/src/utils/pdfOperations/rotate.test.ts new file mode 100644 index 000000000..aff1cff72 --- /dev/null +++ b/frontend/src/utils/pdfOperations/rotate.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument, degrees } from 'pdf-lib'; +import { rotatePdfClientSide } from './rotate'; +import type { RotateParameters } from '../../hooks/tools/rotate/useRotateParameters'; + +async function createSamplePdf(rotation: number = 0): Promise { + const pdf = await PDFDocument.create(); + const page = pdf.addPage([200, 200]); + page.setRotation(degrees(rotation)); + const bytes = await pdf.save(); + return new File([bytes], 'sample.pdf', { type: 'application/pdf' }); +} + +describe('rotatePdfClientSide', () => { + it('rotates pages by the requested angle', async () => { + const input = await createSamplePdf(0); + const params = { angle: 90, processingMode: 'frontend' } as RotateParameters; + + const [rotated] = await rotatePdfClientSide(params, [input]); + const resultDoc = await PDFDocument.load(await rotated.arrayBuffer()); + const [page] = resultDoc.getPages(); + + expect(page.getRotation().angle).toBe(90); + }); + + it('returns copies when no rotation requested', async () => { + const input = await createSamplePdf(180); + const params = { angle: 360, processingMode: 'frontend' } as RotateParameters; + + const [rotated] = await rotatePdfClientSide(params, [input]); + const resultDoc = await PDFDocument.load(await rotated.arrayBuffer()); + const [page] = resultDoc.getPages(); + + expect(page.getRotation().angle).toBe(180); + expect(rotated.name).toBe(input.name); + }); +}); diff --git a/frontend/src/utils/pdfOperations/rotate.ts b/frontend/src/utils/pdfOperations/rotate.ts new file mode 100644 index 000000000..60053d5e2 --- /dev/null +++ b/frontend/src/utils/pdfOperations/rotate.ts @@ -0,0 +1,38 @@ +import { PDFDocument, degrees } from 'pdf-lib'; +import { createFileFromApiResponse } from '../fileResponseUtils'; +import type { RotateParameters } from '../../hooks/tools/rotate/useRotateParameters'; +import { normalizeAngle } from '../../hooks/tools/rotate/useRotateParameters'; + +const PDF_MIME_TYPE = 'application/pdf'; + +export async function rotatePdfClientSide(params: RotateParameters, files: File[]): Promise { + 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; + })); +} diff --git a/frontend/src/utils/pdfOperations/singleLargePage.test.ts b/frontend/src/utils/pdfOperations/singleLargePage.test.ts new file mode 100644 index 000000000..b41620883 --- /dev/null +++ b/frontend/src/utils/pdfOperations/singleLargePage.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { singleLargePageClientSide } from './singleLargePage'; +import type { SingleLargePageParameters } from '../../hooks/tools/singleLargePage/useSingleLargePageParameters'; + +async function createPdf(): Promise { + const doc = await PDFDocument.create(); + doc.addPage([200, 300]); + doc.addPage([200, 400]); + const bytes = await doc.save(); + return new File([bytes], 'multi.pdf', { type: 'application/pdf' }); +} + +describe('singleLargePageClientSide', () => { + it('stacks pages vertically into one page', async () => { + const file = await createPdf(); + const params = { processingMode: 'frontend' } as SingleLargePageParameters; + + const [result] = await singleLargePageClientSide(params, [file]); + const doc = await PDFDocument.load(await result.arrayBuffer()); + const page = doc.getPage(0); + expect(page.getHeight()).toBeCloseTo(700); + expect(page.getWidth()).toBeCloseTo(200); + }); +}); diff --git a/frontend/src/utils/pdfOperations/singleLargePage.ts b/frontend/src/utils/pdfOperations/singleLargePage.ts new file mode 100644 index 000000000..ea70c4a5a --- /dev/null +++ b/frontend/src/utils/pdfOperations/singleLargePage.ts @@ -0,0 +1,47 @@ +import { PDFDocument } from 'pdf-lib'; +import type { SingleLargePageParameters } from '../../hooks/tools/singleLargePage/useSingleLargePageParameters'; +import { createFileFromApiResponse } from '../fileResponseUtils'; + +const PDF_MIME_TYPE = 'application/pdf'; + +export async function singleLargePageClientSide( + _params: SingleLargePageParameters, + files: File[] +): Promise { + 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`); + }) + ); +} diff --git a/frontend/src/utils/pdfOperations/split.test.ts b/frontend/src/utils/pdfOperations/split.test.ts new file mode 100644 index 000000000..77c8fe105 --- /dev/null +++ b/frontend/src/utils/pdfOperations/split.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { splitPdfClientSide } from './split'; +import type { SplitParameters } from '../../hooks/tools/split/useSplitParameters'; +import { SPLIT_METHODS } from '../../constants/splitConstants'; + +async function createPdf(pages: number): Promise { + const doc = await PDFDocument.create(); + for (let i = 0; i < pages; i += 1) { + const page = doc.addPage([200, 200]); + page.moveTo(50, 150); + page.drawText(`Page ${i + 1}`); + } + const bytes = await doc.save(); + return new File([bytes], 'split.pdf', { type: 'application/pdf' }); +} + +describe('splitPdfClientSide', () => { + it('splits pages into separate files based on page list', async () => { + const file = await createPdf(4); + const params = { + method: SPLIT_METHODS.BY_PAGES, + pages: '1,2,4', + processingMode: 'frontend' + } as SplitParameters; + + const outputs = await splitPdfClientSide(params, [file]); + expect(outputs).toHaveLength(3); + + const first = await PDFDocument.load(await outputs[0].arrayBuffer()); + expect(first.getPageCount()).toBe(1); + const last = await PDFDocument.load(await outputs[2].arrayBuffer()); + expect(last.getPageCount()).toBe(1); + }); +}); diff --git a/frontend/src/utils/pdfOperations/split.ts b/frontend/src/utils/pdfOperations/split.ts new file mode 100644 index 000000000..2916b741b --- /dev/null +++ b/frontend/src/utils/pdfOperations/split.ts @@ -0,0 +1,75 @@ +import { PDFDocument } from 'pdf-lib'; +import type { SplitParameters } from '../../hooks/tools/split/useSplitParameters'; +import { resolvePageNumbers, validatePageNumbers } from '../pageSelection'; +import { createFileFromApiResponse } from '../fileResponseUtils'; + +const PDF_MIME_TYPE = 'application/pdf'; + +const getSplitPoints = (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; +}; + +export async function splitPdfClientSide( + params: SplitParameters, + files: File[] +): Promise { + 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.pages, 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()); +}