/** * Utility functions for crop coordinate conversion and PDF bounds handling */ export interface PDFBounds { /** PDF width in points (actual PDF dimensions) */ actualWidth: number; /** PDF height in points (actual PDF dimensions) */ actualHeight: number; /** Thumbnail display width in pixels */ thumbnailWidth: number; /** Thumbnail display height in pixels */ thumbnailHeight: number; /** Horizontal offset for centering thumbnail in container */ offsetX: number; /** Vertical offset for centering thumbnail in container */ offsetY: number; /** Scale factor: thumbnailSize / actualSize */ scale: number; } export interface Rectangle { /** X coordinate */ x: number; /** Y coordinate */ y: number; /** Width */ width: number; /** Height */ height: number; } /** Runtime type guard */ export function isRectangle(value: unknown): value is Rectangle { if (value === null || typeof value !== "object") return false; const r = value as Record; const isNum = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n); return ( isNum(r.x) && isNum(r.y) && isNum(r.width) && isNum(r.height) && r.width >= 0 && r.height >= 0 ); } /** * Calculate PDF bounds for coordinate conversion based on thumbnail dimensions */ export const calculatePDFBounds = ( actualPDFWidth: number, actualPDFHeight: number, containerWidth: number, containerHeight: number ): PDFBounds => { // Calculate scale to fit PDF within container while maintaining aspect ratio const scaleX = containerWidth / actualPDFWidth; const scaleY = containerHeight / actualPDFHeight; const scale = Math.min(scaleX, scaleY); // Calculate actual thumbnail display size const thumbnailWidth = actualPDFWidth * scale; const thumbnailHeight = actualPDFHeight * scale; // Calculate centering offsets - these represent where the thumbnail is positioned within the container const offsetX = (containerWidth - thumbnailWidth) / 2; const offsetY = (containerHeight - thumbnailHeight) / 2; return { actualWidth: actualPDFWidth, actualHeight: actualPDFHeight, thumbnailWidth, thumbnailHeight, offsetX, offsetY, scale }; }; /** * Convert DOM coordinates (relative to container) to PDF coordinates * Handles coordinate system conversion (DOM uses top-left, PDF uses bottom-left origin) */ export const domToPDFCoordinates = ( domRect: Rectangle, pdfBounds: PDFBounds ): Rectangle => { // Convert DOM coordinates to thumbnail-relative coordinates const thumbX = domRect.x - pdfBounds.offsetX; const thumbY = domRect.y - pdfBounds.offsetY; // Convert to PDF coordinates (scale and flip Y-axis) const pdfX = thumbX / pdfBounds.scale; const pdfY = pdfBounds.actualHeight - ((thumbY + domRect.height) / pdfBounds.scale); const pdfWidth = domRect.width / pdfBounds.scale; const pdfHeight = domRect.height / pdfBounds.scale; return { x: pdfX, y: pdfY, width: pdfWidth, height: pdfHeight }; }; /** * Convert PDF coordinates to DOM coordinates (relative to container) */ export const pdfToDOMCoordinates = ( cropArea: Rectangle, pdfBounds: PDFBounds ): Rectangle => { // Convert PDF coordinates to thumbnail coordinates (scale and flip Y-axis) const thumbX = cropArea.x * pdfBounds.scale; const thumbY = (pdfBounds.actualHeight - cropArea.y - cropArea.height) * pdfBounds.scale; const thumbWidth = cropArea.width * pdfBounds.scale; const thumbHeight = cropArea.height * pdfBounds.scale; // Add container offsets to get DOM coordinates return { x: thumbX + pdfBounds.offsetX, y: thumbY + pdfBounds.offsetY, width: thumbWidth, height: thumbHeight }; }; /** * Constrain a crop area to stay within PDF bounds */ export const constrainCropAreaToPDF = ( cropArea: Rectangle, pdfBounds: PDFBounds ): Rectangle => { // Ensure crop area doesn't extend beyond PDF boundaries const maxX = Math.max(0, pdfBounds.actualWidth - cropArea.width); const maxY = Math.max(0, pdfBounds.actualHeight - cropArea.height); return { x: Math.max(0, Math.min(cropArea.x, maxX)), y: Math.max(0, Math.min(cropArea.y, maxY)), width: Math.min(cropArea.width, pdfBounds.actualWidth - Math.max(0, cropArea.x)), height: Math.min(cropArea.height, pdfBounds.actualHeight - Math.max(0, cropArea.y)) }; }; /** * Constrain DOM coordinates to stay within thumbnail bounds */ export const constrainDOMRectToThumbnail = ( domRect: Rectangle, pdfBounds: PDFBounds ): Rectangle => { const thumbnailLeft = pdfBounds.offsetX; const thumbnailTop = pdfBounds.offsetY; const thumbnailRight = pdfBounds.offsetX + pdfBounds.thumbnailWidth; const thumbnailBottom = pdfBounds.offsetY + pdfBounds.thumbnailHeight; // Constrain position const maxX = Math.max(thumbnailLeft, thumbnailRight - domRect.width); const maxY = Math.max(thumbnailTop, thumbnailBottom - domRect.height); const constrainedX = Math.max(thumbnailLeft, Math.min(domRect.x, maxX)); const constrainedY = Math.max(thumbnailTop, Math.min(domRect.y, maxY)); // Constrain size to fit within thumbnail bounds from current position const maxWidth = thumbnailRight - constrainedX; const maxHeight = thumbnailBottom - constrainedY; return { x: constrainedX, y: constrainedY, width: Math.min(domRect.width, maxWidth), height: Math.min(domRect.height, maxHeight) }; }; /** * Check if a point is within the thumbnail area (not just the container) */ export const isPointInThumbnail = ( x: number, y: number, pdfBounds: PDFBounds ): boolean => { return x >= pdfBounds.offsetX && x <= pdfBounds.offsetX + pdfBounds.thumbnailWidth && y >= pdfBounds.offsetY && y <= pdfBounds.offsetY + pdfBounds.thumbnailHeight; }; /** * Create a default crop area that covers the entire PDF */ export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => { return { x: 0, y: 0, width: pdfBounds.actualWidth, height: pdfBounds.actualHeight }; }; /** * Round crop coordinates to reasonable precision (0.1 point) */ export const roundCropArea = (cropArea: Rectangle): Rectangle => { return { x: Math.round(cropArea.x * 10) / 10, y: Math.round(cropArea.y * 10) / 10, width: Math.round(cropArea.width * 10) / 10, height: Math.round(cropArea.height * 10) / 10 }; };