export type TextAlign = "left" | "center" | "right"; export type IdDrawStyle = { fontFamily: string; fontSize: number; color: string; bold: boolean; italic: boolean; align: TextAlign; xPercent: number; yPercent: number; shadowEnabled: boolean; shadowColor: string; shadowOpacity: number; shadowBlur: number; shadowOffsetX: number; shadowOffsetY: number; outlineEnabled: boolean; outlineColor: string; outlineOpacity: number; outlineWidth: number; }; export type GenerateConfig = IdDrawStyle & { prefix: string; suffix: string; start: number; pad: number; count: number; useCustomFont: boolean; }; export const MAX_COUNT = 1000; export const MAX_IMAGE_BYTES = 10 * 1024 * 1024; export const MAX_FONT_BYTES = 5 * 1024 * 1024; export const CUSTOM_FONT_FAMILY = "CustomUpload"; export const DEFAULT_CONFIG: GenerateConfig = { prefix: "KK", suffix: "", start: 1, pad: 4, count: 1000, fontFamily: "Inter", fontSize: 48, color: "#111111", bold: false, italic: false, align: "center", xPercent: 50, yPercent: 50, shadowEnabled: false, shadowColor: "#000000", shadowOpacity: 0.45, shadowBlur: 8, shadowOffsetX: 2, shadowOffsetY: 3, outlineEnabled: false, outlineColor: "#ffffff", outlineOpacity: 1, outlineWidth: 3, useCustomFont: false, }; const COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; export function formatUserId( prefix: string, n: number, pad: number, suffix: string, ): string { return `${prefix}${String(n).padStart(Math.max(0, pad), "0")}${suffix}`; } export function sanitizeFilename(id: string): string { const cleaned = id .replace(/[/\\:*?"<>|]/g, "") .replace(/\s+/g, " ") .trim(); return cleaned || "id"; } export function buildCanvasFont( style: Pick, ): string { const italic = style.italic ? "italic " : ""; const weight = style.bold ? "700 " : "400 "; return `${italic}${weight}${style.fontSize}px "${style.fontFamily}"`; } export function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function asNumber(value: unknown, fallback: number): number { const n = typeof value === "number" ? value : Number(value); return Number.isFinite(n) ? n : fallback; } function asString(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; } function asBool(value: unknown): boolean { return value === true; } export function parseGenerateConfig(raw: unknown): GenerateConfig { if (!raw || typeof raw !== "object") { throw new Error("Invalid config"); } const input = raw as Record; const start = Math.trunc(asNumber(input.start, DEFAULT_CONFIG.start)); const pad = Math.trunc(asNumber(input.pad, DEFAULT_CONFIG.pad)); const count = Math.trunc(asNumber(input.count, DEFAULT_CONFIG.count)); const fontSize = asNumber(input.fontSize, DEFAULT_CONFIG.fontSize); const xPercent = asNumber(input.xPercent, DEFAULT_CONFIG.xPercent); const yPercent = asNumber(input.yPercent, DEFAULT_CONFIG.yPercent); const shadowOpacity = asNumber( input.shadowOpacity, DEFAULT_CONFIG.shadowOpacity, ); const shadowBlur = asNumber(input.shadowBlur, DEFAULT_CONFIG.shadowBlur); const shadowOffsetX = asNumber( input.shadowOffsetX, DEFAULT_CONFIG.shadowOffsetX, ); const shadowOffsetY = asNumber( input.shadowOffsetY, DEFAULT_CONFIG.shadowOffsetY, ); const outlineOpacity = asNumber( input.outlineOpacity, DEFAULT_CONFIG.outlineOpacity, ); const outlineWidth = asNumber( input.outlineWidth, DEFAULT_CONFIG.outlineWidth, ); const color = asString(input.color, DEFAULT_CONFIG.color); const shadowColor = asString(input.shadowColor, DEFAULT_CONFIG.shadowColor); const outlineColor = asString( input.outlineColor, DEFAULT_CONFIG.outlineColor, ); const align = asString(input.align, DEFAULT_CONFIG.align); const prefix = asString(input.prefix).slice(0, 50); const suffix = asString(input.suffix).slice(0, 50); const fontFamily = asString(input.fontFamily, DEFAULT_CONFIG.fontFamily).slice( 0, 80, ); if (start < 0 || start > 1_000_000) { throw new Error("Start number must be between 0 and 1000000"); } if (pad < 0 || pad > 6) { throw new Error("Pad length must be between 0 and 6"); } if (count < 1 || count > MAX_COUNT) { throw new Error(`Count must be between 1 and ${MAX_COUNT}`); } if (fontSize < 8 || fontSize > 400) { throw new Error("Font size must be between 8 and 400"); } if (xPercent < 0 || xPercent > 100 || yPercent < 0 || yPercent > 100) { throw new Error("Position must be between 0 and 100 percent"); } if (!COLOR_RE.test(color)) { throw new Error("Color must be a hex value like #111111"); } if (!COLOR_RE.test(shadowColor)) { throw new Error("Shadow color must be a hex value like #000000"); } if (shadowOpacity < 0 || shadowOpacity > 1) { throw new Error("Shadow opacity must be between 0 and 1"); } if (shadowBlur < 0 || shadowBlur > 80) { throw new Error("Shadow blur must be between 0 and 80"); } if ( shadowOffsetX < -80 || shadowOffsetX > 80 || shadowOffsetY < -80 || shadowOffsetY > 80 ) { throw new Error("Shadow offset must be between -80 and 80"); } if (!COLOR_RE.test(outlineColor)) { throw new Error("Outline color must be a hex value like #ffffff"); } if (outlineOpacity < 0 || outlineOpacity > 1) { throw new Error("Outline opacity must be between 0 and 1"); } if (outlineWidth < 0 || outlineWidth > 40) { throw new Error("Outline width must be between 0 and 40"); } if (align !== "left" && align !== "center" && align !== "right") { throw new Error("Alignment must be left, center, or right"); } return { prefix, suffix, start, pad, count, fontFamily, fontSize, color, bold: asBool(input.bold), italic: asBool(input.italic), align, xPercent, yPercent, shadowEnabled: asBool(input.shadowEnabled), shadowColor, shadowOpacity, shadowBlur, shadowOffsetX, shadowOffsetY, outlineEnabled: asBool(input.outlineEnabled), outlineColor, outlineOpacity, outlineWidth, useCustomFont: asBool(input.useCustomFont), }; } export function toIdDrawStyle( config: GenerateConfig, fontFamily = config.useCustomFont ? CUSTOM_FONT_FAMILY : config.fontFamily, ): IdDrawStyle { return { fontFamily, fontSize: config.fontSize, color: config.color, bold: config.bold, italic: config.italic, align: config.align, xPercent: config.xPercent, yPercent: config.yPercent, shadowEnabled: config.shadowEnabled, shadowColor: config.shadowColor, shadowOpacity: config.shadowOpacity, shadowBlur: config.shadowBlur, shadowOffsetX: config.shadowOffsetX, shadowOffsetY: config.shadowOffsetY, outlineEnabled: config.outlineEnabled, outlineColor: config.outlineColor, outlineOpacity: config.outlineOpacity, outlineWidth: config.outlineWidth, }; } export function hexToRgba(hex: string, opacity: number): string { let value = hex.replace("#", ""); if (value.length === 3) { value = value .split("") .map((char) => char + char) .join(""); } let alpha = clamp(opacity, 0, 1); if (value.length === 8) { alpha *= parseInt(value.slice(6, 8), 16) / 255; value = value.slice(0, 6); } const r = parseInt(value.slice(0, 2), 16); const g = parseInt(value.slice(2, 4), 16); const b = parseInt(value.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }