booklet and server sign

This commit is contained in:
Anthony Stirling 2025-09-03 22:57:58 +01:00
parent 6a09ec6091
commit e00baeb033
25 changed files with 1198 additions and 74 deletions

View File

@ -237,6 +237,7 @@ public class EndpointConfiguration {
addEndpointToGroup("PageOps", "pdf-organizer"); addEndpointToGroup("PageOps", "pdf-organizer");
addEndpointToGroup("PageOps", "rotate-pdf"); addEndpointToGroup("PageOps", "rotate-pdf");
addEndpointToGroup("PageOps", "multi-page-layout"); addEndpointToGroup("PageOps", "multi-page-layout");
addEndpointToGroup("PageOps", "booklet-imposition");
addEndpointToGroup("PageOps", "scale-pages"); addEndpointToGroup("PageOps", "scale-pages");
addEndpointToGroup("PageOps", "crop"); addEndpointToGroup("PageOps", "crop");
addEndpointToGroup("PageOps", "extract-page"); addEndpointToGroup("PageOps", "extract-page");
@ -366,6 +367,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "cert-sign"); addEndpointToGroup("Java", "cert-sign");
addEndpointToGroup("Java", "remove-cert-sign"); addEndpointToGroup("Java", "remove-cert-sign");
addEndpointToGroup("Java", "multi-page-layout"); addEndpointToGroup("Java", "multi-page-layout");
addEndpointToGroup("Java", "booklet-imposition");
addEndpointToGroup("Java", "scale-pages"); addEndpointToGroup("Java", "scale-pages");
addEndpointToGroup("Java", "add-page-numbers"); addEndpointToGroup("Java", "add-page-numbers");
addEndpointToGroup("Java", "auto-rename"); addEndpointToGroup("Java", "auto-rename");

View File

@ -0,0 +1,27 @@
package stirling.software.SPDF.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.SPDF.service.ServerCertificateService;
@Component
@RequiredArgsConstructor
@Slf4j
public class ServerCertificateInitializer {
private final ServerCertificateService serverCertificateService;
@EventListener(ApplicationReadyEvent.class)
public void initializeServerCertificate() {
try {
serverCertificateService.initializeServerCertificate();
} catch (Exception e) {
log.error("Failed to initialize server certificate", e);
}
}
}

View File

@ -0,0 +1,186 @@
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<byte[]> createBookletImposition(
@ModelAttribute BookletImpositionRequest request) throws IOException {
MultipartFile file = request.getFileInput();
String bookletType = request.getBookletType();
int pagesPerSheet = request.getPagesPerSheet();
boolean addBorder = Boolean.TRUE.equals(request.getAddBorder());
String pageOrientation = request.getPageOrientation();
// Validate pages per sheet for booklet
if (pagesPerSheet != 2 && pagesPerSheet != 4) {
throw new IllegalArgumentException("pagesPerSheet must be 2 or 4 for booklet imposition");
}
PDDocument sourceDocument = pdfDocumentFactory.load(file);
int totalPages = sourceDocument.getNumberOfPages();
// Step 1: Reorder pages for booklet (reusing logic from RearrangePagesPDFController)
List<Integer> bookletOrder = getBookletPageOrder(bookletType, totalPages);
// Step 2: Create new document with multi-page layout (reusing logic from MultiPageLayoutController)
PDDocument newDocument = createBookletWithLayout(sourceDocument, bookletOrder, pagesPerSheet, addBorder, pageOrientation);
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");
}
// Reused logic from RearrangePagesPDFController
private List<Integer> getBookletPageOrder(String bookletType, int totalPages) {
if ("SIDE_STITCH_BOOKLET".equals(bookletType)) {
return sideStitchBookletSort(totalPages);
} else {
return bookletSort(totalPages);
}
}
private List<Integer> bookletSort(int totalPages) {
List<Integer> newPageOrder = new ArrayList<>();
for (int i = 0; i < totalPages / 2; i++) {
newPageOrder.add(i);
newPageOrder.add(totalPages - i - 1);
}
return newPageOrder;
}
private List<Integer> sideStitchBookletSort(int totalPages) {
List<Integer> newPageOrder = new ArrayList<>();
for (int i = 0; i < (totalPages + 3) / 4; i++) {
int begin = i * 4;
newPageOrder.add(Math.min(begin + 3, totalPages - 1));
newPageOrder.add(Math.min(begin, totalPages - 1));
newPageOrder.add(Math.min(begin + 1, totalPages - 1));
newPageOrder.add(Math.min(begin + 2, totalPages - 1));
}
return newPageOrder;
}
// Reused and adapted logic from MultiPageLayoutController
private PDDocument createBookletWithLayout(PDDocument sourceDocument, List<Integer> pageOrder,
int pagesPerSheet, boolean addBorder, String pageOrientation) throws IOException {
PDDocument newDocument = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
int cols = pagesPerSheet == 2 ? 2 : 2; // 2x1 for 2 pages, 2x2 for 4 pages
int rows = pagesPerSheet == 2 ? 1 : 2;
int currentPageIndex = 0;
int totalOrderedPages = pageOrder.size();
while (currentPageIndex < totalOrderedPages) {
// Use landscape orientation for booklets (A4 landscape -> A5 portrait when folded)
PDRectangle pageSize = "LANDSCAPE".equals(pageOrientation) ?
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()) : PDRectangle.A4;
PDPage newPage = new PDPage(pageSize);
newDocument.addPage(newPage);
float cellWidth = newPage.getMediaBox().getWidth() / cols;
float cellHeight = newPage.getMediaBox().getHeight() / rows;
PDPageContentStream contentStream = new PDPageContentStream(
newDocument, newPage, PDPageContentStream.AppendMode.APPEND, true, true);
LayerUtility layerUtility = new LayerUtility(newDocument);
if (addBorder) {
contentStream.setLineWidth(1.5f);
contentStream.setStrokingColor(Color.BLACK);
}
// Place pages on the current sheet
for (int sheetPosition = 0; sheetPosition < pagesPerSheet && currentPageIndex < totalOrderedPages; sheetPosition++) {
int sourcePageIndex = pageOrder.get(currentPageIndex);
PDPage sourcePage = sourceDocument.getPage(sourcePageIndex);
PDRectangle rect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / rect.getWidth();
float scaleHeight = cellHeight / rect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
int rowIndex = sheetPosition / cols;
int colIndex = sheetPosition % cols;
float x = colIndex * cellWidth + (cellWidth - rect.getWidth() * scale) / 2;
float y = newPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight - (cellHeight - rect.getHeight() * scale) / 2);
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
contentStream.transform(Matrix.getScaleInstance(scale, scale));
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, sourcePageIndex);
contentStream.drawForm(formXObject);
contentStream.restoreGraphicsState();
if (addBorder) {
float borderX = colIndex * cellWidth;
float borderY = newPage.getMediaBox().getHeight() - (rowIndex + 1) * cellHeight;
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
contentStream.stroke();
}
currentPageIndex++;
}
contentStream.close();
}
return newDocument;
}
}

View File

@ -72,6 +72,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest; import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.service.ServerCertificateService;
import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ExceptionUtils;
@ -101,6 +102,7 @@ public class CertSignController {
} }
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ServerCertificateService serverCertificateService;
private static void sign( private static void sign(
CustomPDFDocumentFactory pdfDocumentFactory, CustomPDFDocumentFactory pdfDocumentFactory,
@ -175,6 +177,7 @@ public class CertSignController {
} }
KeyStore ks = null; KeyStore ks = null;
String keystorePassword = password;
switch (certType) { switch (certType) {
case "PEM": case "PEM":
@ -193,6 +196,19 @@ public class CertSignController {
ks = KeyStore.getInstance("JKS"); ks = KeyStore.getInstance("JKS");
ks.load(jksfile.getInputStream(), password.toCharArray()); ks.load(jksfile.getInputStream(), password.toCharArray());
break; break;
case "SERVER":
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: default:
throw ExceptionUtils.createIllegalArgumentException( throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "error.invalidArgument",
@ -200,7 +216,7 @@ public class CertSignController {
"certificate type: " + certType); "certificate type: " + certType);
} }
CreateSignature createSignature = new CreateSignature(ks, password.toCharArray()); CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
sign( sign(
pdfDocumentFactory, pdfDocumentFactory,

View File

@ -0,0 +1,145 @@
package stirling.software.SPDF.controller.api.security;
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.SPDF.service.ServerCertificateService;
@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 ServerCertificateService serverCertificateService;
@GetMapping("/info")
@Operation(
summary = "Get server certificate information",
description = "Returns information about the current server certificate")
public ResponseEntity<ServerCertificateService.ServerCertificateInfo>
getServerCertificateInfo() {
try {
ServerCertificateService.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<String> 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(e.getMessage());
} 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<String> 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<String> 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("/public-key")
@Operation(
summary = "Download server certificate public key",
description =
"Download the public key of the server certificate for validation purposes")
public ResponseEntity<byte[]> getServerCertificatePublicKey() {
try {
if (!serverCertificateService.hasServerCertificate()) {
return ResponseEntity.notFound().build();
}
byte[] publicKey = serverCertificateService.getServerCertificatePublicKey();
return ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"server-cert.crt\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(publicKey);
} catch (Exception e) {
log.error("Failed to get server certificate public key", 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<Boolean> isServerCertificateEnabled() {
return ResponseEntity.ok(serverCertificateService.isEnabled());
}
}

View File

@ -0,0 +1,40 @@
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 booklet type to create.",
type = "string",
defaultValue = "BOOKLET",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"BOOKLET", "SIDE_STITCH_BOOKLET"})
private String bookletType = "BOOKLET";
@Schema(
description = "The number of pages to fit onto a single sheet in the output PDF.",
type = "number",
defaultValue = "2",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"2", "4"})
private int pagesPerSheet = 2;
@Schema(description = "Boolean for if you wish to add border around the pages")
private Boolean addBorder = false;
@Schema(
description = "The page orientation for the output booklet sheets.",
type = "string",
defaultValue = "LANDSCAPE",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"LANDSCAPE", "PORTRAIT"})
private String pageOrientation = "LANDSCAPE";
}

View File

@ -15,7 +15,7 @@ public class SignPDFWithCertRequest extends PDFFile {
@Schema( @Schema(
description = "The type of the digital certificate", description = "The type of the digital certificate",
allowableValues = {"PEM", "PKCS12", "JKS"}, allowableValues = {"PEM", "PKCS12", "JKS", "SERVER"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String certType; private String certType;

View File

@ -0,0 +1,252 @@
package stirling.software.SPDF.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.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
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;
@Service
@Slf4j
public class ServerCertificateService {
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());
// 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());
}
}
public static class ServerCertificateInfo {
private final boolean exists;
private final String subject;
private final String issuer;
private final Date validFrom;
private final Date validTo;
public ServerCertificateInfo(
boolean exists, String subject, String issuer, Date validFrom, Date validTo) {
this.exists = exists;
this.subject = subject;
this.issuer = issuer;
this.validFrom = validFrom;
this.validTo = validTo;
}
public boolean isExists() {
return exists;
}
public String getSubject() {
return subject;
}
public String getIssuer() {
return issuer;
}
public Date getValidFrom() {
return validFrom;
}
public Date getValidTo() {
return validTo;
}
}
}

View File

@ -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 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) 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 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: html:
urlSecurity: urlSecurity:
enabled: true # Enable URL security restrictions for HTML processing enabled: true # Enable URL security restrictions for HTML processing

View File

@ -450,6 +450,10 @@
"title": "Sign with Certificate", "title": "Sign with Certificate",
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)" "desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
}, },
"manageSignatures": {
"title": "Manage Signatures",
"desc": "Sign PDFs with certificates using manual or server-managed keys"
},
"removeCertSign": { "removeCertSign": {
"title": "Remove Certificate Sign", "title": "Remove Certificate Sign",
"desc": "Remove certificate signature from PDF" "desc": "Remove certificate signature from PDF"
@ -458,6 +462,10 @@
"title": "Multi-Page Layout", "title": "Multi-Page Layout",
"desc": "Merge multiple pages of a PDF document into a single page" "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": { "scalePages": {
"title": "Adjust page size/scale", "title": "Adjust page size/scale",
"desc": "Change the size/scale of a page and/or its contents." "desc": "Change the size/scale of a page and/or its contents."
@ -1390,6 +1398,33 @@
"showLogo": "Show Logo", "showLogo": "Show Logo",
"submit": "Sign PDF" "submit": "Sign PDF"
}, },
"manageSignatures": {
"tags": "sign,certificate,PEM,PKCS12,JKS,server,manual,auto",
"title": "Manage Signatures",
"header": "Sign PDFs with Certificates",
"files": {
"placeholder": "Select PDF files to sign with certificates"
},
"signMode": {
"stepTitle": "Sign Mode"
},
"certType": {
"stepTitle": "Certificate Format"
},
"certFiles": {
"stepTitle": "Certificate Files"
},
"appearance": {
"stepTitle": "Signature Appearance"
},
"sign": {
"submit": "Sign PDF",
"results": "Signed PDF"
},
"error": {
"failed": "An error occurred whilst signing the PDF."
}
},
"removeCertSign": { "removeCertSign": {
"tags": "authenticate,PEM,P12,official,decrypt", "tags": "authenticate,PEM,P12,official,decrypt",
"title": "Remove Certificate Signature", "title": "Remove Certificate Signature",
@ -1416,6 +1451,18 @@
"addBorder": "Add Borders", "addBorder": "Add Borders",
"submit": "Submit" "submit": "Submit"
}, },
"bookletImposition": {
"tags": "booklet,imposition,printing,binding,folding,signature",
"title": "Booklet Imposition",
"header": "Booklet Imposition",
"submit": "Create Booklet",
"files": {
"placeholder": "Select PDF files to create booklet impositions from"
},
"error": {
"failed": "An error occurred while creating the booklet imposition."
}
},
"scalePages": { "scalePages": {
"tags": "resize,modify,dimension,adapt", "tags": "resize,modify,dimension,adapt",
"title": "Adjust page-scale", "title": "Adjust page-scale",

View File

@ -495,6 +495,10 @@
"title": "Multi-Page Layout", "title": "Multi-Page Layout",
"desc": "Merge multiple pages of a PDF document into a single page" "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": { "scalePages": {
"title": "Adjust page size/scale", "title": "Adjust page size/scale",
"desc": "Change the size/scale of a page and/or its contents." "desc": "Change the size/scale of a page and/or its contents."
@ -1097,6 +1101,18 @@
"addBorder": "Add Borders", "addBorder": "Add Borders",
"submit": "Submit" "submit": "Submit"
}, },
"bookletImposition": {
"tags": "booklet,imposition,printing,binding,folding,signature",
"title": "Booklet Imposition",
"header": "Booklet Imposition",
"submit": "Create Booklet",
"files": {
"placeholder": "Select PDF files to create booklet impositions from"
},
"error": {
"failed": "An error occurred while creating the booklet imposition."
}
},
"scalePages": { "scalePages": {
"tags": "resize,modify,dimension,adapt", "tags": "resize,modify,dimension,adapt",
"title": "Adjust page-scale", "title": "Adjust page-scale",

View File

@ -0,0 +1,149 @@
import React from "react";
import { Button, Stack, Text, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { BookletImpositionParameters } from "../../../hooks/tools/bookletImposition/useBookletImpositionParameters";
interface BookletImpositionSettingsProps {
parameters: BookletImpositionParameters;
onParameterChange: (key: keyof BookletImpositionParameters, value: any) => void;
disabled?: boolean;
}
const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = false }: BookletImpositionSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Divider ml='-md'></Divider>
{/* Booklet Type */}
<Stack gap="sm">
<Text size="sm" fw={500}>Booklet Type</Text>
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={parameters.bookletType === 'BOOKLET' ? 'filled' : 'outline'}
color={parameters.bookletType === 'BOOKLET' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('bookletType', 'BOOKLET')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
Standard<br />Booklet
</div>
</Button>
<Button
variant={parameters.bookletType === 'SIDE_STITCH_BOOKLET' ? 'filled' : 'outline'}
color={parameters.bookletType === 'SIDE_STITCH_BOOKLET' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('bookletType', 'SIDE_STITCH_BOOKLET')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
Side-Stitch<br />Booklet
</div>
</Button>
</div>
<Text size="xs" c="dimmed">
{parameters.bookletType === 'BOOKLET'
? "Standard booklet for saddle-stitched binding (fold in half)"
: "Side-stitched booklet for binding along the edge"}
</Text>
</Stack>
<Divider />
{/* Pages Per Sheet */}
<Stack gap="sm">
<Text size="sm" fw={500}>Pages Per Sheet</Text>
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={parameters.pagesPerSheet === 2 ? 'filled' : 'outline'}
color={parameters.pagesPerSheet === 2 ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('pagesPerSheet', 2)}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
2 Pages<br />Per Sheet
</div>
</Button>
<Button
variant={parameters.pagesPerSheet === 4 ? 'filled' : 'outline'}
color={parameters.pagesPerSheet === 4 ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('pagesPerSheet', 4)}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
4 Pages<br />Per Sheet
</div>
</Button>
</div>
<Text size="xs" c="dimmed">
{parameters.pagesPerSheet === 2
? "Two pages side by side on each sheet (most common)"
: "Four pages arranged in a 2x2 grid on each sheet"}
</Text>
</Stack>
<Divider />
{/* Page Orientation */}
<Stack gap="sm">
<Text size="sm" fw={500}>Page Orientation</Text>
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={parameters.pageOrientation === 'LANDSCAPE' ? 'filled' : 'outline'}
color={parameters.pageOrientation === 'LANDSCAPE' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('pageOrientation', 'LANDSCAPE')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
Landscape<br />(Recommended)
</div>
</Button>
<Button
variant={parameters.pageOrientation === 'PORTRAIT' ? 'filled' : 'outline'}
color={parameters.pageOrientation === 'PORTRAIT' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('pageOrientation', 'PORTRAIT')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
Portrait
</div>
</Button>
</div>
<Text size="xs" c="dimmed">
{parameters.pageOrientation === 'LANDSCAPE'
? "A4 landscape → A5 portrait when folded (standard booklet size)"
: "A4 portrait → A6 when folded (smaller booklet)"}
</Text>
</Stack>
<Divider />
{/* Add Border Option */}
<Stack gap="sm">
<label
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
title="Adds borders around each page section to help with cutting and alignment"
>
<input
type="checkbox"
checked={parameters.addBorder}
onChange={(e) => onParameterChange('addBorder', e.target.checked)}
disabled={disabled}
/>
<Text size="sm">Add borders around pages</Text>
</label>
<Text size="xs" c="dimmed" style={{ marginLeft: '24px' }}>
Helpful for cutting and alignment when printing
</Text>
</Stack>
</Stack>
);
};
export default BookletImpositionSettings;

View File

@ -56,6 +56,12 @@ const CertificateFilesSettings = ({ parameters, onParameterChange, disabled = fa
/> />
)} )}
{parameters.certType === 'SERVER' && (
<Text c="dimmed" size="sm">
{t('manageSignatures.signing.serverCertMessage', 'Using server certificate - no files or password required')}
</Text>
)}
{/* Password - only show when files are uploaded */} {/* Password - only show when files are uploaded */}
{parameters.certType && ( {parameters.certType && (
(parameters.certType === 'PEM' && parameters.privateKeyFile && parameters.certFile) || (parameters.certType === 'PEM' && parameters.privateKeyFile && parameters.certFile) ||

View File

@ -0,0 +1,67 @@
import { Stack, Button, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ManageSignaturesParameters } from "../../../hooks/tools/manageSignatures/useManageSignaturesParameters";
interface CertificateFormatSettingsProps {
parameters: ManageSignaturesParameters;
onParameterChange: (key: keyof ManageSignaturesParameters, value: any) => void;
disabled?: boolean;
}
const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = false }: CertificateFormatSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{/* First row - PKCS#12 and PEM */}
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={parameters.certType === 'PKCS12' ? 'filled' : 'outline'}
color={parameters.certType === 'PKCS12' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('certType', 'PKCS12')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
PKCS#12<br />(Single file)
</div>
</Button>
<Button
variant={parameters.certType === 'PEM' ? 'filled' : 'outline'}
color={parameters.certType === 'PEM' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('certType', 'PEM')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
PEM<br />(Key + Cert files)
</div>
</Button>
</div>
{/* Second row - JKS spanning full width */}
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={parameters.certType === 'JKS' ? 'filled' : 'outline'}
color={parameters.certType === 'JKS' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('certType', 'JKS')}
disabled={disabled}
style={{ width: '100%', height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
JKS<br />(Java KeyStore)
</div>
</Button>
</div>
</div>
<Text size="xs" c="dimmed">
{parameters.certType === 'PKCS12' && "Upload a single .p12/.pfx file containing both certificate and private key"}
{parameters.certType === 'PEM' && "Upload separate certificate (.crt/.pem) and private key (.key/.pem) files"}
{parameters.certType === 'JKS' && "Upload a Java KeyStore (.jks) file"}
{!parameters.certType && "Choose the format of your certificate files"}
</Text>
</Stack>
);
};
export default CertificateFormatSettings;

View File

@ -1,4 +1,4 @@
import { Stack, Button } from "@mantine/core"; import { Stack, Button, Text, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ManageSignaturesParameters } from "../../../hooks/tools/manageSignatures/useManageSignaturesParameters"; import { ManageSignaturesParameters } from "../../../hooks/tools/manageSignatures/useManageSignaturesParameters";
@ -13,46 +13,45 @@ const CertificateTypeSettings = ({ parameters, onParameterChange, disabled = fal
return ( return (
<Stack gap="md"> <Stack gap="md">
{/* Certificate Type Selection */} <div style={{ display: 'flex', gap: '4px' }}>
<Stack gap="sm"> <Button
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}> variant={parameters.signMode === 'MANUAL' ? 'filled' : 'outline'}
<div style={{ display: 'flex', gap: '4px' }}> color={parameters.signMode === 'MANUAL' ? 'blue' : 'var(--text-muted)'}
<Button onClick={() => {
variant={parameters.certType === 'PKCS12' ? 'filled' : 'outline'} onParameterChange('signMode', 'MANUAL');
color={parameters.certType === 'PKCS12' ? 'blue' : 'var(--text-muted)'} // Reset cert type when switching to manual
onClick={() => onParameterChange('certType', 'PKCS12')} if (parameters.signMode === 'AUTO') {
disabled={disabled} onParameterChange('certType', '');
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} }
> }}
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}> disabled={disabled}
PKCS#12 style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
</div> >
</Button> <div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
<Button Manual<br />(Provide Files)
variant={parameters.certType === 'PEM' ? 'filled' : 'outline'}
color={parameters.certType === 'PEM' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('certType', 'PEM')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
PEM
</div>
</Button>
</div> </div>
<Button </Button>
variant={parameters.certType === 'JKS' ? 'filled' : 'outline'} <Button
color={parameters.certType === 'JKS' ? 'blue' : 'var(--text-muted)'} variant={parameters.signMode === 'AUTO' ? 'filled' : 'outline'}
onClick={() => onParameterChange('certType', 'JKS')} color={parameters.signMode === 'AUTO' ? 'green' : 'var(--text-muted)'}
disabled={disabled} onClick={() => {
style={{ width: '100%', height: 'auto', minHeight: '40px', fontSize: '11px' }} onParameterChange('signMode', 'AUTO');
> // Clear cert type and files when switching to auto
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}> onParameterChange('certType', '');
JKS }}
</div> disabled={disabled}
</Button> style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
</div> >
</Stack> <div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
Auto<br />(Server Certificate)
</div>
</Button>
</div>
<Text size="xs" c="dimmed">
{parameters.signMode === 'MANUAL'
? "Upload your own certificate files for signing"
: "Use the server's pre-configured certificate"}
</Text>
</Stack> </Stack>
); );
}; };

View File

@ -19,7 +19,8 @@ export const useCertificateTypeTips = (): TooltipContent => {
bullets: [ bullets: [
t("manageSignatures.certType.tooltip.which.bullet1", "PKCS#12 (.p12 / .pfx) one combined file (most common)"), t("manageSignatures.certType.tooltip.which.bullet1", "PKCS#12 (.p12 / .pfx) one combined file (most common)"),
t("manageSignatures.certType.tooltip.which.bullet2", "PEM separate private-key and certificate .pem files"), t("manageSignatures.certType.tooltip.which.bullet2", "PEM separate private-key and certificate .pem files"),
t("manageSignatures.certType.tooltip.which.bullet3", "JKS Java .jks keystore for dev / CI-CD workflows") t("manageSignatures.certType.tooltip.which.bullet3", "JKS Java .jks keystore for dev / CI-CD workflows"),
t("manageSignatures.certType.tooltip.which.bullet4", "SERVER use server's certificate (no files needed)")
] ]
}, },
{ {

View File

@ -33,10 +33,11 @@ export const useManageSignaturesTooltips = (): TooltipContent => {
title: t("manageSignatures.tooltip.signing.title", "Adding Signatures"), title: t("manageSignatures.tooltip.signing.title", "Adding Signatures"),
description: t("manageSignatures.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."), description: t("manageSignatures.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: [ bullets: [
t("manageSignatures.tooltip.signing.bullet1", "Supports PEM, PKCS12, and JKS certificate formats"), t("manageSignatures.tooltip.signing.bullet1", "Supports PEM, PKCS12, JKS, and server certificate formats"),
t("manageSignatures.tooltip.signing.bullet2", "Option to show or hide signature on the PDF"), t("manageSignatures.tooltip.signing.bullet2", "Option to show or hide signature on the PDF"),
t("manageSignatures.tooltip.signing.bullet3", "Add reason, location, and signer name"), t("manageSignatures.tooltip.signing.bullet3", "Add reason, location, and signer name"),
t("manageSignatures.tooltip.signing.bullet4", "Choose which page to place visible signatures") t("manageSignatures.tooltip.signing.bullet4", "Choose which page to place visible signatures"),
t("manageSignatures.tooltip.signing.bullet5", "Use server certificate for simple 'Sign with Stirling-PDF' option")
] ]
} }
] ]

View File

@ -16,6 +16,7 @@ import SingleLargePage from "../tools/SingleLargePage";
import UnlockPdfForms from "../tools/UnlockPdfForms"; import UnlockPdfForms from "../tools/UnlockPdfForms";
import RemoveCertificateSign from "../tools/RemoveCertificateSign"; import RemoveCertificateSign from "../tools/RemoveCertificateSign";
import ManageSignatures from "../tools/ManageSignatures"; import ManageSignatures from "../tools/ManageSignatures";
import BookletImposition from "../tools/BookletImposition";
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation"; import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation"; import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation"; import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
@ -30,6 +31,7 @@ import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperati
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation"; import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation"; import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
import { manageSignaturesOperationConfig } from "../hooks/tools/manageSignatures/useManageSignaturesOperation"; import { manageSignaturesOperationConfig } from "../hooks/tools/manageSignatures/useManageSignaturesOperation";
import { bookletImpositionOperationConfig } from "../hooks/tools/bookletImposition/useBookletImpositionOperation";
import CompressSettings from "../components/tools/compress/CompressSettings"; import CompressSettings from "../components/tools/compress/CompressSettings";
import SplitSettings from "../components/tools/split/SplitSettings"; import SplitSettings from "../components/tools/split/SplitSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings"; import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
@ -42,6 +44,7 @@ import OCRSettings from "../components/tools/ocr/OCRSettings";
import ConvertSettings from "../components/tools/convert/ConvertSettings"; import ConvertSettings from "../components/tools/convert/ConvertSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings"; import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import CertificateTypeSettings from "../components/tools/manageSignatures/CertificateTypeSettings"; import CertificateTypeSettings from "../components/tools/manageSignatures/CertificateTypeSettings";
import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings";
import { ToolId } from "../types/toolId"; import { ToolId } from "../types/toolId";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -353,6 +356,16 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING, subcategoryId: SubcategoryId.PAGE_FORMATTING,
}, },
"booklet-imposition": {
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
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,
},
"single-large-page": { "single-large-page": {
icon: <LocalIcon icon="looks-one-outline-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="looks-one-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"), name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"),

View File

@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig, 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("bookletType", parameters.bookletType);
formData.append("pagesPerSheet", parameters.pagesPerSheet.toString());
formData.append("addBorder", parameters.addBorder.toString());
formData.append("pageOrientation", parameters.pageOrientation);
return formData;
};
// Static configuration object
export const bookletImpositionOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildBookletImpositionFormData,
operationType: 'bookletImposition',
endpoint: '/api/v1/general/booklet-imposition',
filePrefix: 'booklet_',
defaultParameters,
} as const;
export const useBookletImpositionOperation = () => {
const { t } = useTranslation();
return useToolOperation<BookletImpositionParameters>({
...bookletImpositionOperationConfig,
getErrorMessage: createStandardErrorHandler(t('bookletImposition.error.failed', 'An error occurred while creating the booklet imposition.'))
});
};

View File

@ -0,0 +1,28 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface BookletImpositionParameters extends BaseParameters {
bookletType: 'BOOKLET' | 'SIDE_STITCH_BOOKLET';
pagesPerSheet: 2 | 4;
addBorder: boolean;
pageOrientation: 'LANDSCAPE' | 'PORTRAIT';
}
export const defaultParameters: BookletImpositionParameters = {
bookletType: 'BOOKLET',
pagesPerSheet: 2,
addBorder: false,
pageOrientation: 'LANDSCAPE',
};
export type BookletImpositionParametersHook = BaseParametersHook<BookletImpositionParameters>;
export const useBookletImpositionParameters = (): BookletImpositionParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'booklet-imposition',
validateFn: (params) => {
return params.pagesPerSheet === 2 || params.pagesPerSheet === 4;
},
});
};

