mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 01:19:24 +00:00
booklet and server sign
This commit is contained in:
parent
6a09ec6091
commit
e00baeb033
@ -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");
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -72,6 +72,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.SPDF.service.ServerCertificateService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
@ -101,6 +102,7 @@ public class CertSignController {
|
||||
}
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ServerCertificateService serverCertificateService;
|
||||
|
||||
private static void sign(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@ -175,6 +177,7 @@ public class CertSignController {
|
||||
}
|
||||
|
||||
KeyStore ks = null;
|
||||
String keystorePassword = password;
|
||||
|
||||
switch (certType) {
|
||||
case "PEM":
|
||||
@ -193,6 +196,19 @@ public class CertSignController {
|
||||
ks = KeyStore.getInstance("JKS");
|
||||
ks.load(jksfile.getInputStream(), password.toCharArray());
|
||||
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:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
@ -200,7 +216,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,
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -450,6 +450,10 @@
|
||||
"title": "Sign with Certificate",
|
||||
"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": {
|
||||
"title": "Remove Certificate Sign",
|
||||
"desc": "Remove certificate signature from PDF"
|
||||
@ -458,6 +462,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."
|
||||
@ -1390,6 +1398,33 @@
|
||||
"showLogo": "Show Logo",
|
||||
"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": {
|
||||
"tags": "authenticate,PEM,P12,official,decrypt",
|
||||
"title": "Remove Certificate Signature",
|
||||
@ -1416,6 +1451,18 @@
|
||||
"addBorder": "Add Borders",
|
||||
"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": {
|
||||
"tags": "resize,modify,dimension,adapt",
|
||||
"title": "Adjust page-scale",
|
||||
|
@ -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."
|
||||
@ -1097,6 +1101,18 @@
|
||||
"addBorder": "Add Borders",
|
||||
"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": {
|
||||
"tags": "resize,modify,dimension,adapt",
|
||||
"title": "Adjust page-scale",
|
||||
|
@ -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;
|
@ -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 */}
|
||||
{parameters.certType && (
|
||||
(parameters.certType === 'PEM' && parameters.privateKeyFile && parameters.certFile) ||
|
||||
|
@ -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;
|
@ -1,4 +1,4 @@
|
||||
import { Stack, Button } from "@mantine/core";
|
||||
import { Stack, Button, Text, Divider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ManageSignaturesParameters } from "../../../hooks/tools/manageSignatures/useManageSignaturesParameters";
|
||||
|
||||
@ -13,46 +13,45 @@ const CertificateTypeSettings = ({ parameters, onParameterChange, disabled = fal
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Certificate Type Selection */}
|
||||
<Stack gap="sm">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<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
|
||||
</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
|
||||
</div>
|
||||
</Button>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.signMode === 'MANUAL' ? 'filled' : 'outline'}
|
||||
color={parameters.signMode === 'MANUAL' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => {
|
||||
onParameterChange('signMode', 'MANUAL');
|
||||
// Reset cert type when switching to manual
|
||||
if (parameters.signMode === 'AUTO') {
|
||||
onParameterChange('certType', '');
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
Manual<br />(Provide Files)
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.signMode === 'AUTO' ? 'filled' : 'outline'}
|
||||
color={parameters.signMode === 'AUTO' ? 'green' : 'var(--text-muted)'}
|
||||
onClick={() => {
|
||||
onParameterChange('signMode', 'AUTO');
|
||||
// Clear cert type and files when switching to auto
|
||||
onParameterChange('certType', '');
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
@ -19,7 +19,8 @@ export const useCertificateTypeTips = (): TooltipContent => {
|
||||
bullets: [
|
||||
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.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)")
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -33,10 +33,11 @@ export const useManageSignaturesTooltips = (): TooltipContent => {
|
||||
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."),
|
||||
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.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")
|
||||
]
|
||||
}
|
||||
]
|
||||
|
@ -16,6 +16,7 @@ import SingleLargePage from "../tools/SingleLargePage";
|
||||
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
||||
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
||||
import ManageSignatures from "../tools/ManageSignatures";
|
||||
import BookletImposition from "../tools/BookletImposition";
|
||||
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
||||
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
||||
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 { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
import { manageSignaturesOperationConfig } from "../hooks/tools/manageSignatures/useManageSignaturesOperation";
|
||||
import { bookletImpositionOperationConfig } from "../hooks/tools/bookletImposition/useBookletImpositionOperation";
|
||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||
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 ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||
import CertificateTypeSettings from "../components/tools/manageSignatures/CertificateTypeSettings";
|
||||
import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings";
|
||||
import { ToolId } from "../types/toolId";
|
||||
|
||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||
@ -353,6 +356,16 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
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": {
|
||||
icon: <LocalIcon icon="looks-one-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"),
|
||||
|
@ -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.'))
|
||||
});
|
||||
};
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
@ -7,29 +7,35 @@ import { ManageSignaturesParameters, defaultParameters } from './useManageSignat
|
||||
export const buildManageSignaturesFormData = (parameters: ManageSignaturesParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
formData.append('certType', parameters.certType);
|
||||
formData.append('password', parameters.password);
|
||||
|
||||
// Add certificate files based on type
|
||||
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;
|
||||
// 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
|
||||
|
@ -2,7 +2,9 @@ import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
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';
|
||||
privateKeyFile?: File;
|
||||
certFile?: File;
|
||||
@ -20,6 +22,7 @@ export interface ManageSignaturesParameters extends BaseParameters {
|
||||
}
|
||||
|
||||
export const defaultParameters: ManageSignaturesParameters = {
|
||||
signMode: 'MANUAL',
|
||||
certType: '',
|
||||
password: '',
|
||||
showSignature: false,
|
||||
@ -37,7 +40,12 @@ export const useManageSignaturesParameters = (): ManageSignaturesParametersHook
|
||||
defaultParameters,
|
||||
endpointName: 'manage-signatures',
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
|
56
frontend/src/tools/BookletImposition.tsx
Normal file
56
frontend/src/tools/BookletImposition.tsx
Normal 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;
|
@ -1,6 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import CertificateTypeSettings from "../components/tools/manageSignatures/CertificateTypeSettings";
|
||||
import CertificateFormatSettings from "../components/tools/manageSignatures/CertificateFormatSettings";
|
||||
import CertificateFilesSettings from "../components/tools/manageSignatures/CertificateFilesSettings";
|
||||
import SignatureAppearanceSettings from "../components/tools/manageSignatures/SignatureAppearanceSettings";
|
||||
import { useManageSignaturesParameters } from "../hooks/tools/manageSignatures/useManageSignaturesParameters";
|
||||
@ -26,6 +27,13 @@ const ManageSignatures = (props: BaseToolProps) => {
|
||||
// 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);
|
||||
@ -47,10 +55,9 @@ const ManageSignatures = (props: BaseToolProps) => {
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("manageSignatures.certType.stepTitle", "Certificate Type"),
|
||||
title: t("manageSignatures.signMode.stepTitle", "Sign Mode"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: certTypeTips,
|
||||
content: (
|
||||
<CertificateTypeSettings
|
||||
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"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
@ -70,7 +90,7 @@ const ManageSignatures = (props: BaseToolProps) => {
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
title: t("manageSignatures.appearance.stepTitle", "Signature Appearance"),
|
||||
isCollapsed: base.settingsCollapsed || !areCertFilesConfigured(),
|
||||
|
@ -7,7 +7,7 @@ const TOOL_IDS = [
|
||||
'detect-split-scanned-photos',
|
||||
'edit-table-of-contents',
|
||||
'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',
|
||||
'flatten', 'remove-certificate-sign',
|
||||
'unlock-pdf-forms', 'compress', 'extract-page', 'reorganize-pages', 'extract-images',
|
||||
|
Loading…
x
Reference in New Issue
Block a user