import sharp from 'sharp'; import path from 'path'; import fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; // Paper Dimensions (at 300 DPI) const PAPER_SIZES: Record = { 'A4': { width: 2480, height: 3508 }, // 210mm x 297mm 'Letter': { width: 2550, height: 3300 }, // 8.5in x 11in 'A5': { width: 1748, height: 2480 } // 148mm x 210mm }; export const stickerSheetService = { /** * Add a white border and optional cut line to an image. * @param inputPath Path to the source image (sticker). * @param borderWidth Width of the white contour in pixels (default 40). */ async addCutContour(inputPath: string, borderWidth: number = 40): Promise { // 1. Load Image const image = sharp(inputPath); const metadata = await image.metadata(); if (!metadata.width || !metadata.height) throw new Error("Invalid image metadata"); // 2. Create Mask (Alpha Channel) const alpha = await image.clone() .toColourspace('b-w') .ensureAlpha() .extractChannel(3) // Get alpha channel .raw() .toBuffer(); // 3. Dilate Mask to create Border Shape // Proper dilation requires morphological operations which Sharp doesn't support natively well for complex shapes without external libs. // TRICK: We will blur the alpha channel and threshold it to simulate dilation/expansion. const expandedMask = await sharp(alpha, { raw: { width: metadata.width, height: metadata.height, channels: 1 } }) .blur(borderWidth) // Spread the alpha .threshold(10) // Binarize to create hard edge .toBuffer(); // 4. Create White Silhouette using the expanded mask const whiteBackground = await sharp({ create: { width: metadata.width, height: metadata.height, channels: 4, background: { r: 255, g: 255, b: 255, alpha: 1 } } }) .joinChannel(expandedMask) // Apply mask .png() .toBuffer(); // 5. Composite Original Image ON TOP of White Silhouette return sharp(whiteBackground) .composite([{ input: inputPath }]) .withMetadata({ density: 300 }) // Enforce 300 DPI .png() .toBuffer(); }, /** * Generate a BLACKOUT mask for Cricut Cut Lines. * Returns a binary verification image (Black shape on transparent background). */ async generateCutMask(inputPath: string, borderWidth: number = 40): Promise { // 1. Load Image const image = sharp(inputPath); const metadata = await image.metadata(); if (!metadata.width || !metadata.height) throw new Error("Invalid image metadata"); // 2. Extract Alpha const alpha = await image.clone() .toColourspace('b-w') .ensureAlpha() .extractChannel(3) .raw() .toBuffer(); // 3. Dilate (Same logic as addCutContour to match perfectly) const expandedMask = await sharp(alpha, { raw: { width: metadata.width, height: metadata.height, channels: 1 } }) .blur(borderWidth) .threshold(10) .toBuffer(); // 4. Create BLACK Silhouette return sharp({ create: { width: metadata.width, height: metadata.height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } // BLACK } }) .joinChannel(expandedMask) // Apply same mask .withMetadata({ density: 300 }) // Enforce 300 DPI .png() .toBuffer(); }, /** * Arrange multiple stickers onto a single sheet. * @param stickerPaths Array of absolute paths to sticker images. * @param options Configuration for the sheet (target dimensions, paper size name) */ async generateSheet(stickerPaths: string[], options: { width: number, height: number, paperSizeName?: string }): Promise { const { width, height } = options; // Target 4-5 columns for A4/Letter at 2500px width. // If width is ~5000px (High Res A4), we still want ~4-5 columns? // Or do we want more stickers if we have them? // Usually a sticker sheet has a fixed number of stickers. // Let's assume we want a standard grid. // Let's base sticker size on WIDTH. // 4 cols + 5 gaps = width. // stickerW * 4 + gap * 5 = width. // let gap = stickerW / 10. // stickerW * 4 + stickerW/2 = width => 4.5 stickerW = width. const COLS = 4; // Calculation: // Width = COLS * StickerW + (COLS + 1) * GAP // Let GAP = StickerW * 0.1 // Width = 4 * S + 5 * 0.1 * S = 4.5 S const stickerWidth = Math.floor(width / (COLS + (COLS + 1) * 0.1)); const gap = Math.floor(stickerWidth * 0.1); console.log(`[StickerService] Generating Sheet ${width}x${height}. Sticker W: ${stickerWidth}, Gap: ${gap}`); const composites: sharp.OverlayOptions[] = []; let currentX = gap; let currentY = gap; let maxHeightInRow = 0; for (const stickerPath of stickerPaths) { let stickerBuffer; try { stickerBuffer = await this.addCutContour(stickerPath, Math.floor(stickerWidth * 0.05)); // Scale border too } catch (e) { console.error(`Failed to process sticker ${stickerPath}`, e); continue; } // Resize to target width const resizedSticker = await sharp(stickerBuffer) .resize({ width: stickerWidth }) .toBuffer(); const meta = await sharp(resizedSticker).metadata(); const w = meta.width || stickerWidth; const h = meta.height || stickerWidth; // Check if fits in current row if (currentX + w + gap > width) { // Next Row currentX = gap; currentY += maxHeightInRow + gap; maxHeightInRow = 0; } // Check if fits in page (vertical) if (currentY + h + gap > height) { console.warn("Sheet full, skipping remaining stickers"); break; } composites.push({ input: resizedSticker, top: currentY, left: currentX }); currentX += w + gap; if (h > maxHeightInRow) maxHeightInRow = h; } // Create Sheet Canvas return sharp({ create: { width: width, height: height, channels: 4, background: { r: 255, g: 255, b: 255, alpha: 0 } // Transparent } }) .composite(composites) .png() .toBuffer(); } };