View File

@ -7,29 +7,35 @@ import { ManageSignaturesParameters, defaultParameters } from './useManageSignat
export const buildManageSignaturesFormData = (parameters: ManageSignaturesParameters, file: File): FormData => { export const buildManageSignaturesFormData = (parameters: ManageSignaturesParameters, file: File): FormData => {
const formData = new FormData(); const formData = new FormData();
formData.append('fileInput', file); formData.append('fileInput', file);
formData.append('certType', parameters.certType);
formData.append('password', parameters.password);
// Add certificate files based on type // Handle sign mode
switch (parameters.certType) { if (parameters.signMode === 'AUTO') {
case 'PEM': formData.append('certType', 'SERVER');
if (parameters.privateKeyFile) { } else {
formData.append('privateKeyFile', parameters.privateKeyFile); formData.append('certType', parameters.certType);
} formData.append('password', parameters.password);
if (parameters.certFile) {
formData.append('certFile', parameters.certFile); // Add certificate files based on type (only for manual mode)
} switch (parameters.certType) {
break; case 'PEM':
case 'PKCS12': if (parameters.privateKeyFile) {
if (parameters.p12File) { formData.append('privateKeyFile', parameters.privateKeyFile);
formData.append('p12File', parameters.p12File); }
} if (parameters.certFile) {
break; formData.append('certFile', parameters.certFile);
case 'JKS': }
if (parameters.jksFile) { break;
formData.append('jksFile', parameters.jksFile); case 'PKCS12':
} if (parameters.p12File) {
break; formData.append('p12File', parameters.p12File);
}
break;
case 'JKS':
if (parameters.jksFile) {
formData.append('jksFile', parameters.jksFile);
}
break;
}
} }
// Add signature appearance options if enabled // Add signature appearance options if enabled

View File

@ -2,7 +2,9 @@ import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface ManageSignaturesParameters extends BaseParameters { export interface ManageSignaturesParameters extends BaseParameters {
// Certificate signing options // Sign mode selection
signMode: 'MANUAL' | 'AUTO';
// Certificate signing options (only for manual mode)
certType: '' | 'PEM' | 'PKCS12' | 'JKS'; certType: '' | 'PEM' | 'PKCS12' | 'JKS';
privateKeyFile?: File; privateKeyFile?: File;
certFile?: File; certFile?: File;
@ -20,6 +22,7 @@ export interface ManageSignaturesParameters extends BaseParameters {
} }
export const defaultParameters: ManageSignaturesParameters = { export const defaultParameters: ManageSignaturesParameters = {
signMode: 'MANUAL',
certType: '', certType: '',
password: '', password: '',
showSignature: false, showSignature: false,
@ -37,7 +40,12 @@ export const useManageSignaturesParameters = (): ManageSignaturesParametersHook
defaultParameters, defaultParameters,
endpointName: 'manage-signatures', endpointName: 'manage-signatures',
validateFn: (params) => { validateFn: (params) => {
// Requires certificate type // Auto mode (server certificate) - no additional validation needed
if (params.signMode === 'AUTO') {
return true;
}
// Manual mode - requires certificate type and files
if (!params.certType) { if (!params.certType) {
return false; return false;
} }

View File

@ -0,0 +1,56 @@
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 { BaseToolProps, ToolComponent } from "../types/tool";
const BookletImposition = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'bookletImposition',
useBookletImpositionParameters,
useBookletImpositionOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("bookletImposition.files.placeholder", "Select PDF files to create booklet impositions from"),
},
steps: [
{
title: "Settings",
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: (
<BookletImpositionSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
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;

View File

@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import CertificateTypeSettings from "../components/tools/manageSignatures/CertificateTypeSettings"; import CertificateTypeSettings from "../components/tools/manageSignatures/CertificateTypeSettings";
import CertificateFormatSettings from "../components/tools/manageSignatures/CertificateFormatSettings";
import CertificateFilesSettings from "../components/tools/manageSignatures/CertificateFilesSettings"; import CertificateFilesSettings from "../components/tools/manageSignatures/CertificateFilesSettings";
import SignatureAppearanceSettings from "../components/tools/manageSignatures/SignatureAppearanceSettings"; import SignatureAppearanceSettings from "../components/tools/manageSignatures/SignatureAppearanceSettings";
import { useManageSignaturesParameters } from "../hooks/tools/manageSignatures/useManageSignaturesParameters"; import { useManageSignaturesParameters } from "../hooks/tools/manageSignatures/useManageSignaturesParameters";
@ -26,6 +27,13 @@ const ManageSignatures = (props: BaseToolProps) => {
// Check if certificate files are configured for appearance step // Check if certificate files are configured for appearance step
const areCertFilesConfigured = () => { const areCertFilesConfigured = () => {
const params = base.params.parameters; 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) { switch (params.certType) {
case 'PEM': case 'PEM':
return !!(params.privateKeyFile && params.certFile); return !!(params.privateKeyFile && params.certFile);
@ -47,10 +55,9 @@ const ManageSignatures = (props: BaseToolProps) => {
}, },
steps: [ steps: [
{ {
title: t("manageSignatures.certType.stepTitle", "Certificate Type"), title: t("manageSignatures.signMode.stepTitle", "Sign Mode"),
isCollapsed: base.settingsCollapsed, isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: certTypeTips,
content: ( content: (
<CertificateTypeSettings <CertificateTypeSettings
parameters={base.params.parameters} parameters={base.params.parameters}
@ -59,7 +66,20 @@ const ManageSignatures = (props: BaseToolProps) => {
/> />
), ),
}, },
{ ...(base.params.parameters.signMode === 'MANUAL' ? [{
title: t("manageSignatures.certType.stepTitle", "Certificate Format"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: certTypeTips,
content: (
<CertificateFormatSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
}] : []),
...(base.params.parameters.signMode === 'MANUAL' ? [{
title: t("manageSignatures.certFiles.stepTitle", "Certificate Files"), title: t("manageSignatures.certFiles.stepTitle", "Certificate Files"),
isCollapsed: base.settingsCollapsed, isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
@ -70,7 +90,7 @@ const ManageSignatures = (props: BaseToolProps) => {
disabled={base.endpointLoading} disabled={base.endpointLoading}
/> />
), ),
}, }] : []),
{ {
title: t("manageSignatures.appearance.stepTitle", "Signature Appearance"), title: t("manageSignatures.appearance.stepTitle", "Signature Appearance"),
isCollapsed: base.settingsCollapsed || !areCertFilesConfigured(), isCollapsed: base.settingsCollapsed || !areCertFilesConfigured(),

View File

@ -7,7 +7,7 @@ const TOOL_IDS = [
'detect-split-scanned-photos', 'detect-split-scanned-photos',
'edit-table-of-contents', 'edit-table-of-contents',
'scanner-effect', 'scanner-effect',
'auto-rename-pdf-file', 'multi-page-layout', 'adjust-page-size-scale', 'adjust-contrast', 'cropPdf', 'single-large-page', 'multi-tool', 'auto-rename-pdf-file', 'multi-page-layout', 'booklet-imposition', 'adjust-page-size-scale', 'adjust-contrast', 'cropPdf', 'single-large-page', 'multi-tool',
'repair', 'compare', 'addPageNumbers', 'redact', 'repair', 'compare', 'addPageNumbers', 'redact',
'flatten', 'remove-certificate-sign', 'flatten', 'remove-certificate-sign',
'unlock-pdf-forms', 'compress', 'extract-page', 'reorganize-pages', 'extract-images', 'unlock-pdf-forms', 'compress', 'extract-page', 'reorganize-pages', 'extract-images',