feat(pdf): suporte a redimensionamento interativo e proporcional do selo de assinatura

This commit is contained in:
Claudecio Martins
2026-10-11 10:30:49 -03:00
parent c997afbfd4
commit 4c30649598
4 changed files with 241 additions and 48 deletions
@@ -25,7 +25,8 @@ public record PdfSignatureOptions(
Integer targetPageNumber,
CpfDisplayMode cpfDisplayMode,
Boolean includeDateTime,
String customExtraInfo
String customExtraInfo,
Double customSealScale
) {
public PdfSignatureOptions {
@@ -51,6 +52,21 @@ public record PdfSignatureOptions(
if (includeDateTime == null) {
includeDateTime = sealTheme.defaultIncludeDateTime();
}
if (customSealScale == null || customSealScale <= 0) {
customSealScale = 1.0;
}
}
/**
* Construtor para compatibilidade com assinaturas anteriores a customSealScale.
*/
public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal,
PdfSealTheme sealTheme, PdfSealPosition position, PdfSealPageChoice pageChoice,
String reason, String location, Float customX, Float customY,
Integer targetPageNumber, CpfDisplayMode cpfDisplayMode, Boolean includeDateTime,
String customExtraInfo) {
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location,
customX, customY, targetPageNumber, cpfDisplayMode, includeDateTime, customExtraInfo, 1.0);
}
/**
@@ -61,7 +77,7 @@ public record PdfSignatureOptions(
String reason, String location, Float customX, Float customY,
Integer targetPageNumber, CpfDisplayMode cpfDisplayMode, Boolean includeDateTime) {
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location,
customX, customY, targetPageNumber, cpfDisplayMode, includeDateTime, null);
customX, customY, targetPageNumber, cpfDisplayMode, includeDateTime, null, 1.0);
}
/**
@@ -70,7 +86,7 @@ public record PdfSignatureOptions(
public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal,
PdfSealTheme sealTheme, PdfSealPosition position, PdfSealPageChoice pageChoice,
String reason, String location) {
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location, null, null, null, null, null, null);
this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location, null, null, null, null, null, null, 1.0);
}
public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal,
@@ -143,6 +159,7 @@ public record PdfSignatureOptions(
}
private String customExtraInfo;
private Double customSealScale = 1.0;
public Builder includeDateTime(boolean include) {
this.includeDateTime = include;
@@ -154,6 +171,11 @@ public record PdfSignatureOptions(
return this;
}
public Builder customSealScale(Double scale) {
this.customSealScale = scale;
return this;
}
public Builder customPosition(Float x, Float y, Integer pageNumber) {
this.customX = x;
this.customY = y;
@@ -179,7 +201,8 @@ public record PdfSignatureOptions(
targetPageNumber,
cpfDisplayMode,
includeDateTime,
customExtraInfo
customExtraInfo,
customSealScale
);
}
}
@@ -235,8 +235,11 @@ public class PdfSigningService {
PDImageXObject pdImage = PDImageXObject.createFromByteArray(doc, sealPng, "cybercore_seal");
float sealWidth = theme.width() * 0.72f;
float sealHeight = theme.height() * 0.72f;
float scale = (options.customSealScale() != null && options.customSealScale() > 0)
? options.customSealScale().floatValue()
: 1.0f;
float sealWidth = (theme.width() * 0.72f) * scale;
float sealHeight = (theme.height() * 0.72f) * scale;
float margin = 20f;
// Se coordenadas customizadas foram informadas (clique ou drag & drop na página)
@@ -54,6 +54,22 @@ public final class PdfCertificateSignDialog {
private static final Logger logger = LoggerFactory.getLogger(PdfCertificateSignDialog.class);
public record SealConfig(
boolean visibleSeal,
PdfSealTheme sealTheme,
CpfDisplayMode cpfDisplayMode,
boolean includeDateTime,
String customExtraInfo,
PdfSignDialog.PositionOption positionOption,
PdfSignDialog.PageOption pageOption,
boolean isCustomPosition,
Float customX,
Float customY,
Integer customPageNumber,
String reason,
String location,
Double customSealScale
) {
public SealConfig(
boolean visibleSeal,
PdfSealTheme sealTheme,
CpfDisplayMode cpfDisplayMode,
@@ -67,7 +83,11 @@ public final class PdfCertificateSignDialog {
Integer customPageNumber,
String reason,
String location
) {}
) {
this(visibleSeal, sealTheme, cpfDisplayMode, includeDateTime, customExtraInfo, positionOption,
pageOption, isCustomPosition, customX, customY, customPageNumber, reason, location, 1.0);
}
}
private final Stage stage;
private final CertificateManager certManager;
@@ -429,7 +449,8 @@ public final class PdfCertificateSignDialog {
.includeDateTime(sealConfig.includeDateTime())
.customExtraInfo(sealConfig.customExtraInfo())
.reason(sealConfig.reason())
.location(sealConfig.location());
.location(sealConfig.location())
.customSealScale(sealConfig.customSealScale());
if (sealConfig.isCustomPosition() && sealConfig.customX() != null && sealConfig.customY() != null) {
b.customPosition(sealConfig.customX(), sealConfig.customY(), sealConfig.customPageNumber());
@@ -46,6 +46,8 @@ import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
@@ -127,6 +129,22 @@ public final class PdfSignDialog {
private ComboBox<PageOption> pageCombo;
private ImageView sealPreviewImageView;
// Controle de Escala / Redimensionamento do Selo (1.0 = 100%)
private double sealScale = 1.0;
private static final double MIN_SEAL_SCALE = 0.40;
private static final double MAX_SEAL_SCALE = 1.80;
private boolean isResizeModeActive = false;
// Componentes do grupo do selo e alça interativa
private Pane sealGroupPane;
private Rectangle sealSelectionBorder;
private Circle resizeHandle;
private Button btnToggleResize;
private Button btnResetSealScale;
private Label lblSealScaleIndicator;
private double resizeStartSceneX;
private double resizeStartBaseWidth;
// Visualizador Interativo da Folha
private ScrollPane sheetScroll;
private StackPane pageSheetContainer;
@@ -463,13 +481,34 @@ public final class PdfSignDialog {
zoomBox.getChildren().addAll(btnZoomOut, lblZoomValue, btnZoomIn, btnZoomFit);
// Grupo de Tamanho do Selo
HBox resizeToolBox = new HBox(6);
resizeToolBox.setAlignment(Pos.CENTER_LEFT);
resizeToolBox.setStyle("-fx-background-color: -fx-bg-primary; -fx-border-color: -fx-border-light; -fx-border-radius: 6px; -fx-padding: 2px 6px;");
btnToggleResize = new Button("📏 Redimensionar");
btnToggleResize.getStyleClass().addAll("btn-secondary", "btn-compact");
btnToggleResize.setTooltip(new Tooltip("Ativar/desativar alça interativa para alterar o tamanho do selo"));
btnToggleResize.setOnAction(e -> toggleResizeMode());
lblSealScaleIndicator = new Label("100%");
lblSealScaleIndicator.setStyle("-fx-font-size: 11px; -fx-font-weight: bold; -fx-min-width: 36px; -fx-alignment: center;");
btnResetSealScale = new Button("↺ 100%");
btnResetSealScale.getStyleClass().addAll("btn-secondary", "btn-compact");
btnResetSealScale.setTooltip(new Tooltip("Restaurar tamanho padrão original (100%)"));
btnResetSealScale.setVisible(false);
btnResetSealScale.setOnAction(e -> applySealScale(1.0));
resizeToolBox.getChildren().addAll(btnToggleResize, lblSealScaleIndicator, btnResetSealScale);
Region tbSpacer = new Region();
HBox.setHgrow(tbSpacer, Priority.ALWAYS);
lblPositionFeedback = new Label("👉 Clique na folha para posicionar");
lblPositionFeedback.setStyle("-fx-font-size: 11.5px; -fx-text-fill: -fx-accent; -fx-font-weight: bold;");
toolbar.getChildren().addAll(pageNavBox, zoomBox, tbSpacer, lblPositionFeedback);
toolbar.getChildren().addAll(pageNavBox, zoomBox, resizeToolBox, tbSpacer, lblPositionFeedback);
// ScrollPane da Folha de Papel
sheetScroll = new ScrollPane();
@@ -517,14 +556,43 @@ public final class PdfSignDialog {
handleSheetClick(event);
});
// Grupo visual do selo composto pela imagem, borda de seleção e alça de quina
sealGroupPane = new Pane();
sealGroupPane.setVisible(false);
sealStampOverlayView = new ImageView();
sealStampOverlayView.setPreserveRatio(true);
sealStampOverlayView.setCursor(Cursor.MOVE);
DropShadow sealShadow = new DropShadow(8, 0, 2, Color.rgb(2, 132, 199, 0.65));
sealStampOverlayView.setEffect(sealShadow);
sealStampOverlayView.setVisible(false);
// Arraste com ancoragem precisa no ponto exato do clique dentro do selo (sem salto e sem drift)
sealSelectionBorder = new Rectangle();
sealSelectionBorder.setFill(Color.TRANSPARENT);
sealSelectionBorder.setStroke(Color.rgb(2, 132, 199));
sealSelectionBorder.setStrokeWidth(1.8);
sealSelectionBorder.getStrokeDashArray().addAll(5.0, 4.0);
sealSelectionBorder.setVisible(false);
resizeHandle = new Circle(8);
resizeHandle.setFill(Color.rgb(2, 132, 199));
resizeHandle.setStroke(Color.WHITE);
resizeHandle.setStrokeWidth(2.0);
resizeHandle.setCursor(Cursor.SE_RESIZE);
DropShadow handleShadow = new DropShadow(4, 0, 1, Color.rgb(0, 0, 0, 0.35));
resizeHandle.setEffect(handleShadow);
resizeHandle.setVisible(false);
Tooltip.install(resizeHandle, new Tooltip("Puxe a quina para redimensionar o selo proporcionalmente"));
// Duplo clique no selo para ativar/desativar o modo de redimensionamento
sealStampOverlayView.setOnMouseClicked(event -> {
sealDragOccurred = false;
if (event.getClickCount() == 2) {
toggleResizeMode();
}
event.consume();
});
// Arraste com ancoragem precisa no ponto exato do clique dentro do selo
sealStampOverlayView.setOnMousePressed(event -> {
dragStartOffsetInSealX = event.getX();
dragStartOffsetInSealY = event.getY();
@@ -536,7 +604,6 @@ public final class PdfSignDialog {
if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return;
sealDragOccurred = true;
// Converte coordenada da cena para coordenadas locais do painel da folha
Point2D localInSheet = interactiveOverlayPane.sceneToLocal(event.getSceneX(), event.getSceneY());
double targetX = localInSheet.getX() - dragStartOffsetInSealX;
double targetY = localInSheet.getY() - dragStartOffsetInSealY;
@@ -544,17 +611,32 @@ public final class PdfSignDialog {
event.consume();
});
sealStampOverlayView.setOnMouseReleased(event -> {
// Garante que o evento de soltura do mouse não borbulhe para a folha
sealStampOverlayView.setOnMouseReleased(event -> event.consume());
// Eventos da Alça de Redimensionamento Interativa
resizeHandle.setOnMousePressed(event -> {
resizeStartSceneX = event.getSceneX();
resizeStartBaseWidth = currentBaseSealWidth * zoomFactor * sealScale;
event.consume();
});
sealStampOverlayView.setOnMouseClicked(event -> {
sealDragOccurred = false;
resizeHandle.setOnMouseDragged(event -> {
if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return;
double deltaX = event.getSceneX() - resizeStartSceneX;
double newWidth = resizeStartBaseWidth + deltaX;
double baseW = currentBaseSealWidth * zoomFactor;
if (baseW > 0) {
applySealScale(newWidth / baseW);
}
event.consume();
});
interactiveOverlayPane.getChildren().add(sealStampOverlayView);
resizeHandle.setOnMouseReleased(event -> event.consume());
resizeHandle.setOnMouseClicked(event -> event.consume());
sealGroupPane.getChildren().addAll(sealStampOverlayView, sealSelectionBorder, resizeHandle);
interactiveOverlayPane.getChildren().add(sealGroupPane);
pageSheetContainer.getChildren().addAll(pageImageView, interactiveOverlayPane);
sheetWrapper.getChildren().add(pageSheetContainer);
sheetScroll.setContent(sheetWrapper);
@@ -563,6 +645,41 @@ public final class PdfSignDialog {
return column;
}
private void toggleResizeMode() {
this.isResizeModeActive = !this.isResizeModeActive;
if (sealSelectionBorder != null) {
sealSelectionBorder.setVisible(isResizeModeActive);
}
if (resizeHandle != null) {
resizeHandle.setVisible(isResizeModeActive);
}
if (btnToggleResize != null) {
if (isResizeModeActive) {
btnToggleResize.setText("✓ Concluir");
btnToggleResize.setStyle("-fx-background-color: #0284c7; -fx-text-fill: white; -fx-font-weight: bold;");
lblPositionFeedback.setText("👉 Puxe a alça azul no canto do selo para ajustar o tamanho");
} else {
btnToggleResize.setText("📏 Redimensionar");
btnToggleResize.setStyle("");
updateFeedbackLabel();
}
}
}
private void applySealScale(double newScale) {
this.sealScale = Math.max(MIN_SEAL_SCALE, Math.min(MAX_SEAL_SCALE, Math.round(newScale * 100.0) / 100.0));
if (lblSealScaleIndicator != null) {
lblSealScaleIndicator.setText(String.format("%.0f%%", sealScale * 100));
}
if (btnResetSealScale != null) {
btnResetSealScale.setVisible(Math.abs(sealScale - 1.0) > 0.02);
}
updateContainerZoomDimensions();
repositionSealByRelativeCoords();
updateFeedbackLabel();
}
private void adjustZoom(double delta) {
setZoom(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoomFactor + delta)));
}
@@ -592,8 +709,19 @@ public final class PdfSignDialog {
pageImageView.setFitHeight(currentH);
}
if (sealStampOverlayView != null) {
sealStampOverlayView.setFitWidth(currentBaseSealWidth * zoomFactor);
sealStampOverlayView.setFitHeight(currentBaseSealHeight * zoomFactor);
double sealW = currentBaseSealWidth * zoomFactor * sealScale;
double sealH = currentBaseSealHeight * zoomFactor * sealScale;
sealStampOverlayView.setFitWidth(sealW);
sealStampOverlayView.setFitHeight(sealH);
if (sealSelectionBorder != null) {
sealSelectionBorder.setWidth(sealW);
sealSelectionBorder.setHeight(sealH);
}
if (resizeHandle != null) {
resizeHandle.setCenterX(sealW);
resizeHandle.setCenterY(sealH);
}
}
}
@@ -602,15 +730,17 @@ public final class PdfSignDialog {
double sheetW = currentBaseSheetWidth * zoomFactor;
double sheetH = currentBaseSheetHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor;
double sealH = currentBaseSealHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor * sealScale;
double sealH = currentBaseSealHeight * zoomFactor * sealScale;
double posX = Math.max(0, Math.min(sealRelativeX * sheetW, sheetW - sealW));
double posY = Math.max(0, Math.min(sealRelativeY * sheetH, sheetH - sealH));
sealStampOverlayView.setLayoutX(posX);
sealStampOverlayView.setLayoutY(posY);
sealStampOverlayView.setVisible(true);
if (sealGroupPane != null) {
sealGroupPane.setLayoutX(posX);
sealGroupPane.setLayoutY(posY);
sealGroupPane.setVisible(true);
}
}
// =========================================================================
@@ -653,7 +783,9 @@ public final class PdfSignDialog {
renderCurrentPage();
if (chkVisibleSeal.isSelected()) {
sealStampOverlayView.setVisible(true);
if (sealGroupPane != null) {
sealGroupPane.setVisible(true);
}
resetSealToCornerPosition(PositionOption.BOTTOM_RIGHT);
}
} catch (Exception ex) {
@@ -751,38 +883,42 @@ public final class PdfSignDialog {
// =========================================================================
private void handleSheetClick(MouseEvent event) {
if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return;
double sealW = currentBaseSealWidth * zoomFactor;
double sealH = currentBaseSealHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor * sealScale;
double sealH = currentBaseSealHeight * zoomFactor * sealScale;
updateSealPositionFromDrag(event.getX() - sealW / 2.0, event.getY() - sealH / 2.0);
}
private void updateSealPositionFromDrag(double newLayoutX, double newLayoutY) {
double sheetW = currentBaseSheetWidth * zoomFactor;
double sheetH = currentBaseSheetHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor;
double sealH = currentBaseSealHeight * zoomFactor;
double sealW = currentBaseSealWidth * zoomFactor * sealScale;
double sealH = currentBaseSealHeight * zoomFactor * sealScale;
double clampedX = Math.max(0, Math.min(newLayoutX, sheetW - sealW));
double clampedY = Math.max(0, Math.min(newLayoutY, sheetH - sealH));
sealStampOverlayView.setLayoutX(clampedX);
sealStampOverlayView.setLayoutY(clampedY);
sealStampOverlayView.setVisible(true);
if (sealGroupPane != null) {
sealGroupPane.setLayoutX(clampedX);
sealGroupPane.setLayoutY(clampedY);
sealGroupPane.setVisible(true);
}
this.sealRelativeX = (sheetW > 0) ? (clampedX / sheetW) : 0;
this.sealRelativeY = (sheetH > 0) ? (clampedY / sheetH) : 0;
PdfSealTheme theme = activeSealTheme != null ? activeSealTheme : PdfSealThemeRegistry.getDefaultTheme();
float sealPdfW = (float) ((theme != null) ? theme.width() * 0.72 : 144.0);
float sealPdfH = (float) ((theme != null) ? theme.height() * 0.72 : 44.0);
float basePdfW = (float) ((theme != null) ? theme.width() * 0.72 : 144.0);
float basePdfH = (float) ((theme != null) ? theme.height() * 0.72 : 44.0);
float scaledPdfW = basePdfW * (float) sealScale;
float scaledPdfH = basePdfH * (float) sealScale;
// Conversão direta e exata da escala visual na tela para os pontos do PDF
double scale = (currentPagePdfWidth > 0) ? (sheetW / currentPagePdfWidth) : 1.0;
float calculatedPdfX = (float) (clampedX / scale);
float calculatedPdfY = (float) (currentPagePdfHeight - (clampedY / scale) - sealPdfH);
float calculatedPdfY = (float) (currentPagePdfHeight - (clampedY / scale) - scaledPdfH);
this.customPdfX = Math.max(0, Math.min(calculatedPdfX, currentPagePdfWidth - sealPdfW));
this.customPdfY = Math.max(0, Math.min(calculatedPdfY, currentPagePdfHeight - sealPdfH));
this.customPdfX = Math.max(0, Math.min(calculatedPdfX, currentPagePdfWidth - scaledPdfW));
this.customPdfY = Math.max(0, Math.min(calculatedPdfY, currentPagePdfHeight - scaledPdfH));
this.customPageNumber = currentPageIndex + 1;
this.isCustomPositionActive = true;
@@ -799,8 +935,10 @@ public final class PdfSignDialog {
}
PdfSealTheme theme = activeSealTheme != null ? activeSealTheme : PdfSealThemeRegistry.getDefaultTheme();
float sealPdfW = (float) ((theme != null) ? theme.width() * 0.72 : 144.0);
float sealPdfH = (float) ((theme != null) ? theme.height() * 0.72 : 44.0);
float basePdfW = (float) ((theme != null) ? theme.width() * 0.72 : 144.0);
float basePdfH = (float) ((theme != null) ? theme.height() * 0.72 : 44.0);
float sealPdfW = basePdfW * (float) sealScale;
float sealPdfH = basePdfH * (float) sealScale;
float margin = 20f;
float targetPdfX;
@@ -853,16 +991,22 @@ public final class PdfSignDialog {
private void updateFeedbackLabel() {
if (isCustomPositionActive && customPageNumber != null && customPdfX != null && customPdfY != null) {
lblPositionFeedback.setText(String.format("📍 Selo na Pág. %d [X: %.0f pt, Y: %.0f pt]",
customPageNumber, customPdfX, customPdfY));
String scaleStr = (Math.abs(sealScale - 1.0) > 0.02) ? String.format(" | Escala: %.0f%%", sealScale * 100) : "";
lblPositionFeedback.setText(String.format("📍 Selo na Pág. %d [X: %.0f pt, Y: %.0f pt]%s",
customPageNumber, customPdfX, customPdfY, scaleStr));
}
}
private void toggleVisibleSeal(boolean active) {
positionCombo.setDisable(!active);
pageCombo.setDisable(!active);
if (btnToggleResize != null) {
btnToggleResize.setDisable(!active);
}
sealPreviewImageView.setOpacity(active ? 1.0 : 0.3);
sealStampOverlayView.setVisible(active && selectedPdfBytes != null);
if (sealGroupPane != null) {
sealGroupPane.setVisible(active && selectedPdfBytes != null);
}
}
private void updateSealPreview() {
@@ -927,19 +1071,21 @@ public final class PdfSignDialog {
customPdfY,
customPageNumber,
DEFAULT_REASON,
DEFAULT_LOCATION
DEFAULT_LOCATION,
sealScale
);
String sealSummary;
if (chkVisibleSeal.isSelected()) {
String themeName = theme.name();
String scaleInfo = (Math.abs(sealScale - 1.0) > 0.02) ? String.format(" [Escala: %.0f%%]", sealScale * 100) : "";
if (isCustomPositionActive && customPageNumber != null && customPdfX != null && customPdfY != null) {
sealSummary = String.format("Selo Estampado (%s) — Pág. %d [X: %.0f pt, Y: %.0f pt]",
themeName, customPageNumber, customPdfX, customPdfY);
sealSummary = String.format("Selo Estampado (%s)%s — Pág. %d [X: %.0f pt, Y: %.0f pt]",
themeName, scaleInfo, customPageNumber, customPdfX, customPdfY);
} else {
sealSummary = String.format("Selo Estampado (%s) — %s (%s)",
themeName, (pos != null ? pos.label : "Canto Inferior Direito"), (pg != null ? pg.label : "Última Página"));
sealSummary = String.format("Selo Estampado (%s)%s — %s (%s)",
themeName, scaleInfo, (pos != null ? pos.label : "Canto Inferior Direito"), (pg != null ? pg.label : "Última Página"));
}
} else {
sealSummary = "Sem Selo Visual (Assinatura PAdES Invisível)";