# EnvelopeGenerator — Copilot Context Notes (English) ## Purpose A digital document signing system. Senders upload PDFs and place signature annotation fields via PSPDFKit (EnvelopeGenerator.Web). Receivers open the document in a Blazor WASM viewer, confirm each signature field via a checkbox overlay, draw/type/upload their signature, and export the stamped PDF. --- ## Solution Structure | Project | Target | Description | |---|---|---| | `EnvelopeGenerator.API` | net8.0 | ASP.NET Core Web API. Receiver auth (cookie), annotation reading, PDF serving. | | `EnvelopeGenerator.ReceiverUI` | net8.0 WASM | Blazor WebAssembly. Receiver UI. YARP proxies API calls. | | `EnvelopeGenerator.Web` | net7/8/9 | Razor Pages. Sender UI + PSPDFKit annotation placement. | | `EnvelopeGenerator.Application` | multi | MediatR CQRS handlers. | | `EnvelopeGenerator.Domain` | multi | Domain models, constants, interfaces. | | `EnvelopeGenerator.Infrastructure` | multi | EF Core repos, DB context. | | `EnvelopeGenerator.PdfEditor` | multi | iText7 utilities. NOT used in ReceiverUI flow. | | `EnvelopeGenerator.DependencyInjection` | multi | DI registration helpers. | | VB.NET projects (Service/Form/BBTests) | net462 | Legacy. Do NOT touch. | --- ## Key Files | File | Purpose | |---|---| | `ReceiverUI/Pages/EnvelopeViewer.razor` | **NEW** PDF.js-based viewer (`/envelope/{key}`). Replaces ReportViewer.razor. Simple read-only PDF viewing with zoom/navigation. | | `ReceiverUI/Pages/ReportViewer.razor` | **LEGACY** DevExpress-based signing page (`/receiver/{key}`). Still used for signature workflow. Will be deprecated. | | `ReceiverUI/wwwroot/js/pdf-viewer.js` | **NEW** PDF.js wrapper: rendering, zoom, pagination, mouse wheel control. | | `ReceiverUI/wwwroot/js/receiver-signature.js` | JS: checkbox overlay, signature pad (draw/type/image). | | `ReceiverUI/wwwroot/css/envelope-viewer.css` | **NEW** Styles for EnvelopeViewer.razor (external CSS, not inline). | | `ReceiverUI/wwwroot/fake-data/annotations.json` | Dev-mode fake annotations (YARP proxy target). | | `ReceiverUI/Models/AnnotationDto.cs` | Annotation position model. All properties non-nullable. | | `ReceiverUI/Services/AnnotationService.cs` | Fetches `List` from API or fake-data. | | `ReceiverUI/Services/DocumentService.cs` | Fetches PDF bytes from API. | | `ReceiverUI/Services/AuthService.cs` | Manages receiver session cookie. | | `API/Controllers/AnnotationController.cs` | GET `api/Annotation/{key}` ? annotation list. | | `API/Controllers/DocumentController.cs` | GET `api/Document/{key}` ? PDF bytes. | --- ## SignatureDto / AnnotationDto — Coordinate System **Database Storage Format:** INCHES (GdPicture14 native unit) **Origin:** Top-left corner of page **Axes:** X increases rightward, Y increases downward ### Source Evidence (VB.NET Legacy Code) ```vb ' From: EnvelopeGenerator.Form/frmFieldEditor.vb ' GdPicture14.Annotations.AnnotationStickyNote 'Breite und Höhe in Inches (4,5*5cm) Private Const SIGNATURE_WIDTH As Single = 1.77 ' 1.77 inches = 4.5cm Private Const SIGNATURE_HEIGHT As Single = 1.96 ' 1.96 inches = 5cm Sub LoadAnnotation(pElement As Signature, ...) oAnnotation.Left = CSng(pElement.X) ' Direct assignment ? INCHES oAnnotation.Top = CSng(pElement.Y) oAnnotation.Width = CSng(pElement.Width) oAnnotation.Height = CSng(pElement.Height) End Sub ``` ### Conversion Formulas ``` Inches ? DevExpress (DX): x_DX = x_inches * 100.0 y_DX = y_inches * 100.0 Inches ? PDF Points: x_pt = x_inches * 72.0 y_pt = x_inches * 72.0 Inches ? PDF.js Canvas: normalize to [0,1], then scale to pixels x_norm = x_inches / pageWidth_inches y_norm = y_inches / pageHeight_inches x_px = x_norm * canvasWidth * scale * dpr y_px = y_norm * canvasHeight * scale * dpr ``` ### Unit Comparison Table | System | Unit | Origin | Conversion from INCHES | |--------|------|--------|------------------------| | **GdPicture14** (Source) | **Inches** | Top-left | Database format (no conversion) | | DevExpress (LEGACY) | 1/100 inch (DX) | Top-left | `x_DX = x_inches * 100` | | PDF.js (NEW) | Pixels | Top-left | `normalize ? scale` | | PDF Points (iText7) | Points (1/72") | **Bottom-left** | `x_pt = x_inches * 72` + Y-flip | | PSPDFKit (Web) | Points (1/72") | Top-left | `x_pt = x_inches * 72` | **A4 Page Dimensions:** - Width: 8.27 inches = 595 points = 827 DX units - Height: 11.69 inches = 842 points = 1169 DX units --- ## EnvelopeViewer (NEW) — PDF.js Read-Only Viewer **Route:** `/envelope/{EnvelopeKey}` **Purpose:** Modern, high-performance PDF viewing without signing functionality. **Technology:** PDF.js 3.11.174 + custom JavaScript + configurable quality settings ### Architecture **Blazor Component (`EnvelopeViewer.razor`):** - Fetches PDF via `DocumentService.GetDocumentAsync(EnvelopeKey)` - Converts to base64 data URL: `data:application/pdf;base64,{base64}` - Initializes PDF.js viewer via JSInterop with `DotNetObjectReference` for callbacks - Quality settings loaded from `appsettings.json` via `IOptions` - Thumbnail sidebar with resizable splitter (150px-400px, localStorage persistence) - CSS externalized to `envelope-viewer.css` **JavaScript (`pdf-viewer.js`):** ```javascript window.pdfViewer = { qualityOptions, // Configurable from appsettings.json setQualityOptions(options), // Dynamic quality update initialize(canvasId, pdfDataUrl, dotNetRef), renderPage(num), renderThumbnail(pageNum, canvasId), attachWheelEvent(), // Ctrl+Wheel zoom (configurable step) zoomIn(), zoomOut(), // Configurable step percentage dispose() } ``` **Options (`PdfViewerOptions.cs`):** ```csharp public class PdfViewerOptions { public double ThumbnailBaseScale { get; set; } = 0.75; // 0.2-1.5 public bool ThumbnailEnableHiDPI { get; set; } = true; public double ThumbnailMaxDPR { get; set; } = 2.0; // 1.0-3.0 public bool MainCanvasEnableHiDPI { get; set; } = true; public double MainCanvasMaxDPR { get; set; } = 2.0; public bool EnableSmoothZoom { get; set; } = true; public int ZoomTransitionDuration { get; set; } = 150; // ms public double RenderingOpacity { get; set; } = 0.85; // 0.0-1.0 public int ThumbnailRenderDelay { get; set; } = 50; // ms public int ZoomStepPercentage { get; set; } = 5; // 1-50% } ``` ### Configuration (appsettings.json) **Location:** `EnvelopeGenerator.ReceiverUI/wwwroot/appsettings.json` ```json { "PdfViewer": { "ThumbnailBaseScale": 0.75, "ThumbnailEnableHiDPI": true, "ThumbnailMaxDPR": 2.0, "MainCanvasEnableHiDPI": true, "MainCanvasMaxDPR": 2.0, "EnableSmoothZoom": true, "ZoomTransitionDuration": 150, "RenderingOpacity": 0.85, "ThumbnailRenderDelay": 50, "ZoomStepPercentage": 5 } } ``` **Usage:** Edit file ? F5 (browser refresh) ? new settings applied **Presets:** - **High Quality**: `ThumbnailBaseScale: 1.0, MaxDPR: 3.0` (powerful devices) - **Balanced**: Default values (recommended) - **Performance**: `ThumbnailBaseScale: 0.5, EnableHiDPI: false` (mobile/low-end) --- ### Features 1. **HiDPI/Retina Support** ? 4x quality on Retina displays 2. **Configurable Quality** ? All parameters in appsettings.json 3. **Unlimited Zoom** ? 50%-300%, configurable step (default 5%) 4. **Global Ctrl+Wheel Zoom** ? Works anywhere on page 5. **Thumbnail Sidebar** ? Resizable (150-400px), high-quality rendering 6. **Smooth Transitions** ? Configurable fade effect 7. **Responsive Design** ? Desktop/mobile adaptive layout --- ### Features 1. **Unlimited Zoom:** - Canvas size not restricted by `max-width` - Frame stays fixed, scroll bars appear automatically - `text-align: center` for small sizes, full scroll for zoomed views 2. **Global Mouse Wheel Zoom:** - Event listener on `document.body` (works anywhere on page) - `Ctrl + Mouse Wheel` triggers `zoomIn()`/`zoomOut()` - Calls `dotNetReference.invokeMethodAsync('OnZoomChanged', scale)` to update Blazor UI - `{ passive: false }` to enable `preventDefault()` 3. **Render Task Cancellation:** - Stores `currentRenderTask` to cancel previous render if new one starts - Catches `RenderingCancelledException` to avoid console errors - Queue system (`pageNumPending`) for rapid page changes 4. **Thumbnail Sidebar:** - Left panel with page previews (sequential rendering, 50ms delay) - Click to navigate to specific page - Active page highlighted with gradient border - No header/title (maximizes thumbnail space) - Toggle button in toolbar to show/hide 5. **Resizable Splitter:** - 4px draggable divider between thumbnails and canvas - Min width: 150px, Max width: 400px - Visual feedback: gradient on hover/active - User preference saved to localStorage (`envelopeViewer_thumbnailWidth`) - Global mouse events (works anywhere during drag) - `col-resize` cursor (?) for intuitive UX 6. **Flex Layout:** - Thumbnails and canvas in same container (`.pdf-frame`) - `display: flex, flex-direction: row, align-items: stretch` - Perfect vertical alignment (same top/bottom position) - Responsive: column layout on mobile (<768px) 7. **Responsive Design:** - Desktop: 90% width, 1200px max - Mobile: 95% width, adjusted heights, thumbnails collapse to top - Adaptive padding and font sizes ### Initialization Flow ```csharp OnInitializedAsync(): 1. Fetch PDF bytes from DocumentService 2. Convert to base64 data URL 3. Set _isLoading = false OnAfterRenderAsync(firstRender): 1. Load saved thumbnail width from localStorage 2. Create DotNetObjectReference 3. Send PdfViewerOptions to JavaScript 4. Initialize PDF.js viewer 5. Attach splitter resize listeners 6. Render thumbnails sequentially (configurable delay) ``` **User Interactions:** - Zoom: Buttons/Ctrl+Wheel/Slider ? configurable step percentage - Pages: Buttons/Input/Thumbnails ? navigate - Sidebar: Toggle button ? show/hide thumbnails - Splitter: Drag ? resize sidebar (150-400px) **Cleanup:** ```csharp DisposeAsync(): - Dispose PDF.js viewer - Detach event listeners - Dispose DotNetObjectReference ``` --- ### Key Differences from ReportViewer | Feature | EnvelopeViewer (NEW) | ReportViewer (LEGACY) | |---------|----------------------|------------------------| | Technology | PDF.js + Canvas | DevExpress XtraReports | | Route | `/envelope/{key}` | `/receiver/{key}` | | Purpose | Read-only viewing | Signature workflow | | Dependencies | PDF.js CDN | DevExpress NuGet packages | | Zoom | Unlimited, smooth | Report viewer default | | Mouse Wheel | Custom Ctrl+Wheel | Browser default | | File Size | Minimal (JS + CSS) | Heavy (DX libs) | | Maintenance | Simple, standard web | Complex, vendor-specific | --- ## ReceiverUI Signing Flow (ReportViewer.razor — LEGACY) ### On Load (`OnInitializedAsync`) 1. `AuthService.CheckEnvelopeAccessAsync` ? redirect to login if unauthorized 2. `AnnotationService.GetAnnotationsAsync` ? fills `_annotations` 3. `DocumentService.GetDocumentAsync` ? fills `_basePdfBytes` (real mode) 4. `BuildFreshBaseReport()` ? `XtraReport` for `DxReportViewer` ### Signature Popup - Tabs: Draw / Text / Image - Fields: full name (required), position (optional), place (required) - Saved to `_capturedSignature` record - If annotations exist ? popup closes ? JS checkbox overlays installed ### JS Checkbox Overlay (`receiver-signature.js`) - `receiverSignature.installAnnotationCheckboxes(annotations, checkedIds, dotNetRef)` - One `.annot-sig-cb-wrapper` div per annotation, absolutely positioned over viewer scroll container - Position: `left = pageRect.left + ann.x * scaleX`, `top = pageRect.top + ann.y * scaleY` - `scaleX = pagePixelWidth / 827`, `scaleY = pagePixelHeight / 1169` - Click ? `dotNetRef.invokeMethodAsync('OnAnnotationToggled', id, checked)` ### Apply Signatures ("Unterschriften anwenden" — `SubmitSignaturesAsync`) **Real PDF mode (`_basePdfBytes` is set):** - Calls `StampSignaturesOnPdf` using **iText7** directly on PDF bytes - Coordinate conversion: `xPt = ann.X * (72/100)`, `yPt = pageHeight - ann.Y * (72/100) - sigHeight` (Y flip: DX top-down ? PDF bottom-up) - Returns stamped bytes ? loaded into new `XtraReport` with `XRPdfContent` - Viewer refreshed with `ViewerKey++` **Dev/fake mode (`_basePdfBytes` is null):** - Falls back to `AddSignatureAtAnnotation` (XtraReports DetailBand + BeforePrint counter) - This is intentionally left as a dev fallback only ### Export - `reportViewer.ExportToAsync(ExportFormat.Pdf)` --- ## StampSignaturesOnPdf — iText7 Implementation ```csharp // Located in ReportViewer.razor @code section // Called by SubmitSignaturesAsync when _basePdfBytes is available static byte[] StampSignaturesOnPdf( byte[] sourcePdfBytes, byte[] signatureImageBytes, string signerFullName, string signerPosition, string signaturePlace, IReadOnlyList annotations) { // Opens PDF with PdfReader/PdfWriter // For each annotation: // pageNum = ann.Page (clamped to totalPages) // xPt = ann.X * (72f/100f) // imgBottomY = pageHeight - ann.Y * (72f/100f) - sigHeightPt ? Y-axis flip // PdfCanvas.AddImageFittedIntoRectangle(imageData, rect, false) // Separator line at bottom of image // Canvas text block below separator (font: Helvetica 7pt, color: RGB(73,80,87)) // Returns stamped byte[] } ``` --- ## BuildFreshBaseReport() ```csharp // Real PDF mode: var report = new XtraReport(); var detail = new DetailBand(); report.Bands.Add(detail); detail.Controls.Add(new XRPdfContent { Source = _basePdfBytes, GenerateOwnPages = true }); return report; // Dev/fake mode: returns pre-built report from ReportStorage ``` --- ## NuGet Packages in ReceiverUI | Package | Version | Purpose | |---|---|---| | `DevExpress.Blazor.Reporting.Viewer` | 25.2.3 | DxReportViewer (LEGACY, used in ReportViewer.razor) | | `DevExpress.Blazor.PdfViewer` | 25.2.3 | PDF viewer (not used in EnvelopeViewer) | | `DevExpress.Drawing.Skia` | 25.2.3 | Drawing backend | | `itext` | 8.0.5 | PDF stamping (iText7) | | `SkiaSharp.*` | 3.119.1 | WASM native rendering | **External CDN (EnvelopeViewer):** - PDF.js 3.11.174 (via `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js`) - PDF.js Worker (`pdf.worker.min.js`) --- ## Mistakes History — Do NOT Repeat | Mistake | Why Wrong | Session | |---|---|---| | `BottomMarginBand` for per-page signatures | Repeats on every page; Y offset wrong | 4 | | `imageY = (page-1) * 1169 + ann.Y` | Inflates DetailBand; 35 pages ? 140 pages | 8 | | `e.Graph?.PrintingSystem` in BeforePrint | `Graph` not on `CancelEventArgs` | 5 | | `ctrl.Report?.PrintingSystem` | `PrintingSystem` not on `XtraReportBase` in WASM | — | | Adding stamp endpoint to `DocumentController` | Not needed; stamping is done client-side in ReceiverUI | — | | iText7 via API (server-side) | Unnecessary; iText7 runs fine in WASM directly | 10 | | **PDF.js: Hardcoded quality values** | **Use appsettings.json for configurability** | **11** | | **PDF.js: Hardcoded zoom step (1%)** | **Too granular; use configurable percentage** | **11** | | **Toolbar: Complex left/center/right layout** | **User wants simple horizontal layout; failed multiple times to implement** | **11** | | **Zoom label: Badge style (gradient/border/padding)** | **Over-designed; user prefers simple text label** | **11** | | **Attempting to "improve" simple designs** | **User requests simplicity; AI keeps over-engineering** | **11** | | **Ignoring explicit "revert" instructions** | **User said revert toolbar, AI tried to fix CSS instead of reverting HTML structure** | **11** | --- ## DevExpress Article (2023-08-28) — Why It Does NOT Apply The article describes **X.509 cryptographic digital signatures** via `PdfDocumentSigner` + `Pkcs7Signer`. Our use case is **visual/image stamping** at specific page coordinates — different problem, different API. `XRPdfSignature` in the article requires pre-placed fields in the report designer, not runtime coordinates. --- ## Layout Architecture (EnvelopeViewer) ### HTML Structure ```html
@if (_showThumbnails) {
}
``` ### CSS Flexbox Layout ```css .pdf-frame { display: flex; flex-direction: row; /* Side-by-side */ align-items: stretch; /* Same height */ overflow: hidden; } .pdf-thumbnails { flex-shrink: 0; /* Fixed width */ width: 260px; /* Dynamic via inline style */ border-right: none; /* Seamless join with splitter */ } .pdf-splitter { width: 4px; cursor: col-resize; flex-shrink: 0; } .pdf-canvas-wrapper { flex: 1; /* Fill remaining space */ overflow: auto; /* Scrollable for zoom */ padding: 2rem; text-align: center; } ``` ### Resizable Splitter Workflow ``` 1. User hovers splitter ? cursor: col-resize (?) 2. Mouse down ? OnSplitterMouseDown(e) - _isResizing = true - Store start position (clientX) and width - Add 'resizing' class to body (prevent text selection) - Call pdfViewer.startResize() 3. Mouse move (global) ? OnSplitterMouseMove(clientX) - Calculate delta = clientX - startX - newWidth = startWidth + delta - Clamp to 150-400px range - Update _thumbnailWidth - StateHasChanged() for reactive UI 4. Mouse up (global) ? OnSplitterMouseUp() - _isResizing = false - Remove 'resizing' class - Save to localStorage("envelopeViewer_thumbnailWidth") ``` ### Responsive Behavior - **Desktop (>768px)**: Flex row, side-by-side - **Mobile (?768px)**: Flex column, thumbnails on top - **Thumbnail toggle**: Controlled by `@if (_showThumbnails)` in Razor markup --- ## Signature Buttons in EnvelopeViewer — Interactive Overlay System **Purpose:** Render clickable "Unterschreiben" (Sign) buttons on PDF canvas at signature field positions fetched from database. ### Architecture **Blazor Component (`EnvelopeViewer.razor`):** ```csharp IReadOnlyList _signatures = []; protected override async Task OnInitializedAsync() { var signatures = await SignatureService.GetAsync(EnvelopeKey); _signatures = signatures.Convert(UnitOfLength.Point); // INCHES ? POINTS } async Task RenderSignatureButtonsAsync() { await JSRuntime.InvokeVoidAsync("pdfViewer.renderSignatureButtons", _signatures, _currentPage, _dotNetRef); } [JSInvokable] public void OnSignatureButtonClick(int signatureId) { Console.WriteLine($"Signature #{signatureId} signed"); } ``` **JavaScript (`pdf-viewer.js`):** ```javascript renderSignatureButtons(signatures, currentPageNum, dotNetRef) { this.clearSignatureButtons(); // Remove old buttons const pageSignatures = signatures.filter(sig => sig.page === currentPageNum); const signatureLayer = document.getElementById('pdf-signature-layer'); pageSignatures.forEach(sig => { // Convert POINTS to display pixels const xPx = sig.x * this.scale; // sig.x already in PDF POINTS const yPx = sig.y * this.scale; const button = document.createElement('button'); button.className = 'signature-button'; button.style.left = `${xPx}px`; button.style.top = `${yPx}px`; button.style.width = '150px'; button.style.height = '60px'; // German text + pen icon button.innerHTML = `
Unterschreiben
... `; button.addEventListener('click', () => { this.dotNetReference.invokeMethodAsync('OnSignatureButtonClick', sig.id); }); signatureLayer.appendChild(button); this.signatureButtons.push(button); }); } clearSignatureButtons() { this.signatureButtons.forEach(btn => btn.parentNode?.removeChild(btn)); this.signatureButtons = []; } ``` **HTML Structure:** ```html
``` **CSS (`envelope-viewer.css`):** ```css .pdf-signature-layer { position: absolute; left: 0; top: 0; right: 0; bottom: 0; overflow: visible; pointer-events: none; /* Pass clicks through to canvas */ z-index: 20; } .signature-button { pointer-events: auto; /* Re-enable for buttons */ position: absolute; width: 150px; height: 60px; background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); color: white; border: none; border-radius: 8px; cursor: pointer; /* Hover: scale(1.05), shadow, darker gradient */ } ``` ### Rendering Triggers 1. **Initial Load:** After PDF renders (`OnAfterRenderAsync`) 2. **Page Change:** `NextPage()`, `PreviousPage()`, `GoToPageFromThumbnail()` 3. **Zoom Change:** `OnZoomChanged()` (re-calculates pixel positions) ### Coordinate Conversion Flow ``` Database (INCHES) ? SignatureService.GetAsync() SignatureDto.X/Y (INCHES) ? .Convert(UnitOfLength.Point) SignatureDto.X/Y (PDF POINTS, × 72) ? JavaScript Display Pixels (× this.scale) ? CSS button.style.left/top ``` **Example Calculation:** - Database: `X = 1.5 inches, Y = 2.0 inches` - After conversion: `X = 108 points, Y = 144 points` (× 72) - At scale 1.5: `X = 162px, Y = 216px` - Button positioned at `left: 162px, top: 216px` ### Button Design **Visual Spec:** - **Size:** 150px × 60px - **Background:** Purple gradient `#4F46E5` ? `#4338CA` (hover: darker) - **Text:** "Unterschreiben" (18px, bold, white) - **Icon:** Pen SVG (24px white) - **Effects:** - Hover: `scale(1.05)` + shadow `0 4px 12px rgba(79, 70, 229, 0.4)` - Active: `scale(0.98)` - Focus: `2px solid #7e22ce` outline **Accessibility:** - `tabindex="0"` for keyboard navigation - Focus outline for keyboard users - Click handler with semantic button element --- ## Signature Workflow in EnvelopeViewer — NEW Implementation (Session 13-14) **IMPORTANT: iText7 NOT USED in EnvelopeViewer** - **Reason:** GPL license incompatibility (requires source code sharing) - **Alternative:** Client-side signature overlay system (HTML + Canvas API) - **Export:** Signatures are visual overlays only, NOT stamped on PDF bytes - **Future:** Consider PSPDFKit or commercial PDF library for actual PDF stamping ### Signature Data Structure **Captured Signature (`SignatureCaptureDto`):** ```csharp // Model: EnvelopeGenerator.ReceiverUI/Models/SignatureCaptureDto.cs public sealed record SignatureCaptureDto { public required string DataUrl { get; init; } // base64 PNG: "data:image/png;base64,iVBORw0KG..." public required string FullName { get; init; } // Required: "Max Mustermann" public string Position { get; init; } = string.Empty; // Optional: "Geschäftsführer" public required string Place { get; init; } // Required: "Berlin" } // Usage in components: SignatureCaptureDto? _capturedSignature; // Initialization with object initializer (required properties): _capturedSignature = new SignatureCaptureDto { DataUrl = signatureDataUrl, FullName = _signerFullName.Trim(), Position = _signerPosition.Trim(), Place = _signaturePlace.Trim() }; ``` **Applied Signature (HTML Overlay):** ```html
Max Mustermann
Geschäftsführer
Berlin, 26.01.2025
``` ### Complete Workflow (Session 14 Update) **Step 1: Page Load & Automatic Popup** ```csharp protected override async Task OnInitializedAsync() { // ... load PDF and signatures ... // Open signature popup automatically _activeSignatureTab = SignatureTabDraw; _signaturePopupVisible = true; _popupValidationMessage = null; } ``` **Features:** - Opens automatically on page load (no manual trigger needed) - Cannot be closed manually (no X button, ESC disabled, no outside-click) - User MUST create signature before viewing PDF **Step 2: Signature Creation Popup (DxPopup)** **Tabs:** 1. **Zeichnen (Draw):** Canvas-based signature pad (`receiver-signature.js`) - Touch-friendly: `touch-action: none` - Line width: 2.5px, black (`#111`) - Canvas: 560×180px, rounded corners, shadow 2. **Text:** Type signature with font selection - Fonts: Brush Script, Segoe Script, Lucida Handwriting, Comic Sans, Cursive - Real-time preview on canvas 3. **Bild (Image):** Upload PNG/JPG/WebP - File input with preview - Auto-resize to fit canvas **Required Fields:** - ? **Vor- und Nachname** (Full Name) — Red asterisk `*` - ? **Ort** (Place) — Red asterisk `*` - ? **Position** — Optional, gray "(optional)" label **Validation:** ```csharp 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; } // Save to session state _capturedSignature = new(signatureDataUrl, _signerFullName.Trim(), _signerPosition.Trim(), _signaturePlace.Trim()); _signaturePopupVisible = false; } ``` **Design (Modern & Clean):** - **Tabs:** Purple active state (`#4F46E5`), 3px bottom border - **Inputs:** 2px solid border (`#e9ecef`), 6px border-radius, consistent padding - **Canvas:** Light shadow (`0 1px 3px rgba(0,0,0,0.1)`), rounded corners - **Buttons:** - **Erneuern:** Outline secondary with refresh icon - **Speichern:** Purple gradient + checkmark icon + shadow - **Error:** Red left border (`4px solid #dc3545`), light red background (`#fee`) **Step 3: PDF Viewing with Signature Buttons** After popup closes: ```csharp protected override async Task OnAfterRenderAsync(bool firstRender) { // ... PDF initialization ... await RenderSignatureButtonsAsync(); // Render "Unterschreiben" buttons } ``` **Buttons appear at signature field positions:** - Purple gradient background - "Unterschreiben" text + pen icon - Hover: scale(1.05) + darker color - Positioned using PDF POINTS ? display pixels conversion **Step 4: Apply Signature (Click "Unterschreiben")** **C# Handler:** ```csharp [JSInvokable] public async Task OnSignatureButtonClick(int signatureId) { if (_capturedSignature == null) return; await JSRuntime.InvokeVoidAsync("pdfViewer.applySignature", signatureId, _capturedSignature.DataUrl, _capturedSignature.FullName, _capturedSignature.Position, _capturedSignature.Place); } ``` **JavaScript Implementation:** ```javascript async applySignature(signatureId, signatureDataUrl, fullName, position, place) { // 1. Find and remove button const button = this.signatureButtons.find(btn => btn.getAttribute('data-signature-id') == signatureId); button.parentNode.removeChild(button); // 2. Create signature container (German standard format) const signatureContainer = document.createElement('div'); signatureContainer.className = 'applied-signature'; signatureContainer.style.position = 'absolute'; signatureContainer.style.left = button.style.left; // Same position as button signatureContainer.style.top = button.style.top; signatureContainer.style.width = '230px'; signatureContainer.style.backgroundColor = '#f8f9fa'; signatureContainer.style.border = '1px solid #dee2e6'; signatureContainer.style.borderRadius = '6px'; signatureContainer.style.padding = '12px'; // 3. Add signature image const img = document.createElement('img'); img.src = signatureDataUrl; img.style.width = '100%'; img.style.maxHeight = '70px'; img.style.objectFit = 'contain'; // 4. Add separator line (German standard) const separator = document.createElement('div'); separator.style.borderTop = '1px solid #495057'; separator.style.marginTop = '6px'; separator.style.marginBottom = '8px'; // 5. Add text information const today = new Date(); const dateStr = today.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); // "26.01.2025" const infoHtml = [ `${this.escapeHtml(fullName)}`, position ? this.escapeHtml(position) : null, `${this.escapeHtml(place)}, ${dateStr}` ].filter(x => x).join('
'); const info = document.createElement('div'); info.style.fontSize = '9px'; info.style.color = '#495057'; info.innerHTML = infoHtml; // 6. Assemble and add to layer signatureContainer.appendChild(img); signatureContainer.appendChild(separator); signatureContainer.appendChild(info); document.getElementById('pdf-signature-layer').appendChild(signatureContainer); } ``` **German Standard Layout:** ``` ??????????????????????????????? ? [Signature Image] ? ? Base64 PNG, max 70px height ? ? ??????????????????????????????? ? 1px separator (#495057) ? ? ? Max Mustermann (Bold) ? ? Name (font-weight: 600, #212529) ? Geschäftsführer ? ? Position (optional, normal weight) ? Berlin, 26.01.2025 ? ? Place, Date (dd.MM.yyyy) ? ? ??????????????????????????????? ``` **Step 5: Persistence & Re-rendering** - **Zoom/Page Change:** Applied signatures re-render automatically - **Session State:** `_capturedSignature` stored in Blazor component - **Limitation:** Lost on page refresh (no server-side storage) - **Future:** Export to PDF with actual byte stamping (requires non-GPL library) **Security:** ```javascript escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; // Browser auto-escapes return div.innerHTML; } ``` Protects against XSS attacks from malicious input in Name/Position/Place fields. --- ### Popup Design Specification **DxPopup Properties:** ```razor CloseOnOutsideClick="false" ShowCloseButton="false" CloseOnEscape="false"> ``` **Tab Design:** - **Active Tab:** `border-bottom: 3px solid #4F46E5; color: #4F46E5; font-weight: 600;` - **Inactive Tab:** `color: #6c757d;` - **Tab Bar:** `border-bottom: 2px solid #e9ecef;` **Input Styling:** ```css input, select { border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem; } ``` **Canvas Styling:** ```css canvas { border: 2px solid #e9ecef; border-radius: 8px; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } ``` **Button Styling:** ```css /* Erneuern (Renew) */ .btn-outline-secondary { border-radius: 6px; padding: 0.625rem 1.25rem; font-weight: 500; } /* Speichern (Save) */ .btn-primary { background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none; border-radius: 6px; padding: 0.625rem 2rem; font-weight: 600; box-shadow: 0 2px 4px rgba(79, 70, 229, 0.3); } ``` **Future Enhancement Required:** - Replace iText7 with commercial PDF library (e.g., PSPDFKit, Syncfusion) - Or use server-side stamping with Azure PDF Services - Or accept GPL license and open-source the stamping code --- ## Signature Caching System — Session 15 **Purpose:** Persist receiver signature across page refreshes using distributed cache (Redis/SQL Server). ### Architecture **API Cache Controller (`EnvelopeGenerator.API/Controllers/CacheController.cs`):** ```csharp [Route("api/[controller]")] [Authorize(Policy = AuthPolicy.Receiver)] public class CacheController(IDistributedCache cache, IOptions cacheOptions) : ControllerBase { private const string SignatureCacheKeyPrefix = "signature:91751687-8ae6-4777-bf5f-b8846085e62e:"; [HttpPost("SignatureCapture/{envelopeKey}")] public async Task SaveSignature(string envelopeKey, [FromBody] SignatureCaptureDto request, CancellationToken cancel) [HttpGet("SignatureCapture/{envelopeKey}")] public async Task GetSignature(string envelopeKey, CancellationToken cancel) [HttpDelete("SignatureCapture/{envelopeKey}")] public async Task DeleteSignature(string envelopeKey, CancellationToken cancel) } ``` **Cache Options (`EnvelopeGenerator.API/Options/CacheOptions.cs`):** ```csharp public sealed class CacheOptions { public const string SectionName = "Cache"; // If null, signatures never expire (until manual delete) public TimeSpan? SignatureCacheExpiration { get; set; } } ``` **Configuration (appsettings.json):** ```json { "Cache": { "SignatureCacheExpiration": null // Or "02:00:00" for 2 hours } } ``` **Blazor Service (`EnvelopeGenerator.ReceiverUI/Services/SignatureCacheService.cs`):** ```csharp public class SignatureCacheService(HttpClient http, IOptions apiOptions) { public async Task SaveSignatureAsync(string envelopeKey, SignatureCaptureDto signature, CancellationToken cancel = default) public async Task GetSignatureAsync(string envelopeKey, CancellationToken cancel = default) public async Task DeleteSignatureAsync(string envelopeKey, CancellationToken cancel = default) } ``` ### Workflow **1. Page Load (First Time):** ```csharp protected override async Task OnInitializedAsync() { // Try to load cached signature first try { var cachedSignature = await SignatureCacheService.GetSignatureAsync(EnvelopeKey); if (cachedSignature is not null) { _capturedSignature = cachedSignature; _signerFullName = cachedSignature.FullName; _signerPosition = cachedSignature.Position; _signaturePlace = cachedSignature.Place; _signaturePopupVisible = false; // Skip popup } else { // No cache - show popup _signaturePopupVisible = true; } } catch (Exception ex) { logger.LogWarning(ex, "Failed to load cached signature, showing popup"); _signaturePopupVisible = true; // Fallback to popup } } ``` **2. Save Signature:** ```csharp async Task SaveSignatureAsync() { // ... validation ... _capturedSignature = new SignatureCaptureDto { ... }; _signaturePopupVisible = false; // Save to cache (fire-and-forget, ignore errors) _ = Task.Run(async () => { try { await SignatureCacheService.SaveSignatureAsync(EnvelopeKey, _capturedSignature); } catch { // Ignore cache errors } }); } ``` **3. Change Signature (Toolbar Button):** ```csharp void OpenSignaturePopup() { _activeSignatureTab = SignatureTabDraw; _signaturePopupVisible = true; // Load current signature into form fields if (_capturedSignature is not null) { _signerFullName = _capturedSignature.FullName; _signerPosition = _capturedSignature.Position; _signaturePlace = _capturedSignature.Place; } } async Task OnPopupShownAsync() { await InitializeActiveSignatureTabAsync(); // Load existing signature image to canvas (Draw tab) if (_capturedSignature is not null && _activeSignatureTab == SignatureTabDraw) { await Task.Delay(100); await JSRuntime.InvokeVoidAsync("receiverSignature.loadExistingSignature", DrawCanvasId, _capturedSignature.DataUrl); } } ``` **JavaScript Helper:** ```javascript receiverSignature.loadExistingSignature(canvasId, dataUrl) { const canvas = document.getElementById(canvasId); const ctx = canvas.getContext('2d'); const img = new Image(); img.onload = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); state.hasSignature = true; }; img.src = dataUrl; } ``` **4. Restart Signing (Reset Button):** ```csharp void RestartSigning() { Navigation.NavigateTo(Navigation.Uri, forceLoad: true); // Page reload ? cache cleared ? popup shows } ``` ### Toolbar Button Design **Change Signature Button:** ```html ``` **CSS:** ```css .pdf-toolbar__btn--success { background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%); border-color: rgba(16, 185, 129, 0.3); color: #059669; } .pdf-toolbar__btn--success:hover:not(:disabled) { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; } ``` **States:** - **No Signature:** Blue button with pen icon - **Signature Created:** Green button with checkmark icon - **Hover:** Filled green gradient with white icon ### Cache Key Format ``` signature:91751687-8ae6-4777-bf5f-b8846085e62e:{envelopeKey} ``` - Prefix prevents collisions with other cache keys - GUID ensures uniqueness across application instances - `envelopeKey` is user-provided identifier (URL parameter) ### Error Handling Philosophy **API Controller:** No validation, no try-catch, no logging - Throws exceptions directly to caller - Blazor handles errors with try-catch **Blazor Service:** No try-catch - Throws `HttpRequestException` with status code + body - Component catches and handles **Blazor Component:** Try-catch with fallback - Graceful degradation: show popup on cache failure - Fire-and-forget saves: ignore errors ### Benefits 1. **UX:** No need to re-enter signature on page refresh 2. **Performance:** Fast cache retrieval (Redis/SQL) 3. **Security:** Per-receiver isolation (cookie-based auth) 4. **Flexibility:** Configurable expiration time 5. **Reliability:** Graceful degradation on cache failure ---