This commit is contained in:
192
services/stickerSheetService.ts
Normal file
192
services/stickerSheetService.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
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<string, { width: number, height: number }> = {
|
||||
'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<Buffer> {
|
||||
// 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<Buffer> {
|
||||
// 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<Buffer> {
|
||||
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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user