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}.

+ */ +final class SignaturePreviewPane extends Pane { + private static final double DEFAULT_FONT_SIZE = 10.0; + private static final Pattern FORMATTED_TIMESTAMP_PATTERN = + Pattern.compile("\\$\\{timestamp:([^}]+)}"); + private static final Pattern COORD_PATTERN = Pattern.compile( + "\\((-?\\d+(?:\\.\\d+)?)\\s*,\\s*(-?\\d+(?:\\.\\d+)?)\\)\\s*[—–-]\\s*\\((-?\\d+(?:\\.\\d+)?)\\s*,\\s*(-?\\d+(?:\\.\\d+)?)\\)"); + + private final ImageView imageView = new ImageView(); + private final Pane textPane = new Pane(); + private final Rectangle clip = new Rectangle(); + + private Node l2TextControl; + private Node fontSizeControl; + private Node bgImagePathControl; + private Node sigCoordsControl; + private Node signerNameControl; + private Node reasonControl; + private Node locationControl; + private Node contactControl; + private Node keystoreTypeControl; + private Node keystoreFileControl; + private Node keystorePasswordControl; + private Node keyAliasControl; + private boolean listenersAttached; + + private String loadedImagePath = ""; + private Image loadedImage; + private String cachedSignerKey = ""; + private String cachedCertificateSigner = ""; + + // Same font source and metrics used by the OpenPDF signing engine. + private byte[] l2FontData; + private String l2FontName; + private String l2FontEncoding; + private String fxFontFaceName; + private Font cachedFxFont; + private double cachedFxFontPx = -1; + private BaseFont openPdfBaseFont; + + SignaturePreviewPane() { + setMouseTransparent(true); + setPickOnBounds(false); + setVisible(false); + setClip(clip); + + imageView.setMouseTransparent(true); + imageView.setPreserveRatio(true); + imageView.setSmooth(true); + textPane.setMouseTransparent(true); + + getChildren().add(imageView); + getChildren().add(textPane); + + sceneProperty().addListener((obs, oldScene, newScene) -> { + if (newScene != null) { + bindControls(); + refresh(); + } + }); + } + + void updateBounds(double x, double y, double width, double height) { + if (width <= 0 || height <= 0) return; + resizeRelocate(x, y, width, height); + clip.setX(0); + clip.setY(0); + clip.setWidth(width); + clip.setHeight(height); + textPane.resizeRelocate(0, 0, width, height); + layoutImage(width, height); + renderText(width, height); + } + + void refresh() { + bindControls(); + refreshImage(); + renderText(getWidth(), getHeight()); + } + + private void layoutImage(double width, double height) { + if (loadedImage == null || loadedImage.isError() + || loadedImage.getWidth() <= 0 || loadedImage.getHeight() <= 0) { + imageView.setFitWidth(0); + imageView.setFitHeight(0); + imageView.setX(0); + imageView.setY(0); + return; + } + double scale = Math.min(width / loadedImage.getWidth(), height / loadedImage.getHeight()); + double imageWidth = loadedImage.getWidth() * scale; + double imageHeight = loadedImage.getHeight() * scale; + imageView.setFitWidth(imageWidth); + imageView.setFitHeight(imageHeight); + imageView.setX((width - imageWidth) / 2.0); + imageView.setY((height - imageHeight) / 2.0); + } + + private void renderText(double width, double height) { + textPane.getChildren().clear(); + if (width <= 0 || height <= 0) return; + + String rawText = readText(l2TextControl); + String text = rawText == null || rawText.isEmpty() + ? buildAutomaticText() + : expandPlaceholders(rawText); + if (text == null || text.isEmpty()) return; + + // Reproduce the OpenPDF Layer-2 text layout instead of approximating it + // with the JavaFX system font. OpenPDF uses the configured L2 font in + // PDF points, ColumnText leading equal to that font size, and the exact + // BaseFont widths for wrapping. + double fontPt = parsePositiveDouble(readText(fontSizeControl), DEFAULT_FONT_SIZE); + double pointScale = getActualPointScale(width, height); + if (!(pointScale > 0.0) || !Double.isFinite(pointScale)) pointScale = 1.0; + double fontPx = Math.max(1.0, fontPt * pointScale); + + ensureExactFont(fontPx); + Font fxFont = createFxFont(fontPx); + double maxWidthPt = width / pointScale; + List lines = wrapForOpenPdf(text, maxWidthPt, fontPt); + + // PdfSignatureAppearance -> ColumnText.setSimpleColumn(..., leading=fontSize). + // First baseline is one leading below the top, then advances by exactly + // one leading for every row. + double baseline = fontPx; + for (String line : lines) { + if (!line.isEmpty()) { + Text node = new Text(line); + node.setMouseTransparent(true); + node.setTextOrigin(VPos.BASELINE); + node.setFont(fxFont); + node.setX(0); + node.setY(baseline); + node.setStyle("-fx-fill: #000000;"); + textPane.getChildren().add(node); + } + baseline += fontPx; + if (baseline - fontPx > height + fontPx) break; + } + } + + private List wrapForOpenPdf(String text, double maxWidthPt, double fontPt) { + List out = new ArrayList<>(); + String[] paragraphs = text.replace("\r\n", "\n").replace('\r', '\n').split("\\n", -1); + for (String paragraph : paragraphs) { + if (paragraph.isEmpty()) { + out.add(""); + continue; + } + if (openPdfBaseFont == null || maxWidthPt <= 1.0) { + out.add(paragraph); + continue; + } + String remaining = paragraph; + while (!remaining.isEmpty()) { + if (widthPoint(remaining, fontPt) <= maxWidthPt) { + out.add(remaining); + break; + } + int best = -1; + int search = remaining.length(); + while (search > 0) { + int space = remaining.lastIndexOf(' ', search - 1); + if (space < 0) break; + String candidate = remaining.substring(0, space); + if (widthPoint(candidate, fontPt) <= maxWidthPt) { + best = space; + break; + } + search = space; + } + if (best > 0) { + out.add(remaining.substring(0, best)); + remaining = remaining.substring(best + 1); + continue; + } + int cut = 1; + while (cut < remaining.length() + && widthPoint(remaining.substring(0, cut + 1), fontPt) <= maxWidthPt) { + cut++; + } + out.add(remaining.substring(0, cut)); + remaining = remaining.substring(cut); + } + } + return out; + } + + private double widthPoint(String text, double fontPt) { + try { + return openPdfBaseFont.getWidthPoint(text, (float) fontPt); + } catch (Exception ignored) { + return text.length() * fontPt * 0.55; + } + } + + private double getActualPointScale(double width, double height) { + String coords = readText(sigCoordsControl); + if (coords != null && !coords.isEmpty()) { + Matcher matcher = COORD_PATTERN.matcher(coords); + if (matcher.find()) { + try { + double x1 = Double.parseDouble(matcher.group(1)); + double y1 = Double.parseDouble(matcher.group(2)); + double x2 = Double.parseDouble(matcher.group(3)); + double y2 = Double.parseDouble(matcher.group(4)); + double pdfWidth = Math.abs(x2 - x1); + double pdfHeight = Math.abs(y2 - y1); + double sx = pdfWidth > 0.5 ? width / pdfWidth : Double.NaN; + double sy = pdfHeight > 0.5 ? height / pdfHeight : Double.NaN; + if (pdfWidth >= pdfHeight && Double.isFinite(sx) && sx > 0.0) return sx; + if (Double.isFinite(sy) && sy > 0.0) return sy; + if (Double.isFinite(sx) && sx > 0.0) return sx; + } catch (Exception ignored) { + // Use zoom fallback below. + } + } + } + Scene scene = getScene(); + Node zoom = scene == null ? null : scene.lookup("#cmbZoom"); + String z = readValue(zoom); + if (z != null && z.endsWith("%")) { + try { + return Math.max(0.05, Double.parseDouble(z.substring(0, z.length() - 1).trim()) / 100.0); + } catch (Exception ignored) { + // Safe fallback below. + } + } + return 1.0; + } + + private void ensureExactFont(double fontPx) { + if (l2FontData != null && fxFontFaceName != null && openPdfBaseFont != null) return; + try { + L2Font l2 = FontUtils.getL2Font(); + if (l2 != null) { + l2FontData = l2.getData(); + l2FontName = l2.getName(); + l2FontEncoding = l2.getEncoding(); + Font loaded = Font.loadFont(new ByteArrayInputStream(l2FontData), Math.max(1.0, fontPx)); + if (loaded != null) { + fxFontFaceName = loaded.getName(); + cachedFxFont = loaded; + cachedFxFontPx = Math.max(1.0, fontPx); + } + openPdfBaseFont = BaseFont.createFont(l2FontName, l2FontEncoding, + BaseFont.EMBEDDED, BaseFont.CACHED, l2FontData, null); + } + } catch (Exception ignored) { + // Fall back to the same built-in face OpenPDF uses. + } + if (openPdfBaseFont == null) { + try { + openPdfBaseFont = BaseFont.createFont(BaseFont.HELVETICA, BaseFont.WINANSI, BaseFont.NOT_EMBEDDED); + } catch (Exception ignored) { + // Width fallback remains available. + } + } + if (fxFontFaceName == null) fxFontFaceName = "Arial"; + } + + private Font createFxFont(double fontPx) { + try { + if (l2FontData != null && (cachedFxFont == null || Math.abs(cachedFxFontPx - fontPx) > 0.02)) { + Font loaded = Font.loadFont(new ByteArrayInputStream(l2FontData), fontPx); + if (loaded != null) { + cachedFxFont = loaded; + cachedFxFontPx = fontPx; + fxFontFaceName = loaded.getName(); + } + } + if (cachedFxFont != null) return cachedFxFont; + return Font.font(fxFontFaceName, fontPx); + } catch (Exception ignored) { + return Font.font(fontPx); + } + } + + private void refreshImage() { + String path = safeTrim(readText(bgImagePathControl)); + if (path.equals(loadedImagePath)) return; + loadedImagePath = path; + loadedImage = null; + if (!path.isEmpty()) { + try { + File file = new File(path); + if (file.isFile()) loadedImage = new Image(file.toURI().toString()); + } catch (Exception ignored) { + loadedImage = null; + } + } + imageView.setImage(loadedImage); + layoutImage(getWidth(), getHeight()); + } + + private void bindControls() { + if (listenersAttached) return; + Scene scene = getScene(); + if (scene == null) return; + + l2TextControl = scene.lookup("#txtL2Text"); + fontSizeControl = scene.lookup("#txtFontSize"); + bgImagePathControl = scene.lookup("#txtBgImgPath"); + sigCoordsControl = scene.lookup("#lblSigCoords"); + signerNameControl = scene.lookup("#txtSignerName"); + reasonControl = scene.lookup("#txtReason"); + locationControl = scene.lookup("#txtLocation"); + contactControl = scene.lookup("#txtContact"); + keystoreTypeControl = scene.lookup("#cmbKeystoreType"); + keystoreFileControl = scene.lookup("#txtKeystoreFile"); + keystorePasswordControl = scene.lookup("#txtKeystorePassword"); + keyAliasControl = scene.lookup("#cmbKeyAlias"); + + // The three appearance controls must exist before we consider binding complete. + if (l2TextControl == null || fontSizeControl == null || bgImagePathControl == null) return; + + attachListener(l2TextControl, "textProperty"); + attachListener(fontSizeControl, "textProperty"); + attachListener(bgImagePathControl, "textProperty"); + attachListener(sigCoordsControl, "textProperty"); + attachListener(signerNameControl, "textProperty"); + attachListener(reasonControl, "textProperty"); + attachListener(locationControl, "textProperty"); + attachListener(contactControl, "textProperty"); + attachListener(keystoreTypeControl, "valueProperty"); + attachListener(keystoreFileControl, "textProperty"); + attachListener(keystorePasswordControl, "textProperty"); + attachListener(keyAliasControl, "valueProperty"); + listenersAttached = true; + } + + private void attachListener(Node node, String propertyMethod) { + if (node == null) return; + try { + Method method = node.getClass().getMethod(propertyMethod); + Object property = method.invoke(node); + if (property instanceof ObservableValue) { + @SuppressWarnings("unchecked") + ObservableValue observable = (ObservableValue) property; + observable.addListener((obs, oldValue, newValue) -> refresh()); + } + } catch (Exception ignored) { + // A missing optional control must never affect signature placement. + } + } + + private String expandPlaceholders(String text) { + String signer = safeTrim(readText(signerNameControl)); + if (signer.isEmpty()) signer = resolveCertificateSigner(); + String reason = safeTrim(readText(reasonControl)); + String location = safeTrim(readText(locationControl)); + String contact = safeTrim(readText(contactControl)); + Date now = new Date(); + String timestamp = new SimpleDateFormat("yyyy.MM.dd HH:mm:ss z").format(now); + String expanded = expandFormattedTimestamps(text, now); + return expanded.replace("${timestamp}", timestamp) + .replace("${signer}", signer) + .replace("${reason}", reason) + .replace("${location}", location) + .replace("${contact}", contact); + } + + private String expandFormattedTimestamps(String text, Date date) { + Matcher matcher = FORMATTED_TIMESTAMP_PATTERN.matcher(text); + StringBuffer out = new StringBuffer(); + while (matcher.find()) { + String replacement = matcher.group(0); + try { + replacement = new SimpleDateFormat(matcher.group(1)).format(date); + } catch (IllegalArgumentException ignored) { + // Keep invalid patterns visible. + } + matcher.appendReplacement(out, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(out); + return out.toString(); + } + + private String buildAutomaticText() { + String signer = safeTrim(readText(signerNameControl)); + if (signer.isEmpty()) signer = resolveCertificateSigner(); + if (signer.isEmpty()) signer = "..."; + String timestamp = new SimpleDateFormat("yyyy.MM.dd HH:mm:ss z").format(new Date()); + StringBuilder out = new StringBuilder(); + out.append(resource("default.l2text.signedBy", "Digitally signed by:")).append(' ').append(signer).append('\n'); + out.append(resource("default.l2text.date", "Date:")).append(' ').append(timestamp); + String reason = safeTrim(readText(reasonControl)); + if (!reason.isEmpty()) out.append('\n').append(resource("default.l2text.reason", "Reason:")).append(' ').append(reason); + String location = safeTrim(readText(locationControl)); + if (!location.isEmpty()) out.append('\n').append(resource("default.l2text.location", "Location:")).append(' ').append(location); + return out.toString(); + } + + private String resource(String key, String fallback) { + try { + Class constants = Class.forName("net.sf.jsignpdf.Constants"); + Object resources = constants.getField("RES").get(null); + Object value = resources.getClass().getMethod("get", String.class).invoke(resources, key); + String text = value == null ? "" : value.toString(); + return text.isEmpty() ? fallback : text; + } catch (Exception ignored) { + return fallback; + } + } + + private String resolveCertificateSigner() { + String type = safeTrim(readValue(keystoreTypeControl)); + String path = safeTrim(readText(keystoreFileControl)); + String password = readText(keystorePasswordControl); + String alias = safeTrim(readValue(keyAliasControl)); + String key = type + "\n" + path + "\n" + password + "\n" + alias; + if (key.equals(cachedSignerKey)) return cachedCertificateSigner; + cachedSignerKey = key; + cachedCertificateSigner = ""; + try { + if (type.isEmpty()) type = "PKCS12"; + KeyStore keyStore = KeyStore.getInstance(type); + if (path.isEmpty()) { + keyStore.load(null, null); + } else { + try (FileInputStream input = new FileInputStream(path)) { + keyStore.load(input, password == null ? null : password.toCharArray()); + } + } + if (alias.isEmpty() || !keyStore.containsAlias(alias)) { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String candidate = aliases.nextElement(); + if (keyStore.isKeyEntry(candidate) || keyStore.getCertificate(candidate) != null) { + alias = candidate; + break; + } + } + } + Certificate certificate = alias.isEmpty() ? null : keyStore.getCertificate(alias); + if (certificate instanceof X509Certificate x509) { + LdapName name = new LdapName(x509.getSubjectX500Principal().getName()); + for (Rdn rdn : name.getRdns()) { + if ("CN".equalsIgnoreCase(rdn.getType())) { + cachedCertificateSigner = String.valueOf(rdn.getValue()); + break; + } + } + } + } catch (Exception ignored) { + cachedCertificateSigner = ""; + } + return cachedCertificateSigner; + } + + private static String readText(Node node) { + if (node == null) return ""; + try { + Object value = node.getClass().getMethod("getText").invoke(node); + return value == null ? "" : value.toString(); + } catch (Exception ignored) { + return ""; + } + } + + private static String readValue(Node node) { + if (node == null) return ""; + try { + Object value = node.getClass().getMethod("getValue").invoke(node); + return value == null ? "" : value.toString(); + } catch (Exception ignored) { + return ""; + } + } + + private static double parsePositiveDouble(String value, double fallback) { + if (value == null) return fallback; + try { + double parsed = Double.parseDouble(value.trim()); + return parsed > 0 ? parsed : fallback; + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private static String safeTrim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/SignaturePreviewStackPane.java b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/SignaturePreviewStackPane.java new file mode 100644 index 00000000..a1415cdf --- /dev/null +++ b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/control/SignaturePreviewStackPane.java @@ -0,0 +1,155 @@ +package net.sf.jsignpdf.fx.control; + +import javafx.animation.AnimationTimer; +import javafx.scene.Node; +import javafx.geometry.Point2D; +import javafx.scene.layout.StackPane; +import javafx.scene.shape.Rectangle; + +/** + * PDF area that adds the live signature preview as a sibling underneath the + * original SignatureOverlay. The overlay itself remains completely untouched, + * so all original mouse/cursor/move/resize behavior stays owned by JSignPdf. + */ +public final class SignaturePreviewStackPane extends StackPane { + private SignatureOverlay overlay; + private Rectangle signatureRect; + private SignaturePreviewPane preview; + + private double lastX = Double.NaN; + private double lastY = Double.NaN; + private double lastW = Double.NaN; + private double lastH = Double.NaN; + private boolean lastVisible; + private long lastRefreshNanos; + + public SignaturePreviewStackPane() { + super(); + new AnimationTimer() { + @Override + public void handle(long now) { + syncPreview(now); + } + }.start(); + } + + private void syncPreview(long now) { + if (!resolveOverlayAndRect()) { + if (preview != null) preview.setVisible(false); + return; + } + + ensurePreviewBelowOverlay(); + + boolean visible = signatureRect.isVisible(); + double rectX = signatureRect.getX(); + double rectY = signatureRect.getY(); + double rectW = signatureRect.getWidth(); + double rectH = signatureRect.getHeight(); + + if (!visible || rectW <= 0.0 || rectH <= 0.0) { + if (lastVisible) preview.setVisible(false); + lastVisible = false; + return; + } + + // The rectangle coordinates are local to SignatureOverlay. After the + // window/ScrollPane is resized, the overlay can be re-laid out even + // though the rectangle's own x/y/width/height do not change. Convert + // both rectangle corners through scene coordinates into this sibling + // StackPane so the visual preview follows the interactive rectangle. + Point2D topLeftScene = signatureRect.localToScene(rectX, rectY); + Point2D bottomRightScene = signatureRect.localToScene(rectX + rectW, rectY + rectH); + if (topLeftScene == null || bottomRightScene == null) { + if (lastVisible) preview.setVisible(false); + lastVisible = false; + return; + } + Point2D topLeft = sceneToLocal(topLeftScene); + Point2D bottomRight = sceneToLocal(bottomRightScene); + if (topLeft == null || bottomRight == null) { + if (lastVisible) preview.setVisible(false); + lastVisible = false; + return; + } + + double x = Math.min(topLeft.getX(), bottomRight.getX()); + double y = Math.min(topLeft.getY(), bottomRight.getY()); + double w = Math.abs(bottomRight.getX() - topLeft.getX()); + double h = Math.abs(bottomRight.getY() - topLeft.getY()); + + if (w <= 0.0 || h <= 0.0) { + if (lastVisible) preview.setVisible(false); + lastVisible = false; + return; + } + + boolean geometryChanged = x != lastX || y != lastY || w != lastW || h != lastH; + if (!lastVisible) { + preview.setVisible(true); + geometryChanged = true; + } + if (geometryChanged) { + preview.updateBounds(x, y, w, h); + lastX = x; + lastY = y; + lastW = w; + lastH = h; + } + + // Appearance controls can change without geometry changing. Refresh at a + // modest rate; this is visual-only and never participates in mouse picking. + if (geometryChanged || now - lastRefreshNanos >= 150_000_000L) { + preview.refresh(); + lastRefreshNanos = now; + } + lastVisible = true; + } + + private boolean resolveOverlayAndRect() { + if (overlay == null || !getChildren().contains(overlay)) { + overlay = null; + signatureRect = null; + for (Node node : getChildren()) { + if (node instanceof SignatureOverlay) { + overlay = (SignatureOverlay) node; + break; + } + } + } + if (overlay == null) return false; + + if (signatureRect == null) { + for (Node node : overlay.getChildren()) { + if (node instanceof Rectangle + && node.getStyleClass().contains("signature-rect")) { + signatureRect = (Rectangle) node; + break; + } + } + } + return signatureRect != null; + } + + private void ensurePreviewBelowOverlay() { + if (preview == null) { + preview = new SignaturePreviewPane(); + preview.setManaged(false); + preview.setMouseTransparent(true); + } + + int overlayIndex = getChildren().indexOf(overlay); + int previewIndex = getChildren().indexOf(preview); + int desiredIndex = Math.max(0, overlayIndex); + + if (previewIndex < 0) { + getChildren().add(desiredIndex, preview); + } else if (previewIndex != desiredIndex - 1 && previewIndex != desiredIndex) { + // Normally never needed; keeps the preview directly underneath overlay + // if other children are added dynamically. + getChildren().remove(preview); + overlayIndex = getChildren().indexOf(overlay); + getChildren().add(Math.max(0, overlayIndex), preview); + } + } +} diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/view/MainWindowController.java b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/view/MainWindowController.java index 17415c6a..ebda2460 100644 --- a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/view/MainWindowController.java +++ b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/view/MainWindowController.java @@ -66,6 +66,7 @@ import javafx.scene.layout.VBox; import javafx.util.StringConverter; import net.sf.jsignpdf.fx.control.PdfPageView; +import net.sf.jsignpdf.fx.control.OutputSuffixSupport; import net.sf.jsignpdf.fx.control.SignatureOverlay; import net.sf.jsignpdf.fx.service.JpxCodecPrompt; import net.sf.jsignpdf.fx.service.PdfRenderService; @@ -874,15 +875,7 @@ private void updateOutputPathLabel() { * Returns null if the input is null. */ private static String suggestedOutFileFor(File inputFile) { - if (inputFile == null) return null; - String inFile = inputFile.getAbsolutePath(); - String suffix = ".pdf"; - String nameBase = inFile; - if (inFile.toLowerCase().endsWith(suffix)) { - nameBase = inFile.substring(0, inFile.length() - 4); - suffix = inFile.substring(inFile.length() - 4); - } - return nameBase + AppConfig.defaultOutSuffix() + suffix; + return OutputSuffixSupport.suggestedFor(inputFile); } @@ -1128,6 +1121,11 @@ private void onSign() { capturePlacementToSigningVM(); signingVM.syncToOptions(options); + // Apply the session suffix immediately before signing so edits made after + // opening the PDF are reflected in the generated output filename. + options.setOutFile(OutputSuffixSupport.resolveForSign( + options.getInFile(), options.getOutFile())); + // Generate output file name if not set if (options.getOutFile() == null || options.getOutFile().isEmpty()) { String inFile = options.getInFile(); diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/preview/Pdf2Image.java b/jsignpdf/src/main/java/net/sf/jsignpdf/preview/Pdf2Image.java index b45d9e5e..c882351d 100644 --- a/jsignpdf/src/main/java/net/sf/jsignpdf/preview/Pdf2Image.java +++ b/jsignpdf/src/main/java/net/sf/jsignpdf/preview/Pdf2Image.java @@ -1,8 +1,6 @@ package net.sf.jsignpdf.preview; -import java.awt.HeadlessException; import java.awt.Rectangle; -import java.awt.Toolkit; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; @@ -14,197 +12,125 @@ import net.sf.jsignpdf.Constants; import net.sf.jsignpdf.utils.AppConfig; import net.sf.jsignpdf.utils.PdfUtils; - import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.rendering.PDFRenderer; import org.jpedal.PdfDecoder; import org.jpedal.exception.PdfException; - import org.openpdf.renderer.PDFFile; import org.openpdf.renderer.PDFPage; import org.openpdf.renderer.PDFParseException; import org.openpdf.renderer.decrypt.PDFPassword; import org.openpdf.text.pdf.PdfReader; -/** - * Helper class for converting a page in PDF to a {@link BufferedImage} object. - * - * @author Josef Cacek - */ +/** Converts PDF pages to images for the JavaFX preview. */ public class Pdf2Image { - private static final int JPEDAL_MAX_IMAGE_RENDER_SIZE = 2000 * 2000; - private BasicSignerOptions options; + private final BasicSignerOptions options; - /** - * Constructor - gets an options object with configured input PDF and possibly decoding (owner) password. - * - * @param anOpts - */ - public Pdf2Image(BasicSignerOptions anOpts) { - if (anOpts == null) + public Pdf2Image(BasicSignerOptions options) { + if (options == null) { throw new NullPointerException("Options have to be not-null"); - options = anOpts; + } + this.options = options; } /** - * Returns an image preview of given page. - * - * @param aPage Page to preview (counted from 1) - * @return image or null if error occures. + * Uses PDFBox first because placement accuracy depends on preserving page geometry. + * Configured renderers remain available as fallbacks if PDFBox cannot render a page. */ - public BufferedImage getImageForPage(final int aPage) { - BufferedImage tmpResult = null; - for (String libname : AppConfig.pdf2imageLibraries().split("\\s*,\\s*")) { - tmpResult = switch (libname) { - case Constants.PDF2IMAGE_JPEDAL -> getImageUsingJPedal(aPage); - case Constants.PDF2IMAGE_PDFBOX -> getImageUsingPdfBox(aPage); - case Constants.PDF2IMAGE_OPENPDF -> getImageUsingOpenPdfRenderer(aPage); + public BufferedImage getImageForPage(final int page) { + BufferedImage image = getImageUsingPdfBox(page); + if (image != null) { + return image; + } + + for (String library : AppConfig.pdf2imageLibraries().split("\\s*,\\s*")) { + if (Constants.PDF2IMAGE_PDFBOX.equals(library)) { + continue; + } + image = switch (library) { + case Constants.PDF2IMAGE_JPEDAL -> getImageUsingJPedal(page); + case Constants.PDF2IMAGE_OPENPDF -> getImageUsingOpenPdfRenderer(page); default -> { - Constants.LOGGER.fine("Unknown pdf2image library: " + libname); + Constants.LOGGER.fine("Unknown pdf2image library: " + library); yield null; } }; - if (tmpResult != null) - break; + if (image != null) { + return image; + } } - return tmpResult; + return null; } - /** - * Returns image (or null if failed) generated from given page in PDF using JPedal LGPL. - * - * @param aPage page in PDF (1 based) - * @return image or null - */ - public BufferedImage getImageUsingJPedal(final int aPage) { - BufferedImage tmpResult = null; + public BufferedImage getImageUsingJPedal(final int page) { + BufferedImage result = null; PdfReader reader = null; - PdfDecoder pdfDecoder = null; + PdfDecoder decoder = null; try { - reader = PdfUtils.getPdfReader(options.getInFile(), options.getPdfOwnerPwdStrX().getBytes()); - if (JPEDAL_MAX_IMAGE_RENDER_SIZE > reader.getPageSize(aPage).getWidth() * reader.getPageSize(aPage).getHeight()) { - pdfDecoder = new PdfDecoder(); + if (JPEDAL_MAX_IMAGE_RENDER_SIZE > reader.getPageSize(page).getWidth() * reader.getPageSize(page).getHeight()) { + decoder = new PdfDecoder(); try { - pdfDecoder.openPdfFile(options.getInFile(), options.getPdfOwnerPwdStrX()); + decoder.openPdfFile(options.getInFile(), options.getPdfOwnerPwdStrX()); } catch (PdfException e) { try { - // try to read PDF with empty password - pdfDecoder.openPdfFile(options.getInFile(), ""); + decoder.openPdfFile(options.getInFile(), ""); } catch (PdfException e1) { - // try to read PDF without password - pdfDecoder.openPdfFile(options.getInFile()); + decoder.openPdfFile(options.getInFile()); } } - tmpResult = pdfDecoder.getPageAsImage(aPage); + result = decoder.getPageAsImage(page); } } catch (Exception e) { e.printStackTrace(); } finally { - if (reader != null) { - reader.close(); - } - if (pdfDecoder != null) { - pdfDecoder.closePdfFile(); - } + if (reader != null) reader.close(); + if (decoder != null) decoder.closePdfFile(); } - return tmpResult; + return result; } - /** - * Returns image (or null if failed) generated from given page in PDF using the OpenPDF renderer - * (actively-maintained descendant of the Sun Labs PDFRenderer). - * - * @param aPage page in PDF (1 based) - * @return image or null - */ - public BufferedImage getImageUsingOpenPdfRenderer(final int aPage) { - BufferedImage tmpResult = null; + public BufferedImage getImageUsingOpenPdfRenderer(final int pageNumber) { + BufferedImage result = null; RandomAccessFile raf = null; try { - // load a pdf from a byte buffer File file = new File(options.getInFile()); raf = new RandomAccessFile(file, "r"); FileChannel channel = raf.getChannel(); - ByteBuffer buf = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size()); - PDFFile pdffile = null; + ByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size()); + PDFFile pdfFile; try { - // try to read PDF with owner password - pdffile = new PDFFile(buf, new PDFPassword(options.getPdfOwnerPwdStrX())); - } catch (PDFParseException ppe) { + pdfFile = new PDFFile(buffer, new PDFPassword(options.getPdfOwnerPwdStrX())); + } catch (PDFParseException e) { try { - // try to read PDF with empty password - pdffile = new PDFFile(buf, new PDFPassword("")); - } catch (PDFParseException ppe2) { - // try to read PDF without password - pdffile = new PDFFile(buf); + pdfFile = new PDFFile(buffer, new PDFPassword("")); + } catch (PDFParseException e2) { + pdfFile = new PDFFile(buffer); } } - - // draw the page to an image - PDFPage page = pdffile.getPage(aPage); - - // get the width and height for the doc at the default zoom + PDFPage page = pdfFile.getPage(pageNumber); Rectangle rect = new Rectangle(0, 0, (int) page.getBBox().getWidth(), (int) page.getBBox().getHeight()); - - // generate the image - tmpResult = (BufferedImage) page.getImage(rect.width, rect.height, rect, // clip - // rect - null, // null for the ImageObserver - true, // fill background with white - true // block until drawing is done - ); + result = (BufferedImage) page.getImage(rect.width, rect.height, rect, null, true, true); } catch (Exception e) { e.printStackTrace(); } finally { if (raf != null) { - try { - raf.close(); - } catch (IOException e) { - e.printStackTrace(); - } + try { raf.close(); } catch (IOException e) { e.printStackTrace(); } } } - return tmpResult; + return result; } - /** - * Returns image (or null if failed) generated from given page in PDF using PDFBox tool. - * - * @param aPage page in PDF (1 based) - * @return image or null - */ - public BufferedImage getImageUsingPdfBox(final int aPage) { - BufferedImage tmpResult = null; - PDDocument tmpDoc = null; - - try { - File tmpFile = new File(options.getInFile()); - tmpDoc = Loader.loadPDF(tmpFile, options.getPdfOwnerPwdStrX()); - int resolution; - try { - resolution = Toolkit.getDefaultToolkit().getScreenResolution(); - } catch (HeadlessException e) { - resolution = 96; - } - - PDFRenderer rendedrer = new PDFRenderer(tmpDoc); - tmpResult = rendedrer.renderImageWithDPI(aPage - 1, resolution); + public BufferedImage getImageUsingPdfBox(final int page) { + try (PDDocument document = Loader.loadPDF(new File(options.getInFile()), options.getPdfOwnerPwdStrX())) { + PDFRenderer renderer = new PDFRenderer(document); + return renderer.renderImageWithDPI(page - 1, PreviewRenderSettings.RENDER_DPI); } catch (Exception e) { e.printStackTrace(); - } finally { - if (tmpDoc != null) { - try { - tmpDoc.close(); - } catch (Exception e) { - e.printStackTrace(); - } - } + return null; } - return tmpResult; } } diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/preview/PreviewRenderSettings.java b/jsignpdf/src/main/java/net/sf/jsignpdf/preview/PreviewRenderSettings.java new file mode 100644 index 00000000..01ccd47b --- /dev/null +++ b/jsignpdf/src/main/java/net/sf/jsignpdf/preview/PreviewRenderSettings.java @@ -0,0 +1,10 @@ +package net.sf.jsignpdf.preview; + +/** Shared rendering constants for the PDF preview. */ +public final class PreviewRenderSettings { + /** Raster resolution used to create the high-detail PDF preview. */ + public static final int RENDER_DPI = 300; + + private PreviewRenderSettings() { + } +} diff --git a/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/styles/jsignpdf.css b/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/styles/jsignpdf.css index 8020fa6a..00c0978d 100644 --- a/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/styles/jsignpdf.css +++ b/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/styles/jsignpdf.css @@ -118,8 +118,8 @@ /* Signature overlay */ .signature-rect { -fx-stroke: #1565c0; - -fx-stroke-width: 2; - -fx-fill: rgba(21, 101, 192, 0.15); + -fx-stroke-width: 1; + -fx-fill: rgba(21, 101, 192, 0.08); -fx-stroke-dash-array: 6 4; } diff --git a/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/view/MainWindow.fxml b/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/view/MainWindow.fxml index efd8e866..d62c2dda 100644 --- a/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/view/MainWindow.fxml +++ b/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/view/MainWindow.fxml @@ -6,6 +6,8 @@ + + - + + @@ -234,7 +236,9 @@ styleClass="invisible-sig-badge" managed="false" visible="false"/>