mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
Use our fork of `skia-canvas` for rendering which handles encoding characters correctly with the caveat font and other similar fonts that can group glyphs like ligatures. This resolves issues with pdf text extraction where characters were unable to be extracted due to lacking any data within the cmaps.
327 lines
9.6 KiB
TypeScript
327 lines
9.6 KiB
TypeScript
import Konva from 'konva';
|
|
|
|
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE, getSignatureFontFamily } 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 type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
|
import { calculateFieldPosition } from './field-renderer';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let SkiaImage: any;
|
|
|
|
void (async () => {
|
|
if (typeof window === 'undefined') {
|
|
const mod = await import('@documenso/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,
|
|
listening: false,
|
|
});
|
|
|
|
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),
|
|
listening: false,
|
|
});
|
|
};
|
|
|
|
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',
|
|
listening: false,
|
|
});
|
|
|
|
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: getSignatureFontFamily(textToRender),
|
|
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: getSignatureFontFamily(textToRender),
|
|
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: getSignatureFontFamily(fieldSignature.text()),
|
|
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,
|
|
};
|
|
};
|