Add signature overlay workflow to PDF.js viewer
Implemented a complete client-side workflow for creating, applying, and displaying visual signature overlays on the PDF canvas. - Added "Unterschreiben" buttons with modern styling (purple gradient, hover effects) to apply signatures. - Introduced a signature creation popup (DxPopup) with Draw, Text, and Image tabs, including validation for required fields (Name, Place). - Rendered German-style signature overlays with image, separator line, and text (Name, Position, Place, Date in dd.MM.yyyy format). - Ensured automatic re-rendering of overlays on page load, zoom, and page changes. - Added `escapeHtml()` for XSS protection of user-provided text. - Styled popup, canvas, and buttons with a modern, clean design. - Suggested future enhancements for server-side stamping or integration with commercial PDF libraries.
This commit is contained in:
@@ -427,6 +427,16 @@ Our use case is **visual/image stamping** at specific page coordinates
|
||||
| **13** | **2025-01-XX** | **Added clickable signature buttons on PDF canvas (Sign/Unterschreiben)** |
|
||||
| **13** | **2025-01-XX** | **Signature buttons: 150px×60px, purple gradient, pen icon, hover effects** |
|
||||
| **13** | **2025-01-XX** | **JavaScript: `pdfViewer.renderSignatureButtons()` and `pdfViewer.clearSignatureButtons()`** |
|
||||
| **14** | **2025-01-26** | **Added signature creation popup (DxPopup) - opens automatically on page load** |
|
||||
| **14** | **2025-01-26** | **Popup features: 3 tabs (Draw/Text/Image), required fields (Name, Place), optional Position** |
|
||||
| **14** | **2025-01-26** | **Popup: No close button (X), no ESC/outside-click - only saves with validation** |
|
||||
| **14** | **2025-01-26** | **Modern, clean popup design matching EnvelopeViewer theme (purple gradients, rounded inputs)** |
|
||||
| **14** | **2025-01-26** | **Implemented German-style professional signature rendering on PDF canvas** |
|
||||
| **14** | **2025-01-26** | **Click "Unterschreiben" button ? removes button, renders applied signature** |
|
||||
| **14** | **2025-01-26** | **Signature layout: Image + separator line + Name (bold) + Position + Place, Date** |
|
||||
| **14** | **2025-01-26** | **JavaScript: `pdfViewer.applySignature()` creates HTML overlay (230px box, #f8f9fa background)** |
|
||||
| **14** | **2025-01-26** | **German date format: dd.MM.yyyy (e.g., 26.01.2025) via `toLocaleDateString('de-DE')`** |
|
||||
| **14** | **2025-01-26** | **XSS protection: `escapeHtml()` function sanitizes user-provided text** |
|
||||
|
||||
---
|
||||
|
||||
@@ -657,7 +667,7 @@ button.style.left/top
|
||||
|
||||
---
|
||||
|
||||
## Signature Workflow in EnvelopeViewer — NEW Implementation (Session 13+)
|
||||
## Signature Workflow in EnvelopeViewer — NEW Implementation (Session 13-14)
|
||||
|
||||
**IMPORTANT: iText7 NOT USED in EnvelopeViewer**
|
||||
- **Reason:** GPL license incompatibility (requires source code sharing)
|
||||
@@ -672,46 +682,280 @@ button.style.left/top
|
||||
record SignatureCapture(
|
||||
string DataUrl, // base64 PNG: "data:image/png;base64,iVBORw0KG..."
|
||||
string FullName, // Required: "Max Mustermann"
|
||||
string Position, // Optional: "Geschäftsführer"
|
||||
string Position, // Optional: "Geschäftsführer" (can be empty)
|
||||
string Place // Required: "Berlin"
|
||||
);
|
||||
```
|
||||
|
||||
**Applied Signature (per SignatureDto):**
|
||||
```javascript
|
||||
{
|
||||
signatureId: 42,
|
||||
dataUrl: "data:image/png;base64,iVBORw0KG...",
|
||||
fullName: "Max Mustermann",
|
||||
position: "Geschäftsführer",
|
||||
place: "Berlin",
|
||||
date: "15.01.2025",
|
||||
x: 108, // PDF POINTS (already converted from INCHES)
|
||||
y: 144, // PDF POINTS
|
||||
width: 150, // pixels
|
||||
height: 60 // pixels
|
||||
**Applied Signature (HTML Overlay):**
|
||||
```html
|
||||
<div class="applied-signature" data-signature-id="42" style="left: 162px; top: 216px;">
|
||||
<img src="data:image/png;base64,..." /> <!-- Signature image (max 70px height) -->
|
||||
<div style="border-top: 1px solid #495057;"></div> <!-- Separator line -->
|
||||
<div style="font-size: 9px; color: #495057;">
|
||||
<strong>Max Mustermann</strong> <!-- Name (bold, #212529) -->
|
||||
<br>Geschäftsführer <!-- Position (optional) -->
|
||||
<br>Berlin, 26.01.2025 <!-- Place, Date (dd.MM.yyyy) -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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;
|
||||
}
|
||||
```
|
||||
|
||||
### Workflow Steps
|
||||
**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 1: Signature Creation Popup**
|
||||
- Reuse `DxPopup` from ReportViewer.razor
|
||||
- 3 tabs: Draw / Text / Image (using `receiver-signature.js`)
|
||||
- Fields: Full name, Position (optional), Place
|
||||
- Click "Speichern" ? `_capturedSignature` saved to state
|
||||
**Step 2: Signature Creation Popup (DxPopup)**
|
||||
|
||||
**Step 2: Apply Signature to Canvas**
|
||||
- User clicks "Unterschreiben" button on PDF
|
||||
- JavaScript creates HTML overlay (NOT Canvas drawing)
|
||||
- Overlay repositions on zoom/page change
|
||||
- **NO PDF byte modification** (GPL license issue)
|
||||
**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
|
||||
|
||||
**Step 3: Visual Display Only**
|
||||
- Signatures shown as HTML `<div>` overlays
|
||||
- Positioned using absolute positioning
|
||||
- Persist in Blazor component state
|
||||
- Lost on page refresh (no server-side save)
|
||||
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 = [
|
||||
`<strong>${this.escapeHtml(fullName)}</strong>`,
|
||||
position ? this.escapeHtml(position) : null,
|
||||
`${this.escapeHtml(place)}, ${dateStr}`
|
||||
].filter(x => x).join('<br>');
|
||||
|
||||
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
|
||||
<DxPopup @bind-Visible="_signaturePopupVisible"
|
||||
HeaderText="Unterschrift erstellen"
|
||||
Width="620px"
|
||||
MaxWidth="95vw"
|
||||
ShowFooter="true" <!-- REQUIRED for buttons to appear -->
|
||||
CloseOnOutsideClick="false"
|
||||
ShowCloseButton="false" <!-- No X button -->
|
||||
CloseOnEscape="false"> <!-- ESC disabled -->
|
||||
```
|
||||
|
||||
**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)
|
||||
|
||||
Reference in New Issue
Block a user