diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportPage.razor index 8a0a2601..fd271710 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportPage.razor +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportPage.razor @@ -477,21 +477,12 @@ IReadOnlyList signatures, SignatureCaptureDto? capturedSignature) { - // Burn signatures into PDF bytes when a captured signature is available + // Always draw placeholder boxes on signature fields so the user knows where to sign. + // When a captured signature exists, it will be applied in the Signed page instead. byte[] sourcePdf = pdfBytes; - if (capturedSignature is not null - && !string.IsNullOrWhiteSpace(capturedSignature.DataUrl) - && signatures.Count > 0) + if (signatures.Count > 0) { - try - { - sourcePdf = BurnSignaturesIntoPdf(pdfBytes, signatures, capturedSignature); - } - catch - { - // Fall back to unmodified PDF — non-critical - sourcePdf = pdfBytes; - } + sourcePdf = DrawSignaturePlaceholders(pdfBytes, signatures); } var report = new XtraReport @@ -504,31 +495,75 @@ var detail = new DetailBand { HeightF = 0f }; report.Bands.Add(detail); - // GenerateOwnPages = true (default): each PDF page becomes a separate report page - var pdfContent = new XRPdfContent + detail.Controls.Add(new XRPdfContent { Source = sourcePdf, GenerateOwnPages = true, - }; - detail.Controls.Add(pdfContent); + }); return report; } /// - /// Burns signature images directly into the PDF using DevExpress PdfGraphics API. - /// Coordinates: DB stores INCHES with top-left origin, Y down. - /// PDF coordinate system: bottom-left origin, Y up, unit = points (1/72 inch). - /// Note: Implementation placeholder — requires DevExpress.Pdf.Drawing API wiring (Problem 2). + /// Uses PdfSharp to draw a visible signature placeholder box on every signature field. + /// sig.X / sig.Y come from GetSignaturesAsync(UnitOfLength.Point) → already in PDF points. + /// PdfSharp coordinate origin: bottom-left, Y up. Conversion: pdfY = pageH - sigY - sigH + /// Signature field size (fixed): 1.77" × 1.96" = 127.44pt × 141.12pt /// - static byte[] BurnSignaturesIntoPdf( + static byte[] DrawSignaturePlaceholders( byte[] pdfBytes, - IReadOnlyList signatures, - SignatureCaptureDto capturedSignature) + IReadOnlyList signatures) { - // TODO: Implement with PdfGraphics when Problem 2 is addressed. - // For now return unmodified PDF so Problem 1 (all pages) can be verified first. - return pdfBytes; + if (signatures.Count == 0) return pdfBytes; + + using var inputMs = new System.IO.MemoryStream(pdfBytes); + using var outputMs = new System.IO.MemoryStream(); + + var document = PdfSharp.Pdf.IO.PdfReader.Open( + inputMs, + PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify); + + const double sigW = 1.77 * 72; // 127.44 pt + const double sigH = 1.96 * 72; // 141.12 pt + + foreach (var sig in signatures) + { + int pageIndex = sig.Page - 1; + if (pageIndex < 0 || pageIndex >= document.PageCount) continue; + + var page = document.Pages[pageIndex]; + + // PdfSharp XGraphics uses top-left origin, Y down — same as sig.X/sig.Y + // No coordinate conversion needed. + using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page); + + var rect = new PdfSharp.Drawing.XRect(sig.X, sig.Y, sigW, sigH); + + // Filled semi-transparent rectangle + var fillBrush = new PdfSharp.Drawing.XSolidBrush( + PdfSharp.Drawing.XColor.FromArgb(40, 60, 80, 160)); + var borderPen = new PdfSharp.Drawing.XPen( + PdfSharp.Drawing.XColor.FromArgb(200, 60, 80, 200), 1.5); + + gfx.DrawRectangle(fillBrush, rect); + gfx.DrawRectangle(borderPen, rect); + + // "UNTERSCHRIFT" label centred in the box + var font = new PdfSharp.Drawing.XFont("Arial", 9, + PdfSharp.Drawing.XFontStyleEx.Bold); + var textBrush = new PdfSharp.Drawing.XSolidBrush( + PdfSharp.Drawing.XColor.FromArgb(200, 40, 60, 140)); + + var textFmt = new PdfSharp.Drawing.XStringFormat + { + Alignment = PdfSharp.Drawing.XStringAlignment.Center, + LineAlignment = PdfSharp.Drawing.XLineAlignment.Center, + }; + gfx.DrawString("UNTERSCHRIFT", font, textBrush, rect, textFmt); + } + + document.Save(outputMs); + return outputMs.ToArray(); } /// Converts a base64 data URL (data:image/...;base64,...) to raw bytes. diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportSignedPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportSignedPage.razor new file mode 100644 index 00000000..a5036070 --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportSignedPage.razor @@ -0,0 +1,685 @@ +@page "/envelope/{EnvelopeKey}/signed" +@rendermode InteractiveServer +@using DevExpress.Blazor.Reporting +@using DevExpress.XtraReports.UI +@using EnvelopeGenerator.Server.Client.Models +@using EnvelopeGenerator.Server.Client.Models.Constants +@using EnvelopeGenerator.Server.Client.Services +@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver +@using Microsoft.JSInterop +@using DevExpress.Blazor +@using System.Drawing +@using System.Security.Claims +@inject NavigationManager Navigation +@inject IJSRuntime JSRuntime +@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService +@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService +@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService +@inject AppVersionService AppVersion +@inject ILogger Logger +@implements IDisposable + + + + + + +
+
+
+ @* Row 1: Title + Sender + Badges *@ +
+ @* Left: Title + Sender *@ +
+ @if (_envelopeReceiver is not null) + { +
+ @(_envelopeReceiver.Envelope?.Title ?? "Dokument") +
+ @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName) || !string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.Email)) + { + + Von + @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName)) + { + @_envelopeReceiver.Envelope.User.FullName + } + @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.Email)) + { + <@_envelopeReceiver.Envelope.User.Email> + } + @if (_envelopeReceiver.Envelope?.AddedWhen != null) + { +  · @_envelopeReceiver.Envelope.AddedWhen.ToString("dd.MM.yyyy") + } + + } + } + else + { +
Dokumentenansicht
+ } +
+ + @* Right: Badges + Signature status *@ +
+ @if (_envelopeReceiver is not null) + { +
+ @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Name)) + { + + + + + @_envelopeReceiver.Name + + } + @if (_signatures.Count > 0) + { + + + + + @_signatures.Count Unterschrift@(_signatures.Count != 1 ? "en" : "") + @if (_capturedSignature is not null) + { + + } + + } + @if (_envelopeReceiver.Envelope?.UseAccessCode ?? false) + { + + + + + Code + + } + @if (_envelopeReceiver.Envelope?.TFAEnabled ?? false) + { + + + + + + 2FA + + } +
+ } + + @* Unterschrift ändern button (when signature captured) *@ + @if (_capturedSignature is not null) + { + + } +
+
+ + @* Row 2: Messages *@ + @if (_envelopeReceiver is not null && (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.Message) || !string.IsNullOrWhiteSpace(_envelopeReceiver.PrivateMessage))) + { +
+ @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.Message)) + { +
+ 📧 + @_envelopeReceiver.Envelope.Message +
+ } + @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.PrivateMessage)) + { +
+ 🔒 + @_envelopeReceiver.PrivateMessage +
+ } +
+ } +
+
+ +
+ @if (_isLoading) + { +
+
+
+ Lädt... +
+

Dokument wird geladen...

+
+
+ } + else if (_errorMessage is not null) + { +
+
+
+ + + + +
+
Fehler beim Laden des Dokuments
+

@_errorMessage

+
+
+
+
+ } + else if (_report is not null) + { + + } +
+
+ +@* Signature Popup *@ + + + + + @if (_activeSignatureTab == SignatureTabDraw) + { +

Bitte unterschreiben Sie im folgenden Feld.

+ + } + else if (_activeSignatureTab == SignatureTabText) + { +

Geben Sie Ihre Unterschrift als Text ein und wählen Sie eine Schriftart.

+
+
+ +
+
+ +
+
+ + } + else + { +

Laden Sie ein Bild Ihrer Unterschrift hoch.

+ + + } + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_popupValidationMessage)) + { +
+ @_popupValidationMessage +
+ } +
+ +
+ + +
+
+
+ +@code { + // ----- Constants ----- + const string SignatureTabDraw = "draw"; + const string SignatureTabText = "text"; + const string SignatureTabImage = "image"; + const string DrawCanvasId = "rp-signature-pad"; + const string TypedCanvasId = "rp-typed-signature-pad"; + const string ImageInputId = "rp-signature-image-input"; + const string ImageCanvasId = "rp-image-signature-pad"; + + readonly (string Text, string Value)[] TypedSignatureFonts = + [ + ("Brush Script", "'Brush Script MT', cursive"), + ("Segoe Script", "'Segoe Script', cursive"), + ("Lucida Handwriting", "'Lucida Handwriting', cursive"), + ("Comic Sans", "'Comic Sans MS', cursive"), + ("Cursive", "cursive"), + ]; + + // ----- Parameters ----- + [Parameter] public string? EnvelopeKey { get; set; } + + // ----- Page state ----- + bool _isLoading = true; + string? _errorMessage; + byte[]? _pdfBytes; + IReadOnlyList _signatures = []; + EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto? _envelopeReceiver; + ClaimsPrincipal? _receiverUser; + + // ----- Report viewer ----- + DxReportViewer? _reportViewer; + XtraReport? _report; + + // ----- Signature popup state ----- + SignatureCaptureDto? _capturedSignature; + bool _signaturePopupVisible = false; + string? _popupValidationMessage; + string _activeSignatureTab = SignatureTabDraw; + string _typedSignatureText = string.Empty; + string _typedSignatureFont = "'Brush Script MT', cursive"; + string _signerFullName = string.Empty; + string _signerPosition = string.Empty; + string _signaturePlace = string.Empty; + + // ----- Lifecycle ----- + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrWhiteSpace(EnvelopeKey)) + { + _errorMessage = "Envelope-Schlüssel fehlt."; + _isLoading = false; + return; + } + + // Authorization — same pattern as EnvelopeReceiverPage + _receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey); + if (_receiverUser is null) + { + Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}"); + return; + } + + try + { + // Load PDF bytes via MediatR (uses authenticated user's claims) + _pdfBytes = await PageDataService.GetDocumentAsync(_receiverUser); + if (_pdfBytes is not { Length: > 0 }) + { + _errorMessage = "Dokument konnte nicht geladen werden: Keine Daten empfangen."; + _isLoading = false; + return; + } + + // Load signature fields for this receiver + _signatures = await PageDataService.GetSignaturesAsync(_receiverUser); + + // Load envelope receiver metadata + _envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey); + if (_envelopeReceiver is null) + Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey); + + // Build initial report (no signature image yet) + _report = BuildReport(_pdfBytes, _signatures, capturedSignature: null); + + // Try to restore cached signature + try + { + var cachedSignature = await PageDataService.GetCachedSignatureAsync(_receiverUser); + if (cachedSignature is not null) + { + _capturedSignature = cachedSignature; + _signerFullName = cachedSignature.FullName; + _signerPosition = cachedSignature.Position; + _signaturePlace = cachedSignature.Place; + _signaturePopupVisible = false; + + // Rebuild with cached signature overlaid + _report = BuildReport(_pdfBytes, _signatures, _capturedSignature); + } + else + { + _activeSignatureTab = SignatureTabDraw; + _signaturePopupVisible = _signatures.Count > 0; + _popupValidationMessage = null; + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to load cached signature for {EnvelopeKey}", EnvelopeKey); + _activeSignatureTab = SignatureTabDraw; + _signaturePopupVisible = _signatures.Count > 0; + _popupValidationMessage = null; + } + } + catch (Exception ex) + { + _errorMessage = $"Fehler beim Laden des Dokuments: {ex.Message}"; + Logger.LogError(ex, "Unexpected error for {EnvelopeKey}", EnvelopeKey); + } + + _isLoading = false; + await InvokeAsync(StateHasChanged); + } + + // ----- Report builder ----- + /// + /// Builds an XtraReport wrapping the PDF bytes. + /// If a signature is captured and there are signature fields, the signature image is + /// first burned into the PDF via DevExpress PdfDocumentProcessor, then the modified + /// PDF is handed to XRPdfContent with GenerateOwnPages = true so that all pages appear. + /// + static XtraReport BuildReport( + byte[] pdfBytes, + IReadOnlyList signatures, + SignatureCaptureDto? capturedSignature) + { + // Burn signatures into PDF bytes when a captured signature is available + byte[] sourcePdf = pdfBytes; + if (capturedSignature is not null + && !string.IsNullOrWhiteSpace(capturedSignature.DataUrl) + && signatures.Count > 0) + { + sourcePdf = BurnSignaturesIntoPdf(pdfBytes, signatures, capturedSignature); + } + + var report = new XtraReport + { + PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4, + Landscape = false, + Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0), + }; + + var detail = new DetailBand { HeightF = 0f }; + report.Bands.Add(detail); + + // GenerateOwnPages = true (default): each PDF page becomes a separate report page + var pdfContent = new XRPdfContent + { + Source = sourcePdf, + GenerateOwnPages = true, + }; + detail.Controls.Add(pdfContent); + + return report; + } + + /// + /// Burns signature images directly into the PDF using DevExpress PdfGraphics API. + /// Coordinates: DB stores INCHES with top-left origin, Y down. + /// PDF coordinate system: bottom-left origin, Y up, unit = points (1/72 inch). + /// Note: Implementation placeholder — requires DevExpress.Pdf.Drawing API wiring (Problem 2). + /// + static byte[] BurnSignaturesIntoPdf( + byte[] pdfBytes, + IReadOnlyList signatures, + SignatureCaptureDto capturedSignature) + { + // TODO: Implement with PdfGraphics when Problem 2 is addressed. + // For now return unmodified PDF so Problem 1 (all pages) can be verified first. + return pdfBytes; + } + + /// Converts a base64 data URL (data:image/...;base64,...) to raw bytes. + static byte[]? DataUrlToBytes(string dataUrl) + { + try + { + var commaIndex = dataUrl.IndexOf(','); + if (commaIndex < 0) return null; + return Convert.FromBase64String(dataUrl[(commaIndex + 1)..]); + } + catch + { + return null; + } + } + + // ----- Signature popup handlers ----- + void OpenSignaturePopup() + { + _activeSignatureTab = SignatureTabDraw; + _signaturePopupVisible = true; + _popupValidationMessage = null; + } + + async Task OnPopupShownAsync() + { + await InitializeActiveSignatureTabAsync(); + } + + async Task SetSignatureTabAsync(string tab) + { + _activeSignatureTab = tab; + _popupValidationMessage = null; + await InvokeAsync(StateHasChanged); + await Task.Delay(50); + await InitializeActiveSignatureTabAsync(); + } + + async Task InitializeActiveSignatureTabAsync() + { + if (_activeSignatureTab == SignatureTabDraw) + await JSRuntime.InvokeVoidAsync("receiverSignature.initialize", DrawCanvasId); + else if (_activeSignatureTab == SignatureTabText) + { + await JSRuntime.InvokeVoidAsync("receiverSignature.initializeTyped", TypedCanvasId); + await RenderTypedSignatureAsync(); + } + else + await JSRuntime.InvokeVoidAsync("receiverSignature.initializeImage", ImageInputId, ImageCanvasId); + } + + async Task RenewSignatureAsync() + { + _popupValidationMessage = null; + if (_activeSignatureTab == SignatureTabDraw) + await JSRuntime.InvokeVoidAsync("receiverSignature.clear", DrawCanvasId); + else if (_activeSignatureTab == SignatureTabText) + { + _typedSignatureText = string.Empty; + await JSRuntime.InvokeVoidAsync("receiverSignature.clearTyped", TypedCanvasId); + } + else + await JSRuntime.InvokeVoidAsync("receiverSignature.clearImage", ImageInputId, ImageCanvasId); + } + + async Task OnTypedSignatureChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args) + { + _typedSignatureText = args.Value?.ToString() ?? string.Empty; + await RenderTypedSignatureAsync(); + } + + async Task OnTypedSignatureFontChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args) + { + _typedSignatureFont = args.Value?.ToString() ?? _typedSignatureFont; + await RenderTypedSignatureAsync(); + } + + async Task RenderTypedSignatureAsync() + { + await JSRuntime.InvokeVoidAsync("receiverSignature.renderTypedSignature", + TypedCanvasId, _typedSignatureText, _typedSignatureFont); + } + + async Task SaveSignatureAsync() + { + if (string.IsNullOrWhiteSpace(_signerFullName)) + { + _popupValidationMessage = "Bitte geben Sie Vor- und Nachname ein."; + return; + } + if (string.IsNullOrWhiteSpace(_signaturePlace)) + { + _popupValidationMessage = "Bitte geben Sie den Ort ein."; + return; + } + + var signatureDataUrl = await GetActiveSignatureDataUrlAsync(); + if (string.IsNullOrWhiteSpace(signatureDataUrl)) + { + _popupValidationMessage = "Die Unterschrift ist erforderlich."; + return; + } + + _popupValidationMessage = null; + _capturedSignature = new SignatureCaptureDto + { + DataUrl = signatureDataUrl, + FullName = _signerFullName.Trim(), + Position = _signerPosition.Trim(), + Place = _signaturePlace.Trim(), + }; + _signaturePopupVisible = false; + + // Persist to cache (fire-and-forget) + if (_receiverUser is not null) + { + _ = Task.Run(async () => + { + try { await PageDataService.SaveCachedSignatureAsync(_receiverUser, _capturedSignature); } + catch { /* non-critical */ } + }); + } + + // Rebuild the report with signatures overlaid + if (_pdfBytes is not null) + { + var newReport = BuildReport(_pdfBytes, _signatures, _capturedSignature); + + if (_reportViewer is not null) + { + await _reportViewer.OpenReportAsync(newReport); + // Dispose previous report after opening new one + _report?.Dispose(); + } + + _report = newReport; + } + + await InvokeAsync(StateHasChanged); + } + + async Task GetActiveSignatureDataUrlAsync() + { + if (_activeSignatureTab == SignatureTabDraw) + return await JSRuntime.InvokeAsync("receiverSignature.getDataUrl", DrawCanvasId); + + if (_activeSignatureTab == SignatureTabText) + { + await RenderTypedSignatureAsync(); + return await JSRuntime.InvokeAsync("receiverSignature.getTypedDataUrl", TypedCanvasId); + } + + return await JSRuntime.InvokeAsync("receiverSignature.getImageDataUrl", ImageCanvasId); + } + + // ----- Disposal ----- + public void Dispose() + { + _report?.Dispose(); + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj index 1d74f2b3..7b44b2af 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj @@ -33,6 +33,7 @@ +