193 lines
7.0 KiB
TypeScript
193 lines
7.0 KiB
TypeScript
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();
|
|
}
|
|
};
|