From 46a4a978fc344210ae9e60e8f3e44e2aff182f05 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:24:48 +0100 Subject: [PATCH] Booklet and server sign (#4371) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: a Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../software/common/model/api/PDFFile.java | 4 +- .../ServerCertificateServiceInterface.java | 42 +++ .../SPDF/config/EndpointConfiguration.java | 2 + .../api/BookletImpositionController.java | 324 ++++++++++++++++++ .../controller/api/misc/ConfigController.java | 22 +- .../api/security/CertSignController.java | 34 +- .../api/general/BookletImpositionRequest.java | 54 +++ .../api/security/SignPDFWithCertRequest.java | 2 +- .../src/main/resources/settings.yml.template | 5 + .../ServerCertificateInitializer.java | 27 ++ .../api/ServerCertificateController.java | 144 ++++++++ .../service/ServerCertificateService.java | 252 ++++++++++++++ .../public/locales/en-GB/translation.json | 280 ++++++++++++--- .../public/locales/en-US/translation.json | 15 + .../src/components/shared/ButtonSelector.tsx | 7 +- .../BookletImpositionSettings.tsx | 179 ++++++++++ .../certSign/CertificateFilesSettings.tsx | 95 +++++ .../certSign/CertificateFormatSettings.tsx | 70 ++++ .../certSign/CertificateTypeSettings.tsx | 62 ++++ .../certSign/SignatureAppearanceSettings.tsx | 110 ++++++ .../tooltips/useBookletImpositionTips.ts | 57 +++ .../tooltips/useCertSignTooltips.ts | 45 +++ .../tooltips/useCertificateTypeTips.ts | 32 ++ .../components/tooltips/useSignModeTips.ts | 36 ++ .../tooltips/useSignatureAppearanceTips.ts | 33 ++ .../src/data/useTranslatedToolRegistry.tsx | 29 +- .../useBookletImpositionOperation.ts | 37 ++ .../useBookletImpositionParameters.ts | 36 ++ .../tools/certSign/useCertSignOperation.ts | 71 ++++ .../tools/certSign/useCertSignParameters.ts | 67 ++++ frontend/src/hooks/useAppConfig.ts | 1 + .../src/services/signatureDetectionService.ts | 140 ++++++++ frontend/src/tools/BookletImposition.tsx | 59 ++++ frontend/src/tools/CertSign.tsx | 131 +++++++ frontend/src/types/toolId.ts | 1 + frontend/src/utils/urlMapping.ts | 3 + 36 files changed, 2447 insertions(+), 61 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/service/ServerCertificateServiceInterface.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/model/api/general/BookletImpositionRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java create mode 100644 frontend/src/components/tools/bookletImposition/BookletImpositionSettings.tsx create mode 100644 frontend/src/components/tools/certSign/CertificateFilesSettings.tsx create mode 100644 frontend/src/components/tools/certSign/CertificateFormatSettings.tsx create mode 100644 frontend/src/components/tools/certSign/CertificateTypeSettings.tsx create mode 100644 frontend/src/components/tools/certSign/SignatureAppearanceSettings.tsx create mode 100644 frontend/src/components/tooltips/useBookletImpositionTips.ts create mode 100644 frontend/src/components/tooltips/useCertSignTooltips.ts create mode 100644 frontend/src/components/tooltips/useCertificateTypeTips.ts create mode 100644 frontend/src/components/tooltips/useSignModeTips.ts create mode 100644 frontend/src/components/tooltips/useSignatureAppearanceTips.ts create mode 100644 frontend/src/hooks/tools/bookletImposition/useBookletImpositionOperation.ts create mode 100644 frontend/src/hooks/tools/bookletImposition/useBookletImpositionParameters.ts create mode 100644 frontend/src/hooks/tools/certSign/useCertSignOperation.ts create mode 100644 frontend/src/hooks/tools/certSign/useCertSignParameters.ts create mode 100644 frontend/src/services/signatureDetectionService.ts create mode 100644 frontend/src/tools/BookletImposition.tsx create mode 100644 frontend/src/tools/CertSign.tsx diff --git a/app/common/src/main/java/stirling/software/common/model/api/PDFFile.java b/app/common/src/main/java/stirling/software/common/model/api/PDFFile.java index aa811da1e..b584fde2f 100644 --- a/app/common/src/main/java/stirling/software/common/model/api/PDFFile.java +++ b/app/common/src/main/java/stirling/software/common/model/api/PDFFile.java @@ -18,7 +18,9 @@ public class PDFFile { @Schema(description = "The input PDF file", format = "binary") private MultipartFile fileInput; - @Schema(description = "File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)") + @Schema( + description = + "File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)") private String fileId; @AssertTrue(message = "Either fileInput or fileId must be provided") diff --git a/app/common/src/main/java/stirling/software/common/service/ServerCertificateServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/ServerCertificateServiceInterface.java new file mode 100644 index 000000000..3d7c5d90b --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/ServerCertificateServiceInterface.java @@ -0,0 +1,42 @@ +package stirling.software.common.service; + +import java.io.InputStream; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +public interface ServerCertificateServiceInterface { + + boolean isEnabled(); + + boolean hasServerCertificate(); + + void initializeServerCertificate(); + + KeyStore getServerKeyStore() throws Exception; + + String getServerCertificatePassword(); + + X509Certificate getServerCertificate() throws Exception; + + byte[] getServerCertificatePublicKey() throws Exception; + + void uploadServerCertificate(InputStream p12Stream, String password) throws Exception; + + void deleteServerCertificate() throws Exception; + + ServerCertificateInfo getServerCertificateInfo() throws Exception; + + @Getter + @AllArgsConstructor + class ServerCertificateInfo { + private final boolean exists; + private final String subject; + private final String issuer; + private final Date validFrom; + private final Date validTo; + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index dab00a89d..a99bc4184 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -237,6 +237,7 @@ public class EndpointConfiguration { addEndpointToGroup("PageOps", "pdf-organizer"); addEndpointToGroup("PageOps", "rotate-pdf"); addEndpointToGroup("PageOps", "multi-page-layout"); + addEndpointToGroup("PageOps", "booklet-imposition"); addEndpointToGroup("PageOps", "scale-pages"); addEndpointToGroup("PageOps", "crop"); addEndpointToGroup("PageOps", "extract-page"); @@ -366,6 +367,7 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "cert-sign"); addEndpointToGroup("Java", "remove-cert-sign"); addEndpointToGroup("Java", "multi-page-layout"); + addEndpointToGroup("Java", "booklet-imposition"); addEndpointToGroup("Java", "scale-pages"); addEndpointToGroup("Java", "add-page-numbers"); addEndpointToGroup("Java", "auto-rename"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java new file mode 100644 index 000000000..d2e278429 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java @@ -0,0 +1,324 @@ +package stirling.software.SPDF.controller.api; + +import java.awt.*; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.multipdf.LayerUtility; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; +import org.apache.pdfbox.util.Matrix; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.SPDF.model.api.general.BookletImpositionRequest; +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.WebResponseUtils; + +@RestController +@RequestMapping("/api/v1/general") +@Tag(name = "General", description = "General APIs") +@RequiredArgsConstructor +public class BookletImpositionController { + + private final CustomPDFDocumentFactory pdfDocumentFactory; + + @AutoJobPostMapping(value = "/booklet-imposition", consumes = "multipart/form-data") + @Operation( + summary = "Create a booklet with proper page imposition", + description = + "This operation combines page reordering for booklet printing with multi-page layout. " + + "It rearranges pages in the correct order for booklet printing and places multiple pages " + + "on each sheet for proper folding and binding. Input:PDF Output:PDF Type:SISO") + public ResponseEntity createBookletImposition( + @ModelAttribute BookletImpositionRequest request) throws IOException { + + MultipartFile file = request.getFileInput(); + int pagesPerSheet = request.getPagesPerSheet(); + boolean addBorder = Boolean.TRUE.equals(request.getAddBorder()); + String spineLocation = + request.getSpineLocation() != null ? request.getSpineLocation() : "LEFT"; + boolean addGutter = Boolean.TRUE.equals(request.getAddGutter()); + float gutterSize = request.getGutterSize(); + boolean doubleSided = Boolean.TRUE.equals(request.getDoubleSided()); + String duplexPass = request.getDuplexPass() != null ? request.getDuplexPass() : "BOTH"; + boolean flipOnShortEdge = Boolean.TRUE.equals(request.getFlipOnShortEdge()); + + // Validate pages per sheet for booklet - only 2-up landscape is proper booklet + if (pagesPerSheet != 2) { + throw new IllegalArgumentException( + "Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature."); + } + + PDDocument sourceDocument = pdfDocumentFactory.load(file); + int totalPages = sourceDocument.getNumberOfPages(); + + // Create proper booklet with signature-based page ordering + PDDocument newDocument = + createSaddleBooklet( + sourceDocument, + totalPages, + addBorder, + spineLocation, + addGutter, + gutterSize, + doubleSided, + duplexPass, + flipOnShortEdge); + + sourceDocument.close(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + newDocument.save(baos); + newDocument.close(); + + byte[] result = baos.toByteArray(); + return WebResponseUtils.bytesToWebResponse( + result, + Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "") + + "_booklet.pdf"); + } + + private static int padToMultipleOf4(int n) { + return (n + 3) / 4 * 4; + } + + private static class Side { + final int left, right; + final boolean isBack; + + Side(int left, int right, boolean isBack) { + this.left = left; + this.right = right; + this.isBack = isBack; + } + } + + private static List saddleStitchSides( + int totalPagesOriginal, + boolean doubleSided, + String duplexPass, + boolean flipOnShortEdge) { + int N = padToMultipleOf4(totalPagesOriginal); + List out = new ArrayList<>(); + int sheets = N / 4; + + for (int s = 0; s < sheets; s++) { + int a = N - 1 - (s * 2); // left, front + int b = (s * 2); // right, front + int c = (s * 2) + 1; // left, back + int d = N - 2 - (s * 2); // right, back + + // clamp to -1 (blank) if >= totalPagesOriginal + a = (a < totalPagesOriginal) ? a : -1; + b = (b < totalPagesOriginal) ? b : -1; + c = (c < totalPagesOriginal) ? c : -1; + d = (d < totalPagesOriginal) ? d : -1; + + // Handle duplex pass selection + boolean includeFront = "BOTH".equals(duplexPass) || "FIRST".equals(duplexPass); + boolean includeBack = "BOTH".equals(duplexPass) || "SECOND".equals(duplexPass); + + if (includeFront) { + out.add(new Side(a, b, false)); // front side + } + + if (includeBack) { + // For short-edge duplex, swap back-side left/right + // Note: flipOnShortEdge is ignored in manual duplex mode since users physically + // flip the stack + if (doubleSided && flipOnShortEdge) { + out.add(new Side(d, c, true)); // swapped back side (automatic duplex only) + } else { + out.add(new Side(c, d, true)); // normal back side + } + } + } + return out; + } + + private PDDocument createSaddleBooklet( + PDDocument src, + int totalPages, + boolean addBorder, + String spineLocation, + boolean addGutter, + float gutterSize, + boolean doubleSided, + String duplexPass, + boolean flipOnShortEdge) + throws IOException { + + PDDocument dst = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(src); + + // Derive paper size from source document's first page CropBox + PDRectangle srcBox = src.getPage(0).getCropBox(); + PDRectangle portraitPaper = new PDRectangle(srcBox.getWidth(), srcBox.getHeight()); + // Force landscape for booklet (Acrobat booklet uses landscape paper to fold to portrait) + PDRectangle pageSize = new PDRectangle(portraitPaper.getHeight(), portraitPaper.getWidth()); + + // Validate and clamp gutter size + if (gutterSize < 0) gutterSize = 0; + if (gutterSize >= pageSize.getWidth() / 2f) gutterSize = pageSize.getWidth() / 2f - 1f; + + List sides = saddleStitchSides(totalPages, doubleSided, duplexPass, flipOnShortEdge); + + for (Side side : sides) { + PDPage out = new PDPage(pageSize); + dst.addPage(out); + + float cellW = pageSize.getWidth() / 2f; + float cellH = pageSize.getHeight(); + + // For RIGHT spine (RTL), swap left/right placements + boolean rtl = "RIGHT".equalsIgnoreCase(spineLocation); + int leftCol = rtl ? 1 : 0; + int rightCol = rtl ? 0 : 1; + + // Apply gutter margins with centered gap option + float g = addGutter ? gutterSize : 0f; + float leftCellX = leftCol * cellW + (g / 2f); + float rightCellX = rightCol * cellW - (g / 2f); + float leftCellW = cellW - (g / 2f); + float rightCellW = cellW - (g / 2f); + + // Create LayerUtility once per page for efficiency + LayerUtility layerUtility = new LayerUtility(dst); + + try (PDPageContentStream cs = + new PDPageContentStream( + dst, out, PDPageContentStream.AppendMode.APPEND, true, true)) { + + if (addBorder) { + cs.setLineWidth(1.5f); + cs.setStrokingColor(Color.BLACK); + } + + // draw left cell + drawCell( + src, + dst, + cs, + layerUtility, + side.left, + leftCellX, + 0f, + leftCellW, + cellH, + addBorder); + // draw right cell + drawCell( + src, + dst, + cs, + layerUtility, + side.right, + rightCellX, + 0f, + rightCellW, + cellH, + addBorder); + } + } + return dst; + } + + private void drawCell( + PDDocument src, + PDDocument dst, + PDPageContentStream cs, + LayerUtility layerUtility, + int pageIndex, + float cellX, + float cellY, + float cellW, + float cellH, + boolean addBorder) + throws IOException { + + if (pageIndex < 0) { + // Draw border for blank cell if needed + if (addBorder) { + cs.addRect(cellX, cellY, cellW, cellH); + cs.stroke(); + } + return; + } + + PDPage srcPage = src.getPage(pageIndex); + PDRectangle r = srcPage.getCropBox(); // Use CropBox instead of MediaBox + int rot = (srcPage.getRotation() + 360) % 360; + + // Calculate scale factors, accounting for rotation + float sx = cellW / r.getWidth(); + float sy = cellH / r.getHeight(); + float s = Math.min(sx, sy); + + // If rotated 90/270 degrees, swap dimensions for fitting + if (rot == 90 || rot == 270) { + sx = cellW / r.getHeight(); + sy = cellH / r.getWidth(); + s = Math.min(sx, sy); + } + + float drawnW = (rot == 90 || rot == 270) ? r.getHeight() * s : r.getWidth() * s; + float drawnH = (rot == 90 || rot == 270) ? r.getWidth() * s : r.getHeight() * s; + + // Center in cell, accounting for CropBox offset + float tx = cellX + (cellW - drawnW) / 2f - r.getLowerLeftX() * s; + float ty = cellY + (cellH - drawnH) / 2f - r.getLowerLeftY() * s; + + cs.saveGraphicsState(); + cs.transform(Matrix.getTranslateInstance(tx, ty)); + cs.transform(Matrix.getScaleInstance(s, s)); + + // Apply rotation if needed (rotate about origin), then translate to keep in cell + switch (rot) { + case 90: + cs.transform(Matrix.getRotateInstance(Math.PI / 2, 0, 0)); + // After 90° CCW, the content spans x in [-r.getHeight(), 0] and y in [0, + // r.getWidth()] + cs.transform(Matrix.getTranslateInstance(0, -r.getWidth())); + break; + case 180: + cs.transform(Matrix.getRotateInstance(Math.PI, 0, 0)); + cs.transform(Matrix.getTranslateInstance(-r.getWidth(), -r.getHeight())); + break; + case 270: + cs.transform(Matrix.getRotateInstance(3 * Math.PI / 2, 0, 0)); + // After 270° CCW, the content spans x in [0, r.getHeight()] and y in + // [-r.getWidth(), 0] + cs.transform(Matrix.getTranslateInstance(-r.getHeight(), 0)); + break; + default: + // 0°: no-op + } + + // Reuse LayerUtility passed from caller + PDFormXObject form = layerUtility.importPageAsForm(src, pageIndex); + cs.drawForm(form); + + cs.restoreGraphicsState(); + + // Draw border on top of form to ensure visibility + if (addBorder) { + cs.addRect(cellX, cellY, cellW, cellH); + cs.stroke(); + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index f066cb82f..6d9263270 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -10,21 +10,32 @@ import org.springframework.web.bind.annotation.RequestParam; import io.swagger.v3.oas.annotations.Hidden; -import lombok.RequiredArgsConstructor; - import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.ServerCertificateServiceInterface; @ConfigApi -@RequiredArgsConstructor @Hidden public class ConfigController { private final ApplicationProperties applicationProperties; private final ApplicationContext applicationContext; private final EndpointConfiguration endpointConfiguration; + private final ServerCertificateServiceInterface serverCertificateService; + + public ConfigController( + ApplicationProperties applicationProperties, + ApplicationContext applicationContext, + EndpointConfiguration endpointConfiguration, + @org.springframework.beans.factory.annotation.Autowired(required = false) + ServerCertificateServiceInterface serverCertificateService) { + this.applicationProperties = applicationProperties; + this.applicationContext = applicationContext; + this.endpointConfiguration = endpointConfiguration; + this.serverCertificateService = serverCertificateService; + } @GetMapping("/app-config") public ResponseEntity> getAppConfig() { @@ -58,6 +69,11 @@ public class ConfigController { // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); + // Server certificate settings + configData.put( + "serverCertificateEnabled", + serverCertificateService != null && serverCertificateService.isEnabled()); + // Legal settings configData.put( "termsAndConditions", applicationProperties.getLegal().getTermsAndConditions()); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index c68cc073b..4d2130e63 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -53,6 +53,7 @@ import org.bouncycastle.operator.InputDecryptorProvider; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; import org.bouncycastle.pkcs.PKCSException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.ClassPathResource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -68,13 +69,13 @@ import io.micrometer.common.util.StringUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.swagger.StandardPdfResponse; import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.ServerCertificateServiceInterface; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.WebResponseUtils; @@ -82,7 +83,6 @@ import stirling.software.common.util.WebResponseUtils; @RequestMapping("/api/v1/security") @Slf4j @Tag(name = "Security", description = "Security APIs") -@RequiredArgsConstructor public class CertSignController { static { @@ -102,6 +102,15 @@ public class CertSignController { } private final CustomPDFDocumentFactory pdfDocumentFactory; + private final ServerCertificateServiceInterface serverCertificateService; + + public CertSignController( + CustomPDFDocumentFactory pdfDocumentFactory, + @Autowired(required = false) + ServerCertificateServiceInterface serverCertificateService) { + this.pdfDocumentFactory = pdfDocumentFactory; + this.serverCertificateService = serverCertificateService; + } private static void sign( CustomPDFDocumentFactory pdfDocumentFactory, @@ -177,6 +186,7 @@ public class CertSignController { } KeyStore ks = null; + String keystorePassword = password; switch (certType) { case "PEM": @@ -195,6 +205,24 @@ public class CertSignController { ks = KeyStore.getInstance("JKS"); ks.load(jksfile.getInputStream(), password.toCharArray()); break; + case "SERVER": + if (serverCertificateService == null) { + throw ExceptionUtils.createIllegalArgumentException( + "error.serverCertificateNotAvailable", + "Server certificate service is not available in this edition"); + } + if (!serverCertificateService.isEnabled()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.serverCertificateDisabled", + "Server certificate feature is disabled"); + } + if (!serverCertificateService.hasServerCertificate()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.serverCertificateNotFound", "No server certificate configured"); + } + ks = serverCertificateService.getServerKeyStore(); + keystorePassword = serverCertificateService.getServerCertificatePassword(); + break; default: throw ExceptionUtils.createIllegalArgumentException( "error.invalidArgument", @@ -202,7 +230,7 @@ public class CertSignController { "certificate type: " + certType); } - CreateSignature createSignature = new CreateSignature(ks, password.toCharArray()); + CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray()); ByteArrayOutputStream baos = new ByteArrayOutputStream(); sign( pdfDocumentFactory, diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/BookletImpositionRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/BookletImpositionRequest.java new file mode 100644 index 000000000..456302e55 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/BookletImpositionRequest.java @@ -0,0 +1,54 @@ +package stirling.software.SPDF.model.api.general; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import stirling.software.common.model.api.PDFFile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class BookletImpositionRequest extends PDFFile { + + @Schema( + description = + "The number of pages per side for booklet printing (always 2 for proper booklet).", + type = "number", + defaultValue = "2", + requiredMode = Schema.RequiredMode.REQUIRED, + allowableValues = {"2"}) + private int pagesPerSheet = 2; + + @Schema(description = "Boolean for if you wish to add border around the pages") + private Boolean addBorder = false; + + @Schema( + description = "The spine location for the booklet.", + type = "string", + defaultValue = "LEFT", + allowableValues = {"LEFT", "RIGHT"}) + private String spineLocation = "LEFT"; + + @Schema(description = "Add gutter margin (inner margin for binding)") + private Boolean addGutter = false; + + @Schema( + description = "Gutter margin size in points (used when addGutter is true)", + type = "number", + defaultValue = "12") + private float gutterSize = 12f; + + @Schema(description = "Generate both front and back sides (double-sided printing)") + private Boolean doubleSided = true; + + @Schema( + description = "For manual duplex: which pass to generate", + type = "string", + defaultValue = "BOTH", + allowableValues = {"BOTH", "FIRST", "SECOND"}) + private String duplexPass = "BOTH"; + + @Schema(description = "Flip back sides for short-edge duplex printing (default is long-edge)") + private Boolean flipOnShortEdge = false; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java index acb4b55fd..d75f751f1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java @@ -15,7 +15,7 @@ public class SignPDFWithCertRequest extends PDFFile { @Schema( description = "The type of the digital certificate", - allowableValues = {"PEM", "PKCS12", "JKS"}, + allowableValues = {"PEM", "PKCS12", "JKS", "SERVER"}, requiredMode = Schema.RequiredMode.REQUIRED) private String certType; diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index bbbac5fcd..465f95fb6 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -114,6 +114,11 @@ system: enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) maxDPI: 500 # Maximum allowed DPI for PDF to image conversion + serverCertificate: + enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option + organizationName: Stirling-PDF # Organization name for generated certificates + validity: 365 # Certificate validity in days + regenerateOnStartup: false # Generate new certificate on each startup html: urlSecurity: enabled: true # Enable URL security restrictions for HTML processing diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java b/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java new file mode 100644 index 000000000..6e82d1d99 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java @@ -0,0 +1,27 @@ +package stirling.software.proprietary.configuration; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.ServerCertificateServiceInterface; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ServerCertificateInitializer { + + private final ServerCertificateServiceInterface serverCertificateService; + + @EventListener(ApplicationReadyEvent.class) + public void initializeServerCertificate() { + try { + serverCertificateService.initializeServerCertificate(); + } catch (Exception e) { + log.error("Failed to initialize server certificate", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java new file mode 100644 index 000000000..52d77e40c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java @@ -0,0 +1,144 @@ +package stirling.software.proprietary.security.controller.api; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.ServerCertificateServiceInterface; + +@RestController +@RequestMapping("/api/v1/admin/server-certificate") +@Slf4j +@Tag( + name = "Admin - Server Certificate", + description = "Admin APIs for server certificate management") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class ServerCertificateController { + + private final ServerCertificateServiceInterface serverCertificateService; + + @GetMapping("/info") + @Operation( + summary = "Get server certificate information", + description = "Returns information about the current server certificate") + public ResponseEntity + getServerCertificateInfo() { + try { + ServerCertificateServiceInterface.ServerCertificateInfo info = + serverCertificateService.getServerCertificateInfo(); + return ResponseEntity.ok(info); + } catch (Exception e) { + log.error("Failed to get server certificate info", e); + return ResponseEntity.internalServerError().build(); + } + } + + @PostMapping("/upload") + @Operation( + summary = "Upload server certificate", + description = + "Upload a new PKCS12 certificate file to be used as the server certificate") + public ResponseEntity uploadServerCertificate( + @Parameter(description = "PKCS12 certificate file", required = true) + @RequestParam("file") + MultipartFile file, + @Parameter(description = "Certificate password", required = true) + @RequestParam("password") + String password) { + + if (file.isEmpty()) { + return ResponseEntity.badRequest().body("Certificate file cannot be empty"); + } + + if (!file.getOriginalFilename().toLowerCase().endsWith(".p12") + && !file.getOriginalFilename().toLowerCase().endsWith(".pfx")) { + return ResponseEntity.badRequest() + .body("Only PKCS12 (.p12 or .pfx) files are supported"); + } + + try { + serverCertificateService.uploadServerCertificate(file.getInputStream(), password); + return ResponseEntity.ok("Server certificate uploaded successfully"); + } catch (IllegalArgumentException e) { + log.warn("Invalid certificate upload: {}", e.getMessage()); + return ResponseEntity.badRequest().body("Invalid certificate or password."); + } catch (Exception e) { + log.error("Failed to upload server certificate", e); + return ResponseEntity.internalServerError().body("Failed to upload server certificate"); + } + } + + @DeleteMapping + @Operation( + summary = "Delete server certificate", + description = "Delete the current server certificate") + public ResponseEntity deleteServerCertificate() { + try { + serverCertificateService.deleteServerCertificate(); + return ResponseEntity.ok("Server certificate deleted successfully"); + } catch (Exception e) { + log.error("Failed to delete server certificate", e); + return ResponseEntity.internalServerError().body("Failed to delete server certificate"); + } + } + + @PostMapping("/generate") + @Operation( + summary = "Generate new server certificate", + description = "Generate a new self-signed server certificate") + public ResponseEntity generateServerCertificate() { + try { + serverCertificateService.deleteServerCertificate(); // Remove existing if any + serverCertificateService.initializeServerCertificate(); // Generate new + return ResponseEntity.ok("New server certificate generated successfully"); + } catch (Exception e) { + log.error("Failed to generate server certificate", e); + return ResponseEntity.internalServerError() + .body("Failed to generate server certificate"); + } + } + + @GetMapping("/certificate") + @Operation( + summary = "Download server certificate", + description = "Download the server certificate in DER format for validation purposes") + public ResponseEntity getServerCertificate() { + try { + if (!serverCertificateService.hasServerCertificate()) { + return ResponseEntity.notFound().build(); + } + + byte[] certificate = serverCertificateService.getServerCertificatePublicKey(); + + return ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"server-cert.cer\"") + .contentType(MediaType.valueOf("application/pkix-cert")) + .body(certificate); + } catch (Exception e) { + log.error("Failed to get server certificate", e); + return ResponseEntity.internalServerError().build(); + } + } + + @GetMapping("/enabled") + @Operation( + summary = "Check if server certificate feature is enabled", + description = + "Returns whether the server certificate feature is enabled in configuration") + public ResponseEntity isServerCertificateEnabled() { + return ResponseEntity.ok(serverCertificateService.isEnabled()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java new file mode 100644 index 000000000..a743b21fe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java @@ -0,0 +1,252 @@ +package stirling.software.proprietary.service; + +import java.io.*; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.*; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.service.ServerCertificateServiceInterface; + +@Service +@Slf4j +public class ServerCertificateService implements ServerCertificateServiceInterface { + + private static final String KEYSTORE_FILENAME = "server-certificate.p12"; + private static final String KEYSTORE_ALIAS = "stirling-pdf-server"; + private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert"; + + @Value("${system.serverCertificate.enabled:false}") + private boolean enabled; + + @Value("${system.serverCertificate.organizationName:Stirling-PDF}") + private String organizationName; + + @Value("${system.serverCertificate.validity:365}") + private int validityDays; + + @Value("${system.serverCertificate.regenerateOnStartup:false}") + private boolean regenerateOnStartup; + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + private Path getKeystorePath() { + return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME); + } + + public boolean isEnabled() { + return enabled; + } + + public boolean hasServerCertificate() { + return Files.exists(getKeystorePath()); + } + + public void initializeServerCertificate() { + if (!enabled) { + log.debug("Server certificate feature is disabled"); + return; + } + + Path keystorePath = getKeystorePath(); + + if (!Files.exists(keystorePath) || regenerateOnStartup) { + try { + generateServerCertificate(); + log.info("Generated new server certificate at: {}", keystorePath); + } catch (Exception e) { + log.error("Failed to generate server certificate", e); + } + } else { + log.info("Server certificate already exists at: {}", keystorePath); + } + } + + public KeyStore getServerKeyStore() throws Exception { + if (!enabled || !hasServerCertificate()) { + throw new IllegalStateException("Server certificate is not available"); + } + + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (FileInputStream fis = new FileInputStream(getKeystorePath().toFile())) { + keyStore.load(fis, DEFAULT_PASSWORD.toCharArray()); + } + return keyStore; + } + + public String getServerCertificatePassword() { + return DEFAULT_PASSWORD; + } + + public X509Certificate getServerCertificate() throws Exception { + KeyStore keyStore = getServerKeyStore(); + return (X509Certificate) keyStore.getCertificate(KEYSTORE_ALIAS); + } + + public byte[] getServerCertificatePublicKey() throws Exception { + X509Certificate cert = getServerCertificate(); + return cert.getEncoded(); + } + + public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception { + // Validate the uploaded certificate + KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12"); + uploadedKeyStore.load(p12Stream, password.toCharArray()); + + // Find the first private key entry + String alias = null; + for (String a : java.util.Collections.list(uploadedKeyStore.aliases())) { + if (uploadedKeyStore.isKeyEntry(a)) { + alias = a; + break; + } + } + + if (alias == null) { + throw new IllegalArgumentException("No private key found in uploaded certificate"); + } + + // Create new keystore with our standard alias and password + KeyStore newKeyStore = KeyStore.getInstance("PKCS12"); + newKeyStore.load(null, null); + + PrivateKey privateKey = (PrivateKey) uploadedKeyStore.getKey(alias, password.toCharArray()); + Certificate[] chain = uploadedKeyStore.getCertificateChain(alias); + + newKeyStore.setKeyEntry(KEYSTORE_ALIAS, privateKey, DEFAULT_PASSWORD.toCharArray(), chain); + + // Save to server keystore location + Path keystorePath = getKeystorePath(); + Files.createDirectories(keystorePath.getParent()); + + try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) { + newKeyStore.store(fos, DEFAULT_PASSWORD.toCharArray()); + } + + log.info("Server certificate updated from uploaded file"); + } + + public void deleteServerCertificate() throws Exception { + Path keystorePath = getKeystorePath(); + if (Files.exists(keystorePath)) { + Files.delete(keystorePath); + log.info("Server certificate deleted"); + } + } + + public ServerCertificateInfo getServerCertificateInfo() throws Exception { + if (!hasServerCertificate()) { + return new ServerCertificateInfo(false, null, null, null, null); + } + + X509Certificate cert = getServerCertificate(); + return new ServerCertificateInfo( + true, + cert.getSubjectX500Principal().getName(), + cert.getIssuerX500Principal().getName(), + cert.getNotBefore(), + cert.getNotAfter()); + } + + private void generateServerCertificate() throws Exception { + // Generate key pair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + keyPairGenerator.initialize(2048, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // Certificate details + X500Name subject = + new X500Name( + "CN=" + organizationName + " Server, O=" + organizationName + ", C=US"); + BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis()); + Date notBefore = new Date(); + Date notAfter = new Date(notBefore.getTime() + ((long) validityDays * 24 * 60 * 60 * 1000)); + + // Build certificate + JcaX509v3CertificateBuilder certBuilder = + new JcaX509v3CertificateBuilder( + subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic()); + + // Add PDF-specific certificate extensions for optimal PDF signing compatibility + JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils(); + + // 1) End-entity certificate, not a CA (critical) + certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + + // 2) Key usage for PDF digital signatures (critical) + certBuilder.addExtension( + Extension.keyUsage, + true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation)); + + // 3) Extended key usage for document signing (non-critical, widely accepted) + certBuilder.addExtension( + Extension.extendedKeyUsage, + false, + new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning)); + + // 4) Subject Key Identifier for chain building (non-critical) + certBuilder.addExtension( + Extension.subjectKeyIdentifier, + false, + extUtils.createSubjectKeyIdentifier(keyPair.getPublic())); + + // 5) Authority Key Identifier for self-signed cert (non-critical) + certBuilder.addExtension( + Extension.authorityKeyIdentifier, + false, + extUtils.createAuthorityKeyIdentifier(keyPair.getPublic())); + + // Sign certificate + ContentSigner signer = + new JcaContentSignerBuilder("SHA256WithRSA") + .setProvider("BC") + .build(keyPair.getPrivate()); + + X509CertificateHolder certHolder = certBuilder.build(signer); + X509Certificate cert = + new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder); + + // Create keystore + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry( + KEYSTORE_ALIAS, + keyPair.getPrivate(), + DEFAULT_PASSWORD.toCharArray(), + new Certificate[] {cert}); + + // Save keystore + Path keystorePath = getKeystorePath(); + Files.createDirectories(keystorePath.getParent()); + + try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) { + keyStore.store(fos, DEFAULT_PASSWORD.toCharArray()); + } + } +} diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index ad726085f..6f87ab5ce 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -427,6 +427,10 @@ "title": "Flatten", "desc": "Remove all interactive elements and forms from a PDF" }, + "certSign": { + "title": "Sign with Certificate", + "desc": "Signs a PDF with a Certificate/Key (PEM/P12)" + }, "repair": { "title": "Repair", "desc": "Tries to repair a corrupt/broken PDF" @@ -443,10 +447,6 @@ "title": "Compare", "desc": "Compares and shows the differences between 2 PDF Documents" }, - "certSign": { - "title": "Sign with Certificate", - "desc": "Signs a PDF with a Certificate/Key (PEM/P12)" - }, "removeCertSign": { "title": "Remove Certificate Sign", "desc": "Remove certificate signature from PDF" @@ -455,6 +455,10 @@ "title": "Multi-Page Layout", "desc": "Merge multiple pages of a PDF document into a single page" }, + "bookletImposition": { + "title": "Booklet Imposition", + "desc": "Create booklets with proper page ordering and multi-page layout for printing and binding" + }, "scalePages": { "title": "Adjust page size/scale", "desc": "Change the size/scale of a page and/or its contents." @@ -1179,7 +1183,9 @@ }, "pageSelection": { "tooltip": { - "header": { "title": "Page Selection Guide" }, + "header": { + "title": "Page Selection Guide" + }, "basic": { "title": "Basic Usage", "text": "Select specific pages from your PDF document using simple syntax.", @@ -1213,11 +1219,15 @@ "comma": "Comma: , or | — combine selections (e.g., 1-10, 20)", "not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)" }, - "examples": { "title": "Examples" } + "examples": { + "title": "Examples" + } } }, "bulkSelection": { - "header": { "title": "Page Selection Guide" }, + "header": { + "title": "Page Selection Guide" + }, "syntax": { "title": "Syntax Basics", "text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.", @@ -1769,23 +1779,124 @@ } }, "certSign": { - "tags": "authenticate,PEM,P12,official,encrypt", + "tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto", "title": "Certificate Signing", - "header": "Sign a PDF with your certificate (Work in progress)", - "selectPDF": "Select a PDF File for Signing:", - "jksNote": "Note: If your certificate type is not listed below, please convert it to a Java Keystore (.jks) file using the keytool command line tool. Then, choose the .jks file option below.", - "selectKey": "Select Your Private Key File (PKCS#8 format, could be .pem or .der):", - "selectCert": "Select Your Certificate File (X.509 format, could be .pem or .der):", - "selectP12": "Select Your PKCS#12 Keystore File (.p12 or .pfx) (Optional, If provided, it should contain your private key and certificate):", - "selectJKS": "Select Your Java Keystore File (.jks or .keystore):", - "certType": "Certificate Type", - "password": "Enter Your Keystore or Private Key Password (If Any):", - "showSig": "Show Signature", - "reason": "Reason", - "location": "Location", - "name": "Name", - "showLogo": "Show Logo", - "submit": "Sign PDF" + "filenamePrefix": "signed", + "signMode": { + "stepTitle": "Sign Mode", + "tooltip": { + "header": { + "title": "About PDF Signatures" + }, + "overview": { + "title": "How signatures work", + "text": "Both modes seal the document (any edits are flagged as tampering) and record who/when/how for auditing. Viewer trust depends on the certificate chain." + }, + "manual": { + "title": "Manual - Bring your certificate", + "text": "Use your own certificate files for brand-aligned identity. Can display Trusted when your CA/chain is recognised.", + "use": "Use for: customer-facing, legal, compliance." + }, + "auto": { + "title": "Auto - Zero-setup, instant system seal", + "text": "Signs with a server self-signed certificate. Same tamper-evident seal and audit trail; typically shows Unverified in viewers.", + "use": "Use when: you need speed and consistent internal identity across reviews and records." + }, + "rule": { + "title": "Rule of thumb", + "text": "Need recipient Trusted status? Manual. Need a fast, tamper-evident seal and audit trail with no setup? Auto." + } + } + }, + "certTypeStep": { + "stepTitle": "Certificate Format" + }, + "certFiles": { + "stepTitle": "Certificate Files" + }, + "appearance": { + "stepTitle": "Signature Appearance", + "tooltip": { + "header": { + "title": "About Signature Appearance" + }, + "invisible": { + "title": "Invisible Signatures", + "text": "The signature is added to the PDF for security but won't be visible when viewing the document. Perfect for legal requirements without changing the document's appearance.", + "bullet1": "Provides security without visual changes", + "bullet2": "Meets legal requirements for digital signing", + "bullet3": "Doesn't affect document layout or design" + }, + "visible": { + "title": "Visible Signatures", + "text": "Shows a signature block on the PDF with your name, date, and optional details. Useful when you want readers to clearly see the document is signed.", + "bullet1": "Shows signer name and date on the document", + "bullet2": "Can include reason and location for signing", + "bullet3": "Choose which page to place the signature", + "bullet4": "Optional logo can be included" + } + } + }, + "sign": { + "submit": "Sign PDF", + "results": "Signed PDF" + }, + "error": { + "failed": "An error occurred whilst processing signatures." + }, + "tooltip": { + "header": { + "title": "About Managing Signatures" + }, + "overview": { + "title": "What can this tool do?", + "text": "This tool lets you check if your PDFs are digitally signed and add new digital signatures. Digital signatures prove who created or approved a document and show if it has been changed since signing.", + "bullet1": "Check existing signatures and their validity", + "bullet2": "View detailed information about signers and certificates", + "bullet3": "Add new digital signatures to secure your documents", + "bullet4": "Multiple files supported with easy navigation" + }, + "validation": { + "title": "Checking Signatures", + "text": "When you check signatures, the tool tells you if they're valid, who signed the document, when it was signed, and whether the document has been changed since signing.", + "bullet1": "Shows if signatures are valid or invalid", + "bullet2": "Displays signer information and signing date", + "bullet3": "Checks if the document was modified after signing", + "bullet4": "Can use custom certificates for verification" + }, + "signing": { + "title": "Adding Signatures", + "text": "To sign a PDF, you need a digital certificate (like PEM, PKCS12, or JKS). You can choose to make the signature visible on the document or keep it invisible for security only.", + "bullet1": "Supports PEM, PKCS12, JKS, and server certificate formats", + "bullet2": "Option to show or hide signature on the PDF", + "bullet3": "Add reason, location, and signer name", + "bullet4": "Choose which page to place visible signatures", + "bullet5": "Use server certificate for simple 'Sign with Stirling-PDF' option" + } + }, + "certType": { + "tooltip": { + "header": { + "title": "About Certificate Types" + }, + "what": { + "title": "What's a certificate?", + "text": "It's a secure ID for your signature that proves you signed. Unless you're required to sign via certificate, we recommend using another secure method like Type, Draw, or Upload." + }, + "which": { + "title": "Which option should I use?", + "text": "Choose the format that matches your certificate file:", + "bullet1": "PKCS#12 (.p12 / .pfx) – one combined file (most common)", + "bullet2": "PFX (.pfx) – Microsoft's version of PKCS12", + "bullet3": "PEM – separate private-key and certificate .pem files", + "bullet4": "JKS – Java .jks keystore for dev / CI-CD workflows" + }, + "convert": { + "title": "Key not listed?", + "text": "Convert your file to a Java keystore (.jks) with keytool, then pick JKS." + } + } + } }, "removeCertSign": { "tags": "authenticate,PEM,P12,official,decrypt", @@ -1813,6 +1924,99 @@ "addBorder": "Add Borders", "submit": "Submit" }, + "bookletImposition": { + "tags": "booklet,imposition,printing,binding,folding,signature", + "title": "Booklet Imposition", + "header": "Booklet Imposition", + "submit": "Create Booklet", + "spineLocation": { + "label": "Spine Location", + "left": "Left (Standard)", + "right": "Right (RTL)" + }, + "doubleSided": { + "label": "Double-sided printing", + "tooltip": "Creates both front and back sides for proper booklet printing" + }, + "manualDuplex": { + "title": "Manual Duplex Mode", + "instructions": "For printers without automatic duplex. You'll need to run this twice:" + }, + "duplexPass": { + "label": "Print Pass", + "first": "1st Pass", + "second": "2nd Pass", + "firstInstructions": "Prints front sides → stack face-down → run again with 2nd Pass", + "secondInstructions": "Load printed stack face-down → prints back sides" + }, + "rtlBinding": { + "label": "Right-to-left binding", + "tooltip": "For Arabic, Hebrew, or other right-to-left languages" + }, + "addBorder": { + "label": "Add borders around pages", + "tooltip": "Adds borders around each page section to help with cutting and alignment" + }, + "addGutter": { + "label": "Add gutter margin", + "tooltip": "Adds inner margin space for binding" + }, + "gutterSize": { + "label": "Gutter size (points)" + }, + "flipOnShortEdge": { + "label": "Flip on short edge (automatic duplex only)", + "tooltip": "Enable for short-edge duplex printing (automatic duplex only - ignored in manual mode)", + "manualNote": "Not needed in manual mode - you flip the stack yourself" + }, + "advanced": { + "toggle": "Advanced Options" + }, + "paperSizeNote": "Paper size is automatically derived from your first page.", + "tooltip": { + "header": { + "title": "Booklet Creation Guide" + }, + "description": { + "title": "What is Booklet Imposition?", + "text": "Creates professional booklets by arranging pages in the correct printing order. Your PDF pages are placed 2-up on landscape sheets so when folded and bound, they read in proper sequence like a real book." + }, + "example": { + "title": "Example: 8-Page Booklet", + "text": "Your 8-page document becomes 2 sheets:", + "bullet1": "Sheet 1 Front: Pages 8, 1 | Back: Pages 2, 7", + "bullet2": "Sheet 2 Front: Pages 6, 3 | Back: Pages 4, 5", + "bullet3": "When folded & stacked: Reads 1→2→3→4→5→6→7→8" + }, + "printing": { + "title": "How to Print & Assemble", + "text": "Follow these steps for perfect booklets:", + "bullet1": "Print double-sided with 'Flip on long edge'", + "bullet2": "Stack sheets in order, fold in half", + "bullet3": "Staple or bind along the folded spine", + "bullet4": "For short-edge printers: Enable 'Flip on short edge' option" + }, + "manualDuplex": { + "title": "Manual Duplex (Single-sided Printers)", + "text": "For printers without automatic duplex:", + "bullet1": "Turn OFF 'Double-sided printing'", + "bullet2": "Select '1st Pass' → Print → Stack face-down", + "bullet3": "Select '2nd Pass' → Load stack → Print backs", + "bullet4": "Fold and assemble as normal" + }, + "advanced": { + "title": "Advanced Options", + "text": "Fine-tune your booklet:", + "bullet1": "Right-to-Left Binding: For Arabic, Hebrew, or RTL languages", + "bullet2": "Borders: Shows cut lines for trimming", + "bullet3": "Gutter Margin: Adds space for binding/stapling", + "bullet4": "Short-edge Flip: Only for automatic duplex printers" + } + }, + "error": { + "failed": "An error occurred while creating the booklet imposition." + } + }, "scalePages": { "title": "Adjust page-scale", "header": "Adjust page-scale", @@ -2566,20 +2770,14 @@ "actualSize": "Actual Size" }, "viewer": { - "noPdfLoaded": "No PDF loaded. Click to upload a PDF.", - "choosePdf": "Choose PDF", - "noPagesToDisplay": "No pages to display.", - "singlePageView": "Single Page View", - "dualPageView": "Dual Page View", - "hideSidebars": "Hide Sidebars", - "showSidebars": "Show Sidebars", - "zoomOut": "Zoom out", - "zoomIn": "Zoom in", + "firstPage": "First Page", + "lastPage": "Last Page", "previousPage": "Previous Page", "nextPage": "Next Page", - "pageNavigation": "Page Navigation", - "currentPage": "Current Page", - "totalPages": "Total Pages" + "zoomIn": "Zoom In", + "zoomOut": "Zoom Out", + "singlePageView": "Single Page View", + "dualPageView": "Dual Page View" }, "rightRail": { "closeSelected": "Close Selected Files", @@ -2963,15 +3161,5 @@ "processImages": "Process Images", "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." } - }, - "viewer": { - "firstPage": "First Page", - "lastPage": "Last Page", - "previousPage": "Previous Page", - "nextPage": "Next Page", - "zoomIn": "Zoom In", - "zoomOut": "Zoom Out", - "singlePageView": "Single Page View", - "dualPageView": "Dual Page View" } -} +} \ No newline at end of file diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index a58268664..5d8a23faa 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -495,6 +495,10 @@ "title": "Multi-Page Layout", "desc": "Merge multiple pages of a PDF document into a single page" }, + "bookletImposition": { + "title": "Booklet Imposition", + "desc": "Create booklets with proper page ordering and multi-page layout for printing and binding" + }, "scalePages": { "title": "Adjust page size/scale", "desc": "Change the size/scale of a page and/or its contents." @@ -1230,6 +1234,17 @@ "addBorder": "Add Borders", "submit": "Submit" }, + "bookletImposition": { + "tags": "booklet,imposition,printing,binding,folding,signature", + "title": "Booklet Imposition", + "header": "Booklet Imposition", + "submit": "Create Booklet", + "files": { + }, + "error": { + "failed": "An error occurred while creating the booklet imposition." + } + }, "scalePages": { "tags": "resize,modify,dimension,adapt", "title": "Adjust page-scale", diff --git a/frontend/src/components/shared/ButtonSelector.tsx b/frontend/src/components/shared/ButtonSelector.tsx index bc95134d6..79d92bf25 100644 --- a/frontend/src/components/shared/ButtonSelector.tsx +++ b/frontend/src/components/shared/ButtonSelector.tsx @@ -15,7 +15,7 @@ interface ButtonSelectorProps { fullWidth?: boolean; } -const ButtonSelector = ({ +const ButtonSelector = ({ value, onChange, options, @@ -45,7 +45,10 @@ const ButtonSelector = ({ flex: fullWidth ? 1 : undefined, height: 'auto', minHeight: '2.5rem', - fontSize: 'var(--mantine-font-size-sm)' + fontSize: 'var(--mantine-font-size-sm)', + lineHeight: '1.4', + paddingTop: '0.5rem', + paddingBottom: '0.5rem' }} > {option.label} diff --git a/frontend/src/components/tools/bookletImposition/BookletImpositionSettings.tsx b/frontend/src/components/tools/bookletImposition/BookletImpositionSettings.tsx new file mode 100644 index 000000000..e2e7a502b --- /dev/null +++ b/frontend/src/components/tools/bookletImposition/BookletImpositionSettings.tsx @@ -0,0 +1,179 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Stack, Text, Divider, Collapse, Button, NumberInput } from "@mantine/core"; +import { BookletImpositionParameters } from "../../../hooks/tools/bookletImposition/useBookletImpositionParameters"; +import ButtonSelector from "../../shared/ButtonSelector"; + +interface BookletImpositionSettingsProps { + parameters: BookletImpositionParameters; + onParameterChange: (key: keyof BookletImpositionParameters, value: any) => void; + disabled?: boolean; +} + +const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = false }: BookletImpositionSettingsProps) => { + const { t } = useTranslation(); + const [advancedOpen, setAdvancedOpen] = useState(false); + + return ( + + + + + {/* Double Sided */} + + + + {/* Manual Duplex Pass Selection - only show when double-sided is OFF */} + {!parameters.doubleSided && ( + + + {t('bookletImposition.manualDuplex.title', 'Manual Duplex Mode')} + + + {t('bookletImposition.manualDuplex.instructions', 'For printers without automatic duplex. You\'ll need to run this twice:')} + + + onParameterChange('duplexPass', value)} + options={[ + { value: 'FIRST', label: t('bookletImposition.duplexPass.first', '1st Pass') }, + { value: 'SECOND', label: t('bookletImposition.duplexPass.second', '2nd Pass') } + ]} + disabled={disabled} + /> + + + {parameters.duplexPass === 'FIRST' + ? t('bookletImposition.duplexPass.firstInstructions', 'Prints front sides → stack face-down → run again with 2nd Pass') + : t('bookletImposition.duplexPass.secondInstructions', 'Load printed stack face-down → prints back sides') + } + + + )} + + + + + {/* Advanced Options */} + + + + + + {/* Right-to-Left Binding */} + + + {/* Add Border Option */} + + + {/* Gutter Margin */} + + + + {parameters.addGutter && ( + onParameterChange('gutterSize', value || 12)} + min={6} + max={72} + step={6} + disabled={disabled} + size="sm" + /> + )} + + + {/* Flip on Short Edge */} + + + {/* Paper Size Note */} + + {t('bookletImposition.paperSizeNote', 'Paper size is automatically derived from your first page.')} + + + + + + ); +}; + +export default BookletImpositionSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/certSign/CertificateFilesSettings.tsx b/frontend/src/components/tools/certSign/CertificateFilesSettings.tsx new file mode 100644 index 000000000..a11ba5b72 --- /dev/null +++ b/frontend/src/components/tools/certSign/CertificateFilesSettings.tsx @@ -0,0 +1,95 @@ +import { Stack, Text, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters"; +import FileUploadButton from "../../shared/FileUploadButton"; + +interface CertificateFilesSettingsProps { + parameters: CertSignParameters; + onParameterChange: (key: keyof CertSignParameters, value: any) => void; + disabled?: boolean; +} + +const CertificateFilesSettings = ({ parameters, onParameterChange, disabled = false }: CertificateFilesSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {/* Certificate Files based on type */} + {parameters.certType === 'PEM' && ( + + onParameterChange('privateKeyFile', file || undefined)} + accept=".pem,.der,.key" + disabled={disabled} + placeholder={t('certSign.choosePrivateKey', 'Choose Private Key File')} + /> + {parameters.privateKeyFile && ( + onParameterChange('certFile', file || undefined)} + accept=".pem,.der,.crt,.cer" + disabled={disabled} + placeholder={t('certSign.chooseCertificate', 'Choose Certificate File')} + /> + )} + + )} + + {parameters.certType === 'PKCS12' && ( + onParameterChange('p12File', file || undefined)} + accept=".p12" + disabled={disabled} + placeholder={t('certSign.chooseP12File', 'Choose PKCS12 File')} + /> + )} + + {parameters.certType === 'PFX' && ( + onParameterChange('p12File', file || undefined)} + accept=".pfx" + disabled={disabled} + placeholder={t('certSign.choosePfxFile', 'Choose PFX File')} + /> + )} + + {parameters.certType === 'JKS' && ( + onParameterChange('jksFile', file || undefined)} + accept=".jks,.keystore" + disabled={disabled} + placeholder={t('certSign.chooseJksFile', 'Choose JKS File')} + /> + )} + + {parameters.signMode === 'AUTO' && ( + + {t('certSign.serverCertMessage', 'Using server certificate - no files or password required')} + + )} + + {/* Password - only show when files are uploaded */} + {parameters.certType && ( + (parameters.certType === 'PEM' && parameters.privateKeyFile && parameters.certFile) || + (parameters.certType === 'PKCS12' && parameters.p12File) || + (parameters.certType === 'PFX' && parameters.p12File) || + (parameters.certType === 'JKS' && parameters.jksFile) + ) && ( + onParameterChange('password', event.currentTarget.value)} + disabled={disabled} + /> + )} + + ); +}; + +export default CertificateFilesSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/certSign/CertificateFormatSettings.tsx b/frontend/src/components/tools/certSign/CertificateFormatSettings.tsx new file mode 100644 index 000000000..6fac53f4d --- /dev/null +++ b/frontend/src/components/tools/certSign/CertificateFormatSettings.tsx @@ -0,0 +1,70 @@ +import { Stack, Button } from "@mantine/core"; +import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters"; + +interface CertificateFormatSettingsProps { + parameters: CertSignParameters; + onParameterChange: (key: keyof CertSignParameters, value: any) => void; + disabled?: boolean; +} + +const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = false }: CertificateFormatSettingsProps) => { + + return ( + +
+ {/* First row - PKCS#12 and PFX */} +
+ + +
+ {/* Second row - PEM and JKS */} +
+ + +
+
+
+ ); +}; + +export default CertificateFormatSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/certSign/CertificateTypeSettings.tsx b/frontend/src/components/tools/certSign/CertificateTypeSettings.tsx new file mode 100644 index 000000000..2d514a161 --- /dev/null +++ b/frontend/src/components/tools/certSign/CertificateTypeSettings.tsx @@ -0,0 +1,62 @@ +import { Stack, Button } from "@mantine/core"; +import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters"; +import { useAppConfig } from "../../../hooks/useAppConfig"; + +interface CertificateTypeSettingsProps { + parameters: CertSignParameters; + onParameterChange: (key: keyof CertSignParameters, value: any) => void; + disabled?: boolean; +} + +const CertificateTypeSettings = ({ parameters, onParameterChange, disabled = false }: CertificateTypeSettingsProps) => { + const { config } = useAppConfig(); + const isServerCertificateEnabled = config?.serverCertificateEnabled ?? false; + + // Reset to MANUAL if AUTO is selected but feature is disabled + if (parameters.signMode === 'AUTO' && !isServerCertificateEnabled) { + onParameterChange('signMode', 'MANUAL'); + } + + return ( + +
+ + {isServerCertificateEnabled && ( + + )} +
+
+ ); +}; + +export default CertificateTypeSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/certSign/SignatureAppearanceSettings.tsx b/frontend/src/components/tools/certSign/SignatureAppearanceSettings.tsx new file mode 100644 index 000000000..aa99be742 --- /dev/null +++ b/frontend/src/components/tools/certSign/SignatureAppearanceSettings.tsx @@ -0,0 +1,110 @@ +import { Stack, Text, Button, TextInput, NumberInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters"; + +interface SignatureAppearanceSettingsProps { + parameters: CertSignParameters; + onParameterChange: (key: keyof CertSignParameters, value: any) => void; + disabled?: boolean; +} + +const SignatureAppearanceSettings = ({ parameters, onParameterChange, disabled = false }: SignatureAppearanceSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {/* Signature Visibility */} + +
+ + +
+
+ + {/* Visible Signature Options */} + {parameters.showSignature && ( + + + {t('certSign.appearance.options.title', 'Signature Details')} + + onParameterChange('reason', event.currentTarget.value)} + disabled={disabled} + /> + onParameterChange('location', event.currentTarget.value)} + disabled={disabled} + /> + onParameterChange('name', event.currentTarget.value)} + disabled={disabled} + /> + onParameterChange('pageNumber', value || 1)} + min={1} + disabled={disabled} + /> + + + {t('certSign.logoTitle', 'Logo')} + +
+ + +
+
+
+ )} +
+ ); +}; + +export default SignatureAppearanceSettings; \ No newline at end of file diff --git a/frontend/src/components/tooltips/useBookletImpositionTips.ts b/frontend/src/components/tooltips/useBookletImpositionTips.ts new file mode 100644 index 000000000..e690bf284 --- /dev/null +++ b/frontend/src/components/tooltips/useBookletImpositionTips.ts @@ -0,0 +1,57 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useBookletImpositionTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("bookletImposition.tooltip.header.title", "Booklet Creation Guide") + }, + tips: [ + { + title: t("bookletImposition.tooltip.description.title", "What is Booklet Imposition?"), + description: t("bookletImposition.tooltip.description.text", "Creates professional booklets by arranging pages in the correct printing order. Your PDF pages are placed 2-up on landscape sheets so when folded and bound, they read in proper sequence like a real book.") + }, + { + title: t("bookletImposition.tooltip.example.title", "Example: 8-Page Booklet"), + description: t("bookletImposition.tooltip.example.text", "Your 8-page document becomes 2 sheets:"), + bullets: [ + t("bookletImposition.tooltip.example.bullet1", "Sheet 1 Front: Pages 8, 1 | Back: Pages 2, 7"), + t("bookletImposition.tooltip.example.bullet2", "Sheet 2 Front: Pages 6, 3 | Back: Pages 4, 5"), + t("bookletImposition.tooltip.example.bullet3", "When folded & stacked: Reads 1→2→3→4→5→6→7→8") + ] + }, + { + title: t("bookletImposition.tooltip.printing.title", "How to Print & Assemble"), + description: t("bookletImposition.tooltip.printing.text", "Follow these steps for perfect booklets:"), + bullets: [ + t("bookletImposition.tooltip.printing.bullet1", "Print double-sided with 'Flip on long edge'"), + t("bookletImposition.tooltip.printing.bullet2", "Stack sheets in order, fold in half"), + t("bookletImposition.tooltip.printing.bullet3", "Staple or bind along the folded spine"), + t("bookletImposition.tooltip.printing.bullet4", "For short-edge printers: Enable 'Flip on short edge' option") + ] + }, + { + title: t("bookletImposition.tooltip.manualDuplex.title", "Manual Duplex (Single-sided Printers)"), + description: t("bookletImposition.tooltip.manualDuplex.text", "For printers without automatic duplex:"), + bullets: [ + t("bookletImposition.tooltip.manualDuplex.bullet1", "Turn OFF 'Double-sided printing'"), + t("bookletImposition.tooltip.manualDuplex.bullet2", "Select '1st Pass' → Print → Stack face-down"), + t("bookletImposition.tooltip.manualDuplex.bullet3", "Select '2nd Pass' → Load stack → Print backs"), + t("bookletImposition.tooltip.manualDuplex.bullet4", "Fold and assemble as normal") + ] + }, + { + title: t("bookletImposition.tooltip.advanced.title", "Advanced Options"), + description: t("bookletImposition.tooltip.advanced.text", "Fine-tune your booklet:"), + bullets: [ + t("bookletImposition.tooltip.advanced.bullet1", "Right-to-Left Binding: For Arabic, Hebrew, or RTL languages"), + t("bookletImposition.tooltip.advanced.bullet2", "Borders: Shows cut lines for trimming"), + t("bookletImposition.tooltip.advanced.bullet3", "Gutter Margin: Adds space for binding/stapling"), + t("bookletImposition.tooltip.advanced.bullet4", "Short-edge Flip: Only for automatic duplex printers") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/components/tooltips/useCertSignTooltips.ts b/frontend/src/components/tooltips/useCertSignTooltips.ts new file mode 100644 index 000000000..77245a130 --- /dev/null +++ b/frontend/src/components/tooltips/useCertSignTooltips.ts @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useCertSignTooltips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("certSign.tooltip.header.title", "About Managing Signatures") + }, + tips: [ + { + title: t("certSign.tooltip.overview.title", "What can this tool do?"), + description: t("certSign.tooltip.overview.text", "This tool lets you check if your PDFs are digitally signed and add new digital signatures. Digital signatures prove who created or approved a document and show if it has been changed since signing."), + bullets: [ + t("certSign.tooltip.overview.bullet1", "Check existing signatures and their validity"), + t("certSign.tooltip.overview.bullet2", "View detailed information about signers and certificates"), + t("certSign.tooltip.overview.bullet3", "Add new digital signatures to secure your documents"), + t("certSign.tooltip.overview.bullet4", "Multiple files supported with easy navigation") + ] + }, + { + title: t("certSign.tooltip.validation.title", "Checking Signatures"), + description: t("certSign.tooltip.validation.text", "When you check signatures, the tool tells you if they're valid, who signed the document, when it was signed, and whether the document has been changed since signing."), + bullets: [ + t("certSign.tooltip.validation.bullet1", "Shows if signatures are valid or invalid"), + t("certSign.tooltip.validation.bullet2", "Displays signer information and signing date"), + t("certSign.tooltip.validation.bullet3", "Checks if the document was modified after signing"), + t("certSign.tooltip.validation.bullet4", "Can use custom certificates for verification") + ] + }, + { + title: t("certSign.tooltip.signing.title", "Adding Signatures"), + description: t("certSign.tooltip.signing.text", "To sign a PDF, you need a digital certificate (like PEM, PKCS12, or JKS). You can choose to make the signature visible on the document or keep it invisible for security only."), + bullets: [ + t("certSign.tooltip.signing.bullet1", "Supports PEM, PKCS12, JKS, and server certificate formats"), + t("certSign.tooltip.signing.bullet2", "Option to show or hide signature on the PDF"), + t("certSign.tooltip.signing.bullet3", "Add reason, location, and signer name"), + t("certSign.tooltip.signing.bullet4", "Choose which page to place visible signatures"), + t("certSign.tooltip.signing.bullet5", "Use server certificate for simple 'Sign with Stirling-PDF' option") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/components/tooltips/useCertificateTypeTips.ts b/frontend/src/components/tooltips/useCertificateTypeTips.ts new file mode 100644 index 000000000..9e6a13076 --- /dev/null +++ b/frontend/src/components/tooltips/useCertificateTypeTips.ts @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useCertificateTypeTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("certSign.certType.tooltip.header.title", "About Certificate Types") + }, + tips: [ + { + title: t("certSign.certType.tooltip.what.title", "What's a certificate?"), + description: t("certSign.certType.tooltip.what.text", "It's a secure ID for your signature that proves you signed. Unless you're required to sign via certificate, we recommend using another secure method like Type, Draw, or Upload.") + }, + { + title: t("certSign.certType.tooltip.which.title", "Which option should I use?"), + description: t("certSign.certType.tooltip.which.text", "Choose the format that matches your certificate file:"), + bullets: [ + t("certSign.certType.tooltip.which.bullet1", "PKCS12 (.p12) – one combined file (most common)"), + t("certSign.certType.tooltip.which.bullet2", "PFX (.pfx) – Microsoft's version of PKCS12"), + t("certSign.certType.tooltip.which.bullet3", "PEM – separate private-key and certificate .pem files"), + t("certSign.certType.tooltip.which.bullet4", "JKS – Java .jks keystore for dev / CI-CD workflows") + ] + }, + { + title: t("certSign.certType.tooltip.convert.title", "Key not listed?"), + description: t("certSign.certType.tooltip.convert.text", "Convert your file to a Java keystore (.jks) with keytool, then pick JKS.") + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/components/tooltips/useSignModeTips.ts b/frontend/src/components/tooltips/useSignModeTips.ts new file mode 100644 index 000000000..6b68565f1 --- /dev/null +++ b/frontend/src/components/tooltips/useSignModeTips.ts @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useSignModeTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("certSign.signMode.tooltip.header.title", "About PDF Signatures") + }, + tips: [ + { + title: t("certSign.signMode.tooltip.overview.title", "How signatures work"), + description: t("certSign.signMode.tooltip.overview.text", "Both modes seal the document (any edits are flagged as tampering) and record who/when/how for auditing. Viewer trust depends on the certificate chain.") + }, + { + title: t("certSign.signMode.tooltip.manual.title", "Manual - Bring your certificate"), + description: t("certSign.signMode.tooltip.manual.text", "Use your own certificate files for brand-aligned identity. Can display Trusted when your CA/chain is recognized."), + bullets: [ + t("certSign.signMode.tooltip.manual.use", "Use for: customer-facing, legal, compliance.") + ] + }, + { + title: t("certSign.signMode.tooltip.auto.title", "Auto - Zero-setup, instant system seal"), + description: t("certSign.signMode.tooltip.auto.text", "Signs with a server self-signed certificate. Same tamper-evident seal and audit trail; typically shows Unverified in viewers."), + bullets: [ + t("certSign.signMode.tooltip.auto.use", "Use when: you need speed and consistent internal identity across reviews and records.") + ] + }, + { + title: t("certSign.signMode.tooltip.rule.title", "Rule of thumb"), + description: t("certSign.signMode.tooltip.rule.text", "Need recipient Trusted status? Manual. Need a fast, tamper-evident seal and audit trail with no setup? Auto.") + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/components/tooltips/useSignatureAppearanceTips.ts b/frontend/src/components/tooltips/useSignatureAppearanceTips.ts new file mode 100644 index 000000000..79dc3f65a --- /dev/null +++ b/frontend/src/components/tooltips/useSignatureAppearanceTips.ts @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useSignatureAppearanceTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("certSign.appearance.tooltip.header.title", "About Signature Appearance") + }, + tips: [ + { + title: t("certSign.appearance.tooltip.invisible.title", "Invisible Signatures"), + description: t("certSign.appearance.tooltip.invisible.text", "The signature is added to the PDF for security but won't be visible when viewing the document. Perfect for legal requirements without changing the document's appearance."), + bullets: [ + t("certSign.appearance.tooltip.invisible.bullet1", "Provides security without visual changes"), + t("certSign.appearance.tooltip.invisible.bullet2", "Meets legal requirements for digital signing"), + t("certSign.appearance.tooltip.invisible.bullet3", "Doesn't affect document layout or design") + ] + }, + { + title: t("certSign.appearance.tooltip.visible.title", "Visible Signatures"), + description: t("certSign.appearance.tooltip.visible.text", "Shows a signature block on the PDF with your name, date, and optional details. Useful when you want readers to clearly see the document is signed."), + bullets: [ + t("certSign.appearance.tooltip.visible.bullet1", "Shows signer name and date on the document"), + t("certSign.appearance.tooltip.visible.bullet2", "Can include reason and location for signing"), + t("certSign.appearance.tooltip.visible.bullet3", "Choose which page to place the signature"), + t("certSign.appearance.tooltip.visible.bullet4", "Optional logo can be included") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index be6d2b0a7..76cf9e7bd 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -19,6 +19,8 @@ import AutoRename from "../tools/AutoRename"; import SingleLargePage from "../tools/SingleLargePage"; import UnlockPdfForms from "../tools/UnlockPdfForms"; import RemoveCertificateSign from "../tools/RemoveCertificateSign"; +import CertSign from "../tools/CertSign"; +import BookletImposition from "../tools/BookletImposition"; import Flatten from "../tools/Flatten"; import Rotate from "../tools/Rotate"; import ChangeMetadata from "../tools/ChangeMetadata"; @@ -36,6 +38,8 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation"; import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation"; import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation"; import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation"; +import { certSignOperationConfig } from "../hooks/tools/certSign/useCertSignOperation"; +import { bookletImpositionOperationConfig } from "../hooks/tools/bookletImposition/useBookletImpositionOperation"; import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation'; import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation"; import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation"; @@ -54,6 +58,8 @@ import AddWatermarkSingleStepSettings from "../components/tools/addWatermark/Add import OCRSettings from "../components/tools/ocr/OCRSettings"; import ConvertSettings from "../components/tools/convert/ConvertSettings"; import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings"; +import CertificateTypeSettings from "../components/tools/certSign/CertificateTypeSettings"; +import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings"; import FlattenSettings from "../components/tools/flatten/FlattenSettings"; import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings"; import RotateSettings from "../components/tools/rotate/RotateSettings"; @@ -159,11 +165,15 @@ export function useFlatToolRegistry(): ToolRegistry { certSign: { icon: , - name: t("home.certSign.title", "Sign with Certificate"), - component: null, - description: t("home.certSign.desc", "Signs a PDF with a Certificate/Key (PEM/P12)"), + name: t("home.certSign.title", "Certificate Sign"), + component: CertSign, + description: t("home.certSign.desc", "Sign PDF documents using digital certificates"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.SIGNING, + maxFiles: -1, + endpoints: ["cert-sign"], + operationConfig: certSignOperationConfig, + settingsComponent: CertificateTypeSettings, }, sign: { icon: , @@ -267,8 +277,6 @@ export function useFlatToolRegistry(): ToolRegistry { operationConfig: changePermissionsOperationConfig, settingsComponent: ChangePermissionsSettings, }, - // Verification - getPdfInfo: { icon: , name: t("home.getPdfInfo.title", "Get ALL Info on PDF"), @@ -390,7 +398,18 @@ export function useFlatToolRegistry(): ToolRegistry { categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, }, + bookletImposition: { + icon: , + name: t("home.bookletImposition.title", "Booklet Imposition"), + component: BookletImposition, + operationConfig: bookletImpositionOperationConfig, + settingsComponent: BookletImpositionSettings, + description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"), + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.PAGE_FORMATTING, + }, pdfToSinglePage: { + icon: , name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"), component: SingleLargePage, diff --git a/frontend/src/hooks/tools/bookletImposition/useBookletImpositionOperation.ts b/frontend/src/hooks/tools/bookletImposition/useBookletImpositionOperation.ts new file mode 100644 index 000000000..5daed63c3 --- /dev/null +++ b/frontend/src/hooks/tools/bookletImposition/useBookletImpositionOperation.ts @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation, ToolType } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { BookletImpositionParameters, defaultParameters } from './useBookletImpositionParameters'; + +// Static configuration that can be used by both the hook and automation executor +export const buildBookletImpositionFormData = (parameters: BookletImpositionParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("pagesPerSheet", parameters.pagesPerSheet.toString()); + formData.append("addBorder", parameters.addBorder.toString()); + formData.append("spineLocation", parameters.spineLocation); + formData.append("addGutter", parameters.addGutter.toString()); + formData.append("gutterSize", parameters.gutterSize.toString()); + formData.append("doubleSided", parameters.doubleSided.toString()); + formData.append("duplexPass", parameters.duplexPass); + formData.append("flipOnShortEdge", parameters.flipOnShortEdge.toString()); + return formData; +}; + +// Static configuration object +export const bookletImpositionOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildBookletImpositionFormData, + operationType: 'bookletImposition', + endpoint: '/api/v1/general/booklet-imposition', + defaultParameters, +} as const; + +export const useBookletImpositionOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...bookletImpositionOperationConfig, + getErrorMessage: createStandardErrorHandler(t('bookletImposition.error.failed', 'An error occurred while creating the booklet imposition.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/bookletImposition/useBookletImpositionParameters.ts b/frontend/src/hooks/tools/bookletImposition/useBookletImpositionParameters.ts new file mode 100644 index 000000000..fd794379b --- /dev/null +++ b/frontend/src/hooks/tools/bookletImposition/useBookletImpositionParameters.ts @@ -0,0 +1,36 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface BookletImpositionParameters extends BaseParameters { + pagesPerSheet: 2; + addBorder: boolean; + spineLocation: 'LEFT' | 'RIGHT'; + addGutter: boolean; + gutterSize: number; + doubleSided: boolean; + duplexPass: 'BOTH' | 'FIRST' | 'SECOND'; + flipOnShortEdge: boolean; +} + +export const defaultParameters: BookletImpositionParameters = { + pagesPerSheet: 2, + addBorder: false, + spineLocation: 'LEFT', + addGutter: false, + gutterSize: 12, + doubleSided: true, + duplexPass: 'BOTH', + flipOnShortEdge: false, +}; + +export type BookletImpositionParametersHook = BaseParametersHook; + +export const useBookletImpositionParameters = (): BookletImpositionParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'booklet-imposition', + validateFn: (params) => { + return params.pagesPerSheet === 2; + }, + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/certSign/useCertSignOperation.ts b/frontend/src/hooks/tools/certSign/useCertSignOperation.ts new file mode 100644 index 000000000..0422e7d56 --- /dev/null +++ b/frontend/src/hooks/tools/certSign/useCertSignOperation.ts @@ -0,0 +1,71 @@ +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { CertSignParameters, defaultParameters } from './useCertSignParameters'; + +// Build form data for signing +export const buildCertSignFormData = (parameters: CertSignParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + + // Handle sign mode + if (parameters.signMode === 'AUTO') { + formData.append('certType', 'SERVER'); + } else { + formData.append('certType', parameters.certType); + formData.append('password', parameters.password); + + // Add certificate files based on type (only for manual mode) + switch (parameters.certType) { + case 'PEM': + if (parameters.privateKeyFile) { + formData.append('privateKeyFile', parameters.privateKeyFile); + } + if (parameters.certFile) { + formData.append('certFile', parameters.certFile); + } + break; + case 'PKCS12': + if (parameters.p12File) { + formData.append('p12File', parameters.p12File); + } + break; + case 'JKS': + if (parameters.jksFile) { + formData.append('jksFile', parameters.jksFile); + } + break; + } + } + + // Add signature appearance options if enabled + if (parameters.showSignature) { + formData.append('showSignature', 'true'); + formData.append('reason', parameters.reason); + formData.append('location', parameters.location); + formData.append('name', parameters.name); + formData.append('pageNumber', parameters.pageNumber.toString()); + formData.append('showLogo', parameters.showLogo.toString()); + } + + return formData; +}; + +// Static configuration object +export const certSignOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildCertSignFormData, + operationType: 'certSign', + endpoint: '/api/v1/security/cert-sign', + multiFileEndpoint: false, + defaultParameters, +} as const; + +export const useCertSignOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...certSignOperationConfig, + getErrorMessage: createStandardErrorHandler(t('certSign.error.failed', 'An error occurred while processing signatures.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/certSign/useCertSignParameters.ts b/frontend/src/hooks/tools/certSign/useCertSignParameters.ts new file mode 100644 index 000000000..aa49cb31c --- /dev/null +++ b/frontend/src/hooks/tools/certSign/useCertSignParameters.ts @@ -0,0 +1,67 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface CertSignParameters extends BaseParameters { + // Sign mode selection + signMode: 'MANUAL' | 'AUTO'; + // Certificate signing options (only for manual mode) + certType: '' | 'PEM' | 'PKCS12' | 'PFX' | 'JKS'; + privateKeyFile?: File; + certFile?: File; + p12File?: File; + jksFile?: File; + password: string; + + // Signature appearance options + showSignature: boolean; + reason: string; + location: string; + name: string; + pageNumber: number; + showLogo: boolean; +} + +export const defaultParameters: CertSignParameters = { + signMode: 'MANUAL', + certType: '', + password: '', + showSignature: false, + reason: '', + location: '', + name: '', + pageNumber: 1, + showLogo: true, +}; + +export type CertSignParametersHook = BaseParametersHook; + +export const useCertSignParameters = (): CertSignParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'cert-sign', + validateFn: (params) => { + // Auto mode (server certificate) - no additional validation needed + if (params.signMode === 'AUTO') { + return true; + } + + // Manual mode - requires certificate type and files + if (!params.certType) { + return false; + } + + // Check for required files based on cert type + switch (params.certType) { + case 'PEM': + return !!(params.privateKeyFile && params.certFile); + case 'PKCS12': + case 'PFX': + return !!params.p12File; + case 'JKS': + return !!params.jksFile; + default: + return false; + } + }, + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/useAppConfig.ts b/frontend/src/hooks/useAppConfig.ts index 899038f51..cb23cfb60 100644 --- a/frontend/src/hooks/useAppConfig.ts +++ b/frontend/src/hooks/useAppConfig.ts @@ -23,6 +23,7 @@ export interface AppConfig { license?: string; GoogleDriveEnabled?: boolean; SSOAutoLogin?: boolean; + serverCertificateEnabled?: boolean; error?: string; } diff --git a/frontend/src/services/signatureDetectionService.ts b/frontend/src/services/signatureDetectionService.ts new file mode 100644 index 000000000..44a12be92 --- /dev/null +++ b/frontend/src/services/signatureDetectionService.ts @@ -0,0 +1,140 @@ +/** + * Service for detecting signatures in PDF files using PDF.js + * This provides a quick client-side check to determine if a PDF contains signatures + * without needing to make API calls + */ + +// PDF.js types (simplified) +declare global { + interface Window { + pdfjsLib?: any; + } +} + +export interface SignatureDetectionResult { + hasSignatures: boolean; + signatureCount?: number; + error?: string; +} + +export interface FileSignatureStatus { + file: File; + result: SignatureDetectionResult; +} + +/** + * Detect signatures in a single PDF file using PDF.js + */ +const detectSignaturesInFile = async (file: File): Promise => { + try { + // Ensure PDF.js is available + if (!window.pdfjsLib) { + return { + hasSignatures: false, + error: 'PDF.js not available' + }; + } + + // Convert file to ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + // Load the PDF document + const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise; + + let totalSignatures = 0; + + // Check each page for signature annotations + for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { + const page = await pdf.getPage(pageNum); + const annotations = await page.getAnnotations(); + + // Count signature annotations (Type: /Sig) + const signatureAnnotations = annotations.filter((annotation: any) => + annotation.subtype === 'Widget' && + annotation.fieldType === 'Sig' + ); + + totalSignatures += signatureAnnotations.length; + } + + // Also check for document-level signatures in AcroForm + const metadata = await pdf.getMetadata(); + if (metadata?.info?.Signature || metadata?.metadata?.has('dc:signature')) { + totalSignatures = Math.max(totalSignatures, 1); + } + + // Clean up PDF.js document + pdf.destroy(); + + return { + hasSignatures: totalSignatures > 0, + signatureCount: totalSignatures + }; + + } catch (error) { + console.warn('PDF signature detection failed:', error); + return { + hasSignatures: false, + signatureCount: 0, + error: error instanceof Error ? error.message : 'Detection failed' + }; + } +}; + +/** + * Detect if PDF files contain signatures using PDF.js client-side processing + */ +export const detectSignaturesInFiles = async (files: File[]): Promise => { + const results: FileSignatureStatus[] = []; + + for (const file of files) { + const result = await detectSignaturesInFile(file); + results.push({ file, result }); + } + + return results; +}; + +/** + * Hook for managing signature detection state + */ +export const useSignatureDetection = () => { + const [detectionResults, setDetectionResults] = React.useState([]); + const [isDetecting, setIsDetecting] = React.useState(false); + + const detectSignatures = async (files: File[]) => { + if (files.length === 0) { + setDetectionResults([]); + return; + } + + setIsDetecting(true); + try { + const results = await detectSignaturesInFiles(files); + setDetectionResults(results); + } finally { + setIsDetecting(false); + } + }; + + const getFileSignatureStatus = (file: File): SignatureDetectionResult | null => { + const result = detectionResults.find(r => r.file === file); + return result ? result.result : null; + }; + + const hasAnySignatures = detectionResults.some(r => r.result.hasSignatures); + const totalSignatures = detectionResults.reduce((sum, r) => sum + (r.result.signatureCount || 0), 0); + + return { + detectionResults, + isDetecting, + detectSignatures, + getFileSignatureStatus, + hasAnySignatures, + totalSignatures, + reset: () => setDetectionResults([]) + }; +}; + +// Import React for the hook +import React from 'react'; \ No newline at end of file diff --git a/frontend/src/tools/BookletImposition.tsx b/frontend/src/tools/BookletImposition.tsx new file mode 100644 index 000000000..de3821710 --- /dev/null +++ b/frontend/src/tools/BookletImposition.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings"; +import { useBookletImpositionParameters } from "../hooks/tools/bookletImposition/useBookletImpositionParameters"; +import { useBookletImpositionOperation } from "../hooks/tools/bookletImposition/useBookletImpositionOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { useBookletImpositionTips } from "../components/tooltips/useBookletImpositionTips"; +import { BaseToolProps, ToolComponent } from "../types/tool"; + +const BookletImposition = (props: BaseToolProps) => { + const { t } = useTranslation(); + + const base = useBaseTool( + 'bookletImposition', + useBookletImpositionParameters, + useBookletImpositionOperation, + props + ); + + const bookletTips = useBookletImpositionTips(); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: "Settings", + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: bookletTips, + content: ( + + ), + }, + ], + executeButton: { + text: t("bookletImposition.submit", "Create Booklet"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("bookletImposition.title", "Booklet Imposition Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default BookletImposition as ToolComponent; \ No newline at end of file diff --git a/frontend/src/tools/CertSign.tsx b/frontend/src/tools/CertSign.tsx new file mode 100644 index 000000000..fd8dabc9c --- /dev/null +++ b/frontend/src/tools/CertSign.tsx @@ -0,0 +1,131 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import CertificateTypeSettings from "../components/tools/certSign/CertificateTypeSettings"; +import CertificateFormatSettings from "../components/tools/certSign/CertificateFormatSettings"; +import CertificateFilesSettings from "../components/tools/certSign/CertificateFilesSettings"; +import SignatureAppearanceSettings from "../components/tools/certSign/SignatureAppearanceSettings"; +import { useCertSignParameters } from "../hooks/tools/certSign/useCertSignParameters"; +import { useCertSignOperation } from "../hooks/tools/certSign/useCertSignOperation"; +import { useCertificateTypeTips } from "../components/tooltips/useCertificateTypeTips"; +import { useSignatureAppearanceTips } from "../components/tooltips/useSignatureAppearanceTips"; +import { useSignModeTips } from "../components/tooltips/useSignModeTips"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; + +const CertSign = (props: BaseToolProps) => { + const { t } = useTranslation(); + + const base = useBaseTool( + 'certSign', + useCertSignParameters, + useCertSignOperation, + props + ); + + const certTypeTips = useCertificateTypeTips(); + const appearanceTips = useSignatureAppearanceTips(); + const signModeTips = useSignModeTips(); + + // Check if certificate files are configured for appearance step + const areCertFilesConfigured = () => { + const params = base.params.parameters; + + // Auto mode (server certificate) - always configured + if (params.signMode === 'AUTO') { + return true; + } + + // Manual mode - check for required files based on cert type + switch (params.certType) { + case 'PEM': + return !!(params.privateKeyFile && params.certFile); + case 'PKCS12': + case 'PFX': + return !!params.p12File; + case 'JKS': + return !!params.jksFile; + default: + return false; + } + }; + + return createToolFlow({ + forceStepNumbers: true, + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: t("certSign.signMode.stepTitle", "Sign Mode"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: signModeTips, + content: ( + + ), + }, + ...(base.params.parameters.signMode === 'MANUAL' ? [{ + title: t("certSign.certTypeStep.stepTitle", "Certificate Format"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: certTypeTips, + content: ( + + ), + }] : []), + ...(base.params.parameters.signMode === 'MANUAL' ? [{ + title: t("certSign.certFiles.stepTitle", "Certificate Files"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + content: ( + + ), + }] : []), + { + title: t("certSign.appearance.stepTitle", "Signature Appearance"), + isCollapsed: base.settingsCollapsed || !areCertFilesConfigured(), + onCollapsedClick: (base.settingsCollapsed || !areCertFilesConfigured()) ? base.handleSettingsReset : undefined, + tooltip: appearanceTips, + content: ( + + ), + }, + ], + executeButton: { + text: t("certSign.sign.submit", "Sign PDF"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("certSign.sign.results", "Signed PDF"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +// Static method to get the operation hook for automation +CertSign.tool = () => useCertSignOperation; + +export default CertSign as ToolComponent; \ No newline at end of file diff --git a/frontend/src/types/toolId.ts b/frontend/src/types/toolId.ts index 1877297e0..1a5fb1d4f 100644 --- a/frontend/src/types/toolId.ts +++ b/frontend/src/types/toolId.ts @@ -55,6 +55,7 @@ const TOOL_IDS = [ 'devFolderScanning', 'devSsoGuide', 'devAirgapped', + 'bookletImposition', ] as const; // Tool identity - what PDF operation we're performing (type-safe) diff --git a/frontend/src/utils/urlMapping.ts b/frontend/src/utils/urlMapping.ts index 2fab4282d..0c271952c 100644 --- a/frontend/src/utils/urlMapping.ts +++ b/frontend/src/utils/urlMapping.ts @@ -31,4 +31,7 @@ export const URL_TO_TOOL_MAP: Record = { '/unlock-pdf-forms': 'unlockPDFForms', '/remove-certificate-sign': 'removeCertSign', '/remove-cert-sign': 'removeCertSign', + '/cert-sign': 'certSign', + '/manage-signatures': 'certSign', + '/booklet-imposition': 'bookletImposition', };