Skip to content

Commit 84fa118

Browse files
antontranelisclaude
andcommitted
feat: add pseudo-watermark portrait with radial fade
- Watermark on front side (right, 78% + 70px offset) - Watermark on back side (centered) - Radial gradient mask: center visible → edges transparent - Grayscale, brightened, low opacity (18%) for subtle effect - Works without background removal due to fade effect Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 4e92789 commit 84fa118

4 files changed

Lines changed: 239 additions & 15 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@antontranelis/money-printer",
3-
"version": "1.0.90",
3+
"version": "1.0.91",
44
"description": "Create personalized time vouchers that look like real currency. React components for voucher generation with PDF export.",
55
"type": "module",
66
"license": "MIT",

src/components/BillPreview.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,18 +128,22 @@ export function BillPreview({ onPortraitClick, onFileDrop }: BillPreviewProps =
128128
backBackgroundUrl,
129129
backBadgesUrl,
130130
backFrameUrl,
131+
currentPortrait,
131132
personalInfo.name,
132133
personalInfo.email,
133134
personalInfo.phone,
134135
displayDescription,
135136
layout.back,
136137
template.width,
137138
template.height,
139+
portrait.zoom,
140+
portrait.panX,
141+
portrait.panY,
138142
debouncedHue,
139143
hours,
140144
billLanguage
141145
);
142-
}, [template, backBackgroundUrl, backBadgesUrl, backFrameUrl, personalInfo, displayDescription, layout, debouncedHue, hours, billLanguage]);
146+
}, [template, backBackgroundUrl, backBadgesUrl, backFrameUrl, currentPortrait, personalInfo, displayDescription, layout, portrait.zoom, portrait.panX, portrait.panY, debouncedHue, hours, billLanguage]);
143147

