Files
documenso/packages/lib/universal/field-renderer/render-signature-field.ts
T
2026-05-08 15:14:27 +10:00

342 lines
9.5 KiB
TypeScript

import Konva from 'konva';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf';
import { AppError } from '../../errors/app-error';
import type { TSignatureFieldMeta } from '../../types/field-meta';
import { resolveFieldOverflowMode } from '../../types/field-meta';
import { calculateOverflowLayout } from './calculate-overflow-layout';
import {
createFieldHoverInteraction,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import { calculateFieldPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SkiaImage: any = undefined;
void (async () => {
if (typeof window === 'undefined') {
const mod = await import('skia-canvas');
SkiaImage = mod.Image;
}
})();
const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeight: number) => {
let imageWidth = img.width;
let imageHeight = img.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
const imageX = (fieldWidth - imageWidth) / 2;
const imageY = (fieldHeight - imageHeight) / 2;
return {
width: imageWidth,
height: imageHeight,
x: imageX,
y: imageY,
};
};
type FieldSignature =
| {
node: Konva.Text;
isImageSignature: false;
isLabel: boolean;
}
| {
node: Konva.Image;
isImageSignature: true;
isLabel: boolean;
};
/**
* The pixel ratio used when caching the signature image as an offscreen bitmap.
*
* Konva's default redraw composites the source image with low-quality scaling
* which makes signatures look blurry, especially when the source PNG is much
* larger than the field. Caching at a high pixel ratio rasterises the shape
* once into a sharp bitmap that is then reused on every redraw.
*
* Multiplied by `devicePixelRatio` to keep the cache crisp on retina displays.
*/
const SIGNATURE_IMAGE_CACHE_PIXEL_RATIO = 2;
/**
* Build a Konva.Image for a base64 signature, sized to fit within the given
* field dimensions. Works in both browser and Node.js (via skia-canvas).
*/
const createSignatureImage = (
signatureImageAsBase64: string,
fieldWidth: number,
fieldHeight: number,
): Konva.Image => {
if (typeof window !== 'undefined') {
const img = new Image();
const image = new Konva.Image({
image: img,
x: 0,
y: 0,
width: fieldWidth,
height: fieldHeight,
});
img.onload = () => {
image.setAttrs({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
});
// Cache the image as a high-resolution bitmap so it stays sharp on
// redraws and zoom changes instead of being re-scaled from the source PNG
// every time.
image.cache({
pixelRatio: SIGNATURE_IMAGE_CACHE_PIXEL_RATIO * (window.devicePixelRatio || 1),
});
};
img.src = signatureImageAsBase64;
return image;
}
// Node.js with skia-canvas
if (!SkiaImage) {
throw new Error('Skia image not found');
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const img = new SkiaImage(signatureImageAsBase64) as unknown as HTMLImageElement;
return new Konva.Image({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
});
};
const createFieldSignature = (
field: FieldToRender,
options: RenderFieldElementOptions,
): FieldSignature => {
const { pageWidth, pageHeight, mode = 'edit', translations } = options;
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
pageWidth,
pageHeight,
);
const fontSize = field.fieldMeta?.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE;
const fieldText = new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
const fieldTypeName = translations?.[field.type] || field.type;
// Calculate text positioning based on alignment
const textX = 0;
const textY = 0;
let textToRender: string = fieldTypeName;
const signature = field.signature;
// Handle edit mode.
if (mode === 'edit') {
textToRender = fieldTypeName;
// If the field has already been signed and we have the signature data
// available, render it. Otherwise leave the field type label as a placeholder.
if (field.inserted && signature?.typedSignature) {
textToRender = signature.typedSignature;
}
if (field.inserted && signature?.signatureImageAsBase64) {
return {
node: createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight),
isImageSignature: true,
isLabel: false,
};
}
}
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = fieldTypeName;
if (field.inserted && !signature) {
throw new AppError('MISSING_SIGNATURE');
}
if (signature?.typedSignature) {
textToRender = signature.typedSignature;
}
if (signature?.signatureImageAsBase64) {
return {
node: createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight),
isImageSignature: true,
isLabel: false,
};
}
}
const fieldMeta = field.fieldMeta as TSignatureFieldMeta | undefined;
// Whether we're rendering the field type name (like "Signature") vs actual signed content.
// Overflow should not apply to the label.
const isLabel = !signature?.typedSignature;
const overflowLayout = calculateOverflowLayout({
overflowMode: resolveFieldOverflowMode(fieldMeta),
isLabel,
textToRender,
fontSize,
fontFamily: 'Caveat, sans-serif',
lineHeight: 1,
letterSpacing: 0,
textAlign: 'center',
verticalAlign: 'middle',
baseX: textX,
baseY: textY,
baseWidth: fieldWidth,
baseHeight: fieldHeight,
groupX: fieldX,
groupY: fieldY,
pageWidth,
pageHeight,
});
fieldText.setAttrs({
x: overflowLayout.x,
y: overflowLayout.y,
verticalAlign: overflowLayout.verticalAlign,
wrap: overflowLayout.wrap,
text: textToRender,
fontSize,
fontFamily: 'Caveat, sans-serif',
align: overflowLayout.textAlign,
width: overflowLayout.width,
height: overflowLayout.height,
} satisfies Partial<Konva.TextConfig>);
return { node: fieldText, isImageSignature: false, isLabel };
};
export const renderSignatureFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { mode = 'edit', pageLayer, pageWidth, pageHeight, color } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const { node: fieldSignature, isImageSignature, isLabel } = createFieldSignature(field, options);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldSignature);
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldSignature.scaleX(1 / groupScaleX);
fieldSignature.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// During active transform, use crop dimensions (field bounds only).
if (!isImageSignature) {
fieldSignature.x(0);
fieldSignature.y(0);
fieldSignature.wrap('word');
}
fieldSignature.width(rectWidth);
fieldSignature.height(rectHeight);
fieldGroup.getLayer()?.batchDraw();
});
fieldGroup.on('transformend', () => {
fieldSignature.scaleX(1);
fieldSignature.scaleY(1);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
if (!isImageSignature) {
const fieldMeta = field.fieldMeta as TSignatureFieldMeta | undefined;
const newOverflowLayout = calculateOverflowLayout({
overflowMode: resolveFieldOverflowMode(fieldMeta),
isLabel,
textToRender: fieldSignature.text(),
fontSize: fieldSignature.fontSize(),
fontFamily: 'Caveat, sans-serif',
lineHeight: 1,
letterSpacing: 0,
textAlign: 'center',
verticalAlign: 'middle',
baseX: 0,
baseY: 0,
baseWidth: rectWidth,
baseHeight: rectHeight,
groupX: fieldGroup.x(),
groupY: fieldGroup.y(),
pageWidth,
pageHeight,
});
fieldSignature.x(newOverflowLayout.x);
fieldSignature.y(newOverflowLayout.y);
fieldSignature.width(newOverflowLayout.width);
fieldSignature.height(newOverflowLayout.height);
fieldSignature.wrap(newOverflowLayout.wrap);
fieldSignature.verticalAlign(newOverflowLayout.verticalAlign);
} else {
fieldSignature.width(rectWidth);
fieldSignature.height(rectHeight);
}
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {
// Hide the rectangle.
fieldRect.opacity(0);
}
if (color !== 'readOnly' && mode !== 'export') {
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
}
return {
fieldGroup,
isFirstRender,
};
};