diff --git a/engines/api/src/main/java/net/sf/jsignpdf/utils/TextTimestampSubstitutor.java b/engines/api/src/main/java/net/sf/jsignpdf/utils/TextTimestampSubstitutor.java
new file mode 100644
index 00000000..3edd0982
--- /dev/null
+++ b/engines/api/src/main/java/net/sf/jsignpdf/utils/TextTimestampSubstitutor.java
@@ -0,0 +1,114 @@
+package net.sf.jsignpdf.utils;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Date;
+import java.util.Map;
+import java.util.SimpleTimeZone;
+import java.util.TimeZone;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Expands normal JSignPdf placeholders and optional formatted timestamp
+ * placeholders such as ${timestamp:yyyy.MM.dd}.
+ */
+public final class TextTimestampSubstitutor {
+ private static final String BASE_TIMESTAMP_PATTERN = "yyyy.MM.dd HH:mm:ss z";
+ private static final Pattern FORMATTED_TIMESTAMP = Pattern.compile("\\$\\{timestamp:([^}]+)}");
+ private static final DateTimeFormatter WALL_TIME_FORMAT =
+ DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss");
+
+ private TextTimestampSubstitutor() {
+ }
+
+ public static String replace(Object template, Map, ?> replacements) {
+ if (template == null) {
+ return null;
+ }
+
+ String result = String.valueOf(template);
+ Object timestamp = replacements == null ? null : replacements.get("timestamp");
+ if (timestamp != null && result.contains("${timestamp:")) {
+ result = expandFormattedTimestamps(result, String.valueOf(timestamp));
+ }
+
+ if (replacements == null) {
+ return result;
+ }
+ for (Map.Entry, ?> entry : replacements.entrySet()) {
+ if (entry.getKey() == null || entry.getValue() == null) {
+ continue;
+ }
+ String placeholder = "${" + entry.getKey() + "}";
+ result = result.replace(placeholder, String.valueOf(entry.getValue()));
+ }
+ return result;
+ }
+
+ private static String expandFormattedTimestamps(String template, String baseTimestamp) {
+ Matcher matcher = FORMATTED_TIMESTAMP.matcher(template);
+ if (!matcher.find()) {
+ return template;
+ }
+
+ Date date = null;
+ TimeZone timeZone = null;
+ try {
+ SimpleDateFormat baseFormat = new SimpleDateFormat(BASE_TIMESTAMP_PATTERN);
+ baseFormat.setLenient(false);
+ date = baseFormat.parse(baseTimestamp);
+ timeZone = resolveTimestampOffset(baseTimestamp, date);
+ } catch (ParseException ignored) {
+ // Keep formatted placeholders unchanged if the base timestamp cannot be parsed.
+ }
+
+ matcher.reset();
+ StringBuffer output = new StringBuffer();
+ while (matcher.find()) {
+ String replacement = matcher.group(0);
+ if (date != null) {
+ try {
+ SimpleDateFormat requestedFormat = new SimpleDateFormat(matcher.group(1));
+ if (timeZone != null) {
+ requestedFormat.setTimeZone(timeZone);
+ }
+ replacement = requestedFormat.format(date);
+ } catch (IllegalArgumentException ignored) {
+ // Invalid format: leave the original placeholder visible.
+ }
+ }
+ matcher.appendReplacement(output, Matcher.quoteReplacement(replacement));
+ }
+ matcher.appendTail(output);
+ return output.toString();
+ }
+
+ /**
+ * SimpleDateFormat parses a zone token into the resulting instant but does
+ * not reliably retain that parsed zone on its Calendar. Reconstruct the
+ * effective offset from the wall-clock part and the parsed instant so a
+ * formatted placeholder keeps the same local date/time as ${timestamp}.
+ */
+ private static TimeZone resolveTimestampOffset(String baseTimestamp, Date instant) {
+ try {
+ if (baseTimestamp.length() < 19) {
+ return TimeZone.getDefault();
+ }
+ LocalDateTime wallTime = LocalDateTime.parse(
+ baseTimestamp.substring(0, 19), WALL_TIME_FORMAT);
+ long wallAsUtcMillis = wallTime.toInstant(ZoneOffset.UTC).toEpochMilli();
+ long offsetMillis = wallAsUtcMillis - instant.getTime();
+ long maxReasonableOffset = 18L * 60L * 60L * 1000L;
+ if (Math.abs(offsetMillis) <= maxReasonableOffset) {
+ return new SimpleTimeZone((int) offsetMillis, "timestamp-offset");
+ }
+ } catch (RuntimeException ignored) {
+ // Fall back to the JVM zone below.
+ }
+ return TimeZone.getDefault();
+ }
+}
diff --git a/engines/api/src/main/resources/net/sf/jsignpdf/translations/messages.properties b/engines/api/src/main/resources/net/sf/jsignpdf/translations/messages.properties
index 8bc4e5e9..10842853 100644
--- a/engines/api/src/main/resources/net/sf/jsignpdf/translations/messages.properties
+++ b/engines/api/src/main/resources/net/sf/jsignpdf/translations/messages.properties
@@ -328,6 +328,8 @@ ssl.keymanager.init=Initializing key manager from keystore file {0}.
#
# JavaFX GUI
#
+jfx.gui.outputSuffix.label=Suffix:
+jfx.gui.outputSuffix.prompt=signed
jfx.gui.engine.label=Engine:
jfx.gui.engine.tooltip=Signing engine used to sign the document.
jfx.gui.engine.unsupported=This option is not supported by the selected engine.
diff --git a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java
index 36b95f8c..c4325df1 100644
--- a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java
+++ b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java
@@ -47,10 +47,10 @@
import net.sf.jsignpdf.types.ServerAuthentication;
import net.sf.jsignpdf.utils.AppConfig;
import net.sf.jsignpdf.utils.KeyStoreUtils;
+import net.sf.jsignpdf.utils.TextTimestampSubstitutor;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
-import org.apache.commons.lang3.text.StrSubstitutor;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
@@ -938,7 +938,7 @@ private String buildSignatureText(BasicSignerOptions options, Certificate[] chai
replacements.put(L2TEXT_PLACEHOLDER_LOCATION, StringUtils.defaultString(location));
replacements.put(L2TEXT_PLACEHOLDER_REASON, StringUtils.defaultString(reason));
replacements.put(L2TEXT_PLACEHOLDER_CONTACT, StringUtils.defaultString(options.getContact()));
- return StrSubstitutor.replace(options.getL2Text(), replacements);
+ return TextTimestampSubstitutor.replace(options.getL2Text(), replacements);
}
private String extractCN(X509Certificate cert) {
diff --git a/engines/openpdf/src/main/java/net/sf/jsignpdf/engine/openpdf/OpenPdfSigningEngine.java b/engines/openpdf/src/main/java/net/sf/jsignpdf/engine/openpdf/OpenPdfSigningEngine.java
index 97f124d8..0a70a9a5 100644
--- a/engines/openpdf/src/main/java/net/sf/jsignpdf/engine/openpdf/OpenPdfSigningEngine.java
+++ b/engines/openpdf/src/main/java/net/sf/jsignpdf/engine/openpdf/OpenPdfSigningEngine.java
@@ -45,14 +45,18 @@
import net.sf.jsignpdf.utils.AppConfig;
import net.sf.jsignpdf.utils.KeyStoreUtils;
import net.sf.jsignpdf.utils.PKCS11Utils;
+import net.sf.jsignpdf.utils.TextTimestampSubstitutor;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
-import org.apache.commons.lang3.text.StrSubstitutor;
+import org.openpdf.text.DocumentException;
+import org.openpdf.text.Element;
import org.openpdf.text.Font;
import org.openpdf.text.Image;
+import org.openpdf.text.Phrase;
import org.openpdf.text.Rectangle;
+import org.openpdf.text.pdf.ColumnText;
import org.openpdf.text.pdf.AcroFields;
import org.openpdf.text.pdf.OcspClientBouncyCastle;
import org.openpdf.text.pdf.PdfDate;
@@ -63,6 +67,7 @@
import org.openpdf.text.pdf.PdfSignature;
import org.openpdf.text.pdf.PdfSignatureAppearance;
import org.openpdf.text.pdf.PdfStamper;
+import org.openpdf.text.pdf.PdfTemplate;
import org.openpdf.text.pdf.PdfString;
import org.openpdf.text.pdf.PdfWriter;
import org.openpdf.text.pdf.TSAClientBouncyCastle;
@@ -336,7 +341,7 @@ public boolean sign(final BasicSignerOptions options, final EngineConfig engineC
replacements.put(L2TEXT_PLACEHOLDER_LOCATION, StringUtils.defaultString(location));
replacements.put(L2TEXT_PLACEHOLDER_REASON, StringUtils.defaultString(reason));
replacements.put(L2TEXT_PLACEHOLDER_CONTACT, StringUtils.defaultString(contact));
- final String l2text = StrSubstitutor.replace(options.getL2Text(), replacements);
+ final String l2text = TextTimestampSubstitutor.replace(options.getL2Text(), replacements);
sap.setLayer2Text(l2text);
}
final org.openpdf.text.pdf.BaseFont l2BaseFont = OpenPdfFonts.getL2BaseFont();
@@ -369,6 +374,9 @@ public boolean sign(final BasicSignerOptions options, final EngineConfig engineC
Rectangle signitureRect = computeSignatureRectangle(reader.getPageSize(page), options);
sap.setVisibleSignature(signitureRect, page, null);
}
+ if (renderMode == RenderMode.DESCRIPTION_ONLY) {
+ configureDescriptionLayer2(sap);
+ }
}
LOGGER.info(RES.get("console.processing"));
@@ -498,6 +506,55 @@ public boolean sign(final BasicSignerOptions options, final EngineConfig engineC
return finished;
}
+ /**
+ * Builds the description-only layer 2 appearance over the complete signature rectangle.
+ *
+ *
OpenPDF 3.0.5 reserves the top 30% of the description-only layer for the legacy
+ * layer-4 status text. JSignPdf already controls the complete visible appearance, so that
+ * reservation can clip multiline layer-2 text in short signature rectangles. Creating layer 2
+ * explicitly keeps the public OpenPDF dependency unchanged while matching the full-rectangle
+ * layout used by JSignPdf's preview.
+ */
+ private void configureDescriptionLayer2(final PdfSignatureAppearance sap) throws DocumentException {
+ final PdfTemplate layer = sap.getLayer(2);
+ final Rectangle rect = sap.getRect();
+
+ final Image background = sap.getImage();
+ if (background != null) {
+ final float imageScale = sap.getImageScale();
+ if (imageScale == 0) {
+ layer.addImage(background, rect.getWidth(), 0, 0, rect.getHeight(), 0, 0);
+ } else {
+ float usableScale = imageScale;
+ if (imageScale < 0) {
+ usableScale = Math.min(rect.getWidth() / background.getWidth(),
+ rect.getHeight() / background.getHeight());
+ }
+ final float width = background.getWidth() * usableScale;
+ final float height = background.getHeight() * usableScale;
+ final float x = (rect.getWidth() - width) / 2;
+ final float y = (rect.getHeight() - height) / 2;
+ layer.addImage(background, width, 0, 0, height, x, y);
+ }
+ }
+
+ final Font configuredFont = sap.getLayer2Font();
+ final Font font = configuredFont == null ? new Font() : new Font(configuredFont);
+ float size = font.getSize();
+ final String text = StringUtils.defaultString(sap.getLayer2Text());
+ final Rectangle dataRect = new Rectangle(0, 0, rect.getWidth(), rect.getHeight());
+ if (size <= 0) {
+ final Rectangle fitRect = new Rectangle(dataRect.getWidth(), dataRect.getHeight());
+ size = PdfSignatureAppearance.fitText(font, text, fitRect, 12, sap.getRunDirection());
+ }
+
+ final ColumnText column = new ColumnText(layer);
+ column.setRunDirection(sap.getRunDirection());
+ column.setSimpleColumn(new Phrase(text, font), dataRect.getLeft(), dataRect.getBottom(),
+ dataRect.getRight(), dataRect.getTop(), size, Element.ALIGN_LEFT);
+ column.go();
+ }
+
private Rectangle computeSignatureRectangle(Rectangle pageRect, BasicSignerOptions options) {
float pgWidth = pageRect.getWidth();
float pgHeighth = pageRect.getHeight();
diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/OutputSuffixField.java b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/OutputSuffixField.java
new file mode 100644
index 00000000..c21cf583
--- /dev/null
+++ b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/OutputSuffixField.java
@@ -0,0 +1,36 @@
+package net.sf.jsignpdf.fx.control;
+
+import java.io.File;
+import javafx.scene.Node;
+import javafx.scene.Scene;
+import javafx.scene.control.Label;
+import javafx.scene.control.TextField;
+import javafx.scene.control.Tooltip;
+
+/** Text field for the suffix of the next signed PDF (e.g. DL or EM). */
+public final class OutputSuffixField extends TextField {
+ public OutputSuffixField() {
+ super();
+ textProperty().addListener((obs, oldValue, newValue) -> {
+ OutputSuffixSupport.setUserValue(newValue);
+ refreshVisibleOutput();
+ });
+ }
+
+ private void refreshVisibleOutput() {
+ String output = OutputSuffixSupport.suggestedForLastInput();
+ if (output == null) return;
+ Scene scene = getScene();
+ if (scene == null) return;
+ Node node = scene.lookup("#lblOutputPath");
+ if (!(node instanceof Label label)) return;
+ label.setText("→ " + new File(output).getName());
+ Tooltip tooltip = label.getTooltip();
+ if (tooltip == null) {
+ tooltip = new Tooltip(output);
+ label.setTooltip(tooltip);
+ } else {
+ tooltip.setText(output);
+ }
+ }
+}
diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/OutputSuffixSupport.java b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/OutputSuffixSupport.java
new file mode 100644
index 00000000..126bd08f
--- /dev/null
+++ b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/OutputSuffixSupport.java
@@ -0,0 +1,67 @@
+package net.sf.jsignpdf.fx.control;
+
+import java.io.File;
+import java.util.Locale;
+import net.sf.jsignpdf.utils.AppConfig;
+
+/** Session-only output filename suffix state shared by the UI field and signing flow. */
+public final class OutputSuffixSupport {
+ private static volatile String token = "";
+ private static volatile boolean touched = false;
+ private static volatile String lastInputPath;
+
+ private OutputSuffixSupport() {}
+
+ public static void setUserValue(String value) {
+ token = normalizeToken(value);
+ touched = true;
+ }
+
+ public static String suggestedFor(File inputFile) {
+ if (inputFile == null) {
+ lastInputPath = null;
+ return null;
+ }
+ lastInputPath = inputFile.getAbsolutePath();
+ return appendCurrentSuffix(lastInputPath);
+ }
+
+ public static String resolveForSign(String inputPath, String currentOutPath) {
+ if (!touched && currentOutPath != null && !currentOutPath.isBlank()) {
+ return currentOutPath;
+ }
+ if (inputPath == null || inputPath.isBlank()) {
+ return currentOutPath;
+ }
+ lastInputPath = inputPath;
+ return appendCurrentSuffix(inputPath);
+ }
+
+ public static String suggestedForLastInput() {
+ String input = lastInputPath;
+ return input == null || input.isBlank() ? null : appendCurrentSuffix(input);
+ }
+
+ private static String appendCurrentSuffix(String inputPath) {
+ String ext = ".pdf";
+ String base = inputPath;
+ if (inputPath.toLowerCase(Locale.ROOT).endsWith(ext)) {
+ base = inputPath.substring(0, inputPath.length() - ext.length());
+ ext = inputPath.substring(inputPath.length() - ext.length());
+ }
+ String suffix = token.isEmpty() ? AppConfig.defaultOutSuffix() : "_" + token;
+ return base + suffix + ext;
+ }
+
+ private static String normalizeToken(String value) {
+ if (value == null) return "";
+ String s = value.trim();
+ if (s.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
+ s = s.substring(0, s.length() - 4).trim();
+ }
+ while (s.startsWith("_")) s = s.substring(1);
+ s = s.trim().replaceAll("\\s+", "_");
+ while (s.contains("__")) s = s.replace("__", "_");
+ return s;
+ }
+}
diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/PdfPageView.java b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/PdfPageView.java
index 71911a3d..43bcb935 100644
--- a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/PdfPageView.java
+++ b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/PdfPageView.java
@@ -1,5 +1,8 @@
package net.sf.jsignpdf.fx.control;
+import java.awt.HeadlessException;
+import java.awt.Toolkit;
+
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
@@ -7,12 +10,15 @@
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Region;
+import net.sf.jsignpdf.preview.PreviewRenderSettings;
/**
- * Custom Region that displays a rendered PDF page with zoom support.
- * The image is scaled by the zoomLevel property.
+ * Displays a high-resolution raster preview at a logical UI zoom.
+ * Raster resolution affects image detail only; it does not change the
+ * apparent size of the page on screen.
*/
public class PdfPageView extends Region {
+ private static final double FALLBACK_SCREEN_DPI = 96.0;
private final ImageView imageView = new ImageView();
private final ObjectProperty pageImage = new SimpleObjectProperty<>();
@@ -22,28 +28,35 @@ public PdfPageView() {
getChildren().add(imageView);
imageView.setPreserveRatio(true);
imageView.setSmooth(true);
-
- // Bind image
imageView.imageProperty().bind(pageImage);
-
- // Update size when image or zoom changes
- pageImage.addListener((obs, o, n) -> updateSize());
- zoomLevel.addListener((obs, o, n) -> updateSize());
-
+ pageImage.addListener((obs, oldImage, newImage) -> updateSize());
+ zoomLevel.addListener((obs, oldZoom, newZoom) -> updateSize());
getStyleClass().add("pdf-page-view");
}
private void updateSize() {
- Image img = pageImage.get();
- if (img != null) {
- double zoom = zoomLevel.get();
- double w = img.getWidth() * zoom;
- double h = img.getHeight() * zoom;
- imageView.setFitWidth(w);
- imageView.setFitHeight(h);
- setPrefSize(w, h);
- setMinSize(w, h);
- setMaxSize(w, h);
+ Image image = pageImage.get();
+ if (image == null) {
+ return;
+ }
+
+ double rasterToDisplayScale = getScreenDpi() / PreviewRenderSettings.RENDER_DPI;
+ double displayScale = rasterToDisplayScale * zoomLevel.get();
+ double width = image.getWidth() * displayScale;
+ double height = image.getHeight() * displayScale;
+
+ imageView.setFitWidth(width);
+ imageView.setFitHeight(height);
+ setPrefSize(width, height);
+ setMinSize(width, height);
+ setMaxSize(width, height);
+ }
+
+ private static double getScreenDpi() {
+ try {
+ return Toolkit.getDefaultToolkit().getScreenResolution();
+ } catch (HeadlessException e) {
+ return FALLBACK_SCREEN_DPI;
}
}
@@ -52,7 +65,6 @@ protected void layoutChildren() {
imageView.relocate(0, 0);
}
- // --- Properties ---
public ObjectProperty pageImageProperty() { return pageImage; }
public Image getPageImage() { return pageImage.get(); }
public void setPageImage(Image image) { pageImage.set(image); }
diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/SignaturePreviewPane.java b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/SignaturePreviewPane.java
new file mode 100644
index 00000000..e8f93973
--- /dev/null
+++ b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/SignaturePreviewPane.java
@@ -0,0 +1,523 @@
+package net.sf.jsignpdf.fx.control;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.lang.reflect.Method;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Enumeration;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.naming.ldap.LdapName;
+import javax.naming.ldap.Rdn;
+
+import javafx.beans.value.ObservableValue;
+import javafx.geometry.VPos;
+import javafx.scene.Node;
+import javafx.scene.Scene;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.Pane;
+import javafx.scene.shape.Rectangle;
+import javafx.scene.text.Font;
+import javafx.scene.text.Text;
+import net.sf.jsignpdf.utils.FontUtils;
+import net.sf.jsignpdf.utils.FontUtils.L2Font;
+import org.openpdf.text.pdf.BaseFont;
+
+/**
+ * Purely visual live preview of the visible signature contents.
+ *
+ *
This pane never participates in mouse picking. Placement, moving and
+ * resizing remain entirely owned by {@link SignatureOverlay}.