144148
const handleFlip = () => {
145149
// Toggle the visual flip animation

src/services/canvasRenderer.ts

Lines changed: 138 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,81 @@ export function drawPortraitPlaceholder(
129129
ctx.restore();
130130
}
131131

132+
/**
133+
* Draw portrait as a faded watermark with radial fade effect
134+
* Center is visible (~15-20% opacity), edges fade to transparent
135+
*/
136+
export function drawWatermarkPortrait(
137+
ctx: CanvasRenderingContext2D,
138+
portrait: HTMLImageElement,
139+
centerX: number,
140+
centerY: number,
141+
size: number,
142+
zoom: number = 1,
143+
panX: number = 0,
144+
panY: number = 0
145+
): void {
146+
ctx.save();
147+
148+
// Create offscreen canvas for the watermark effect
149+
const watermarkCanvas = document.createElement('canvas');
150+
watermarkCanvas.width = size;
151+
watermarkCanvas.height = size;
152+
const wmCtx = watermarkCanvas.getContext('2d');
153+
if (!wmCtx) {
154+
ctx.restore();
155+
return;
156+
}
157+
158+
// Calculate image dimensions to cover the circle
159+
const imgAspect = portrait.width / portrait.height;
160+
let drawWidth: number;
161+
let drawHeight: number;
162+
163+
if (imgAspect > 1) {
164+
drawHeight = size;
165+
drawWidth = size * imgAspect;
166+
} else {
167+
drawWidth = size;
168+
drawHeight = size / imgAspect;
169+
}
170+
171+
drawWidth *= zoom;
172+
drawHeight *= zoom;
173+
174+
const maxPanX = Math.max(0, (drawWidth - size) / 2);
175+
const maxPanY = Math.max(0, (drawHeight - size) / 2);
176+
177+
const drawX = (size - drawWidth) / 2 + panX * maxPanX;
178+
const drawY = (size - drawHeight) / 2 + panY * maxPanY;
179+
180+
// Draw portrait with desaturation and brightness
181+
wmCtx.filter = 'grayscale(70%) brightness(1.4) contrast(0.6)';
182+
wmCtx.drawImage(portrait, drawX, drawY, drawWidth, drawHeight);
183+
wmCtx.filter = 'none';
184+
185+
// Apply radial gradient mask (center visible, edges transparent)
186+
wmCtx.globalCompositeOperation = 'destination-in';
187+
const gradient = wmCtx.createRadialGradient(
188+
size / 2, size / 2, 0, // inner circle (center)
189+
size / 2, size / 2, size / 2 // outer circle (edge)
190+
);
191+
gradient.addColorStop(0, 'rgba(0,0,0,1)'); // center: fully visible
192+
gradient.addColorStop(0.5, 'rgba(0,0,0,0.8)'); // mid: mostly visible
193+
gradient.addColorStop(0.8, 'rgba(0,0,0,0.3)'); // near edge: fading
194+
gradient.addColorStop(1, 'rgba(0,0,0,0)'); // edge: transparent
195+
196+
wmCtx.fillStyle = gradient;
197+
wmCtx.fillRect(0, 0, size, size);
198+
199+
// Draw the watermark onto main canvas with low opacity
200+
ctx.globalAlpha = 0.18;
201+
ctx.drawImage(watermarkCanvas, centerX - size / 2, centerY - size / 2);
202+
ctx.globalAlpha = 1;
203+
204+
ctx.restore();
205+
}
206+
132207
export function drawOvalPortrait(
133208
ctx: CanvasRenderingContext2D,
134209
portrait: HTMLImageElement,
@@ -343,11 +418,35 @@ export async function renderFrontSide(
343418
const background = await getHueShiftedTemplate(backgroundSrc, templateHue, width, height);
344419
drawTemplate(offCtx, background, width, height);
345420

346-
// Layer 2: Draw badges - NO hue shift (keep original colors)
421+
// Layer 2: Draw watermark portrait on right side (if portrait available)
422+
if (portraitSrc) {
423+
try {
424+
const portrait = await loadImage(portraitSrc);
425+
// Position: right side of the bill, vertically centered
426+
// Size: roughly 60% of height for a prominent but subtle watermark
427+
const watermarkSize = height * 0.6;
428+
const watermarkX = width * 0.78 + 70; // Right side
429+
const watermarkY = height * 0.5; // Vertically centered
430+
drawWatermarkPortrait(
431+
offCtx,
432+
portrait,
433+
watermarkX,
434+
watermarkY,
435+
watermarkSize,
436+
portraitZoom,
437+
portraitPanX,
438+
portraitPanY
439+
);
440+
} catch (e) {
441+
console.error('Failed to draw watermark:', e);
442+
}
443+
}
444+
445+
// Layer 3: Draw badges - NO hue shift (keep original colors)
347446
const badges = await loadImage(badgesSrc);
348447
drawTemplate(offCtx, badges, width, height);
349448

350-
// Layer 3: Draw portrait if available, or placeholder (UNDER the frame)
449+
// Layer 4: Draw main portrait if available, or placeholder (UNDER the frame)
351450
if (portraitSrc) {
352451
try {
353452
const portrait = await loadImage(portraitSrc);
@@ -377,17 +476,17 @@ export async function renderFrontSide(
377476
);
378477
}
379478

380-
// Layer 4: Draw frame ON TOP of portrait - NO hue shift (frame stays original)
479+
// Layer 5: Draw frame ON TOP of portrait - NO hue shift (frame stays original)
381480
const frame = await loadImage(frameSrc);
382481
drawTemplate(offCtx, frame, width, height);
383482

384-
// Layer 5: Draw banner text ON TOP of frame
483+
// Layer 6: Draw banner text ON TOP of frame
385484
// Calculate scale based on preview vs full resolution
386485
// Preview is 0.5 scale (width ~1816), full is 1.0 (width 3633)
387486
const scale = width / 3633;
388487
drawBannerText(offCtx, hours, language, scale);
389488

390-
// Layer 6: Draw name
489+
// Layer 7: Draw name
391490
if (name) {
392491
drawText(offCtx, name, layout.namePlate);
393492
}
@@ -403,13 +502,17 @@ export async function renderBackSide(
403502
backgroundSrc: string,
404503
badgesSrc: string,
405504
frameSrc: string,
505+
portraitSrc: string | null,
406506
name: string,
407507
email: string,
408508
phone: string,
409509
description: string,
410510
layout: TemplateLayout,
411511
width: number,
412512
height: number,
513+
portraitZoom: number = 1,
514+
portraitPanX: number = 0,
515+
portraitPanY: number = 0,
413516
templateHue: number = 0,
414517
hours: HourValue = 1,
415518
language: Language = 'de'
@@ -431,34 +534,57 @@ export async function renderBackSide(
431534
const background = await getHueShiftedTemplate(backgroundSrc, templateHue, width, height);
432535
drawTemplate(offCtx, background, width, height);
433536

434-
// Layer 2: Draw badges - NO hue shift (keep original colors)
537+
// Layer 2: Draw watermark portrait in center (if portrait available)
538+
if (portraitSrc) {
539+
try {
540+
const portrait = await loadImage(portraitSrc);
541+
// Position: center of the bill
542+
const watermarkSize = height * 0.6;
543+
const watermarkX = width * 0.5;
544+
const watermarkY = height * 0.5;
545+
drawWatermarkPortrait(
546+
offCtx,
547+
portrait,
548+
watermarkX,
549+
watermarkY,
550+
watermarkSize,
551+
portraitZoom,
552+
portraitPanX,
553+
portraitPanY
554+
);
555+
} catch (e) {
556+
console.error('Failed to draw watermark:', e);
557+
}
558+
}
559+
560+
// Layer 3: Draw badges - NO hue shift (keep original colors)
435561
const badges = await loadImage(badgesSrc);
436562
drawTemplate(offCtx, badges, width, height);
437563

438-
// Layer 3: Draw frame ON TOP - NO hue shift (frame stays original)
564+
// Layer 4: Draw frame ON TOP - NO hue shift (frame stays original)
439565
const frame = await loadImage(frameSrc);
440566
drawTemplate(offCtx, frame, width, height);
441567

442-
// Layer 4: Draw banner text ON TOP of frame
568+
// Layer 5: Draw banner text ON TOP of frame
443569
const scale = width / 3633;
444570
drawBannerText(offCtx, hours, language, scale);
445571

446-
// Layer 5: Draw contact info
572+
// Layer 6: Draw contact info
447573
if (layout.contactInfo && (name || email || phone)) {
448574
drawContactInfo(offCtx, name, email, phone, layout.contactInfo);
449575
}
450576

451-
// Layer 6: Draw description
577+
// Layer 7: Draw description
452578
if (layout.description && description) {
453579
drawMultilineText(offCtx, description, layout.description);
454580
}
455581

456-
// Layer 7: Draw name at bottom
582+
// Layer 8: Draw name at bottom
457583
if (name) {
458584
drawText(offCtx, name, layout.namePlate);
459585
}
460586

461-
// Layer 8: Draw signature field
587+
// Layer 9: Draw signature field
462588
if (layout.signature) {
463589
const signatureLabel = language === 'de' ? 'Unterschrift' : 'Signature';
464590
drawSignature(offCtx, layout.signature, signatureLabel);

src/services/pdfGenerator.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,66 @@ async function loadImageBitmap(url) {
160160
return createImageBitmap(blob);
161161
}
162162
163+
// Draw portrait as faded watermark with radial fade effect
164+
function drawWatermarkPortrait(ctx, portrait, centerX, centerY, size, zoom, panX, panY) {
165+
ctx.save();
166+
167+
// Create offscreen canvas for the watermark effect
168+
const watermarkCanvas = new OffscreenCanvas(size, size);
169+
const wmCtx = watermarkCanvas.getContext('2d');
170+
if (!wmCtx) {
171+
ctx.restore();
172+
return;
173+
}
174+
175+
// Calculate image dimensions to cover the circle
176+
const imgAspect = portrait.width / portrait.height;
177+
let drawWidth, drawHeight;
178+
179+
if (imgAspect > 1) {
180+
drawHeight = size;
181+
drawWidth = size * imgAspect;
182+
} else {
183+
drawWidth = size;
184+
drawHeight = size / imgAspect;
185+
}
186+
187+
drawWidth *= zoom;
188+
drawHeight *= zoom;
189+
190+
const maxPanX = Math.max(0, (drawWidth - size) / 2);
191+
const maxPanY = Math.max(0, (drawHeight - size) / 2);
192+
193+
const drawX = (size - drawWidth) / 2 + panX * maxPanX;
194+
const drawY = (size - drawHeight) / 2 + panY * maxPanY;
195+
196+
// Draw portrait with desaturation and brightness
197+
wmCtx.filter = 'grayscale(70%) brightness(1.4) contrast(0.6)';
198+
wmCtx.drawImage(portrait, drawX, drawY, drawWidth, drawHeight);
199+
wmCtx.filter = 'none';
200+
201+
// Apply radial gradient mask (center visible, edges transparent)
202+
wmCtx.globalCompositeOperation = 'destination-in';
203+
const gradient = wmCtx.createRadialGradient(
204+
size / 2, size / 2, 0,
205+
size / 2, size / 2, size / 2
206+
);
207+
gradient.addColorStop(0, 'rgba(0,0,0,1)');
208+
gradient.addColorStop(0.5, 'rgba(0,0,0,0.8)');
209+
gradient.addColorStop(0.8, 'rgba(0,0,0,0.3)');
210+
gradient.addColorStop(1, 'rgba(0,0,0,0)');
211+
212+
wmCtx.fillStyle = gradient;
213+
wmCtx.fillRect(0, 0, size, size);
214+
215+
// Draw the watermark onto main canvas with low opacity
216+
ctx.globalAlpha = 0.18;
217+
ctx.drawImage(watermarkCanvas, centerX - size / 2, centerY - size / 2);
218+
ctx.globalAlpha = 1;
219+
220+
ctx.restore();
221+
}
222+
163223
// Draw oval portrait with clipping
164224
function drawOvalPortrait(ctx, portrait, centerX, centerY, radiusX, radiusY, zoom, panX, panY) {
165225
ctx.save();
@@ -382,7 +442,24 @@ self.onmessage = async (e) => {
382442
frontCtx.putImageData(frontImageData, 0, 0);
383443
}
384444
385-
// Draw portrait
445+
// Draw watermark portrait on right side
446+
if (portrait) {
447+
const watermarkSize = templateHeight * 0.6;
448+
const watermarkX = templateWidth * 0.78 + 70;
449+
const watermarkY = templateHeight * 0.5;
450+
drawWatermarkPortrait(
451+
frontCtx,
452+
portrait,
453+
watermarkX,
454+
watermarkY,
455+
watermarkSize,
456+
portraitZoom,
457+
portraitPanX,
458+
portraitPanY
459+
);
460+
}
461+
462+
// Draw main portrait
386463
if (portrait) {
387464
drawOvalPortrait(
388465
frontCtx,
@@ -423,6 +500,23 @@ self.onmessage = async (e) => {
423500
backCtx.putImageData(backImageData, 0, 0);
424501
}
425502
503+
// Draw watermark portrait in center of back side
504+
if (portrait) {
505+
const watermarkSize = templateHeight * 0.6;
506+
const watermarkX = templateWidth * 0.5;
507+
const watermarkY = templateHeight * 0.5;
508+
drawWatermarkPortrait(
509+
backCtx,
510+
portrait,
511+
watermarkX,
512+
watermarkY,
513+
watermarkSize,
514+
portraitZoom,
515+
portraitPanX,
516+
portraitPanY
517+
);
518+
}
519+
426520
// Draw contact info
427521
if (layout.back.contactInfo && (name || email || phone)) {
428522
drawContactInfo(

0 commit comments

Comments
 (0)