init
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { ZipArchive } from "archiver";
|
||||
|
||||
export function createZipArchive(level = 6): ZipArchive {
|
||||
return new ZipArchive({ zlib: { level } });
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { buildCanvasFont, hexToRgba, type IdDrawStyle } from "./id-config";
|
||||
|
||||
export type TextMetricsLike = {
|
||||
width: number;
|
||||
actualBoundingBoxAscent?: number;
|
||||
actualBoundingBoxDescent?: number;
|
||||
};
|
||||
|
||||
export type Canvas2D = {
|
||||
font: string;
|
||||
fillStyle: string | CanvasGradient | CanvasPattern;
|
||||
strokeStyle: string | CanvasGradient | CanvasPattern;
|
||||
lineWidth: number;
|
||||
textAlign: CanvasTextAlign;
|
||||
textBaseline: CanvasTextBaseline;
|
||||
shadowColor: string;
|
||||
shadowBlur: number;
|
||||
shadowOffsetX: number;
|
||||
shadowOffsetY: number;
|
||||
lineJoin: CanvasLineJoin;
|
||||
miterLimit: number;
|
||||
fillText(text: string, x: number, y: number): void;
|
||||
strokeText(text: string, x: number, y: number): void;
|
||||
measureText(text: string): TextMetricsLike;
|
||||
save(): void;
|
||||
restore(): void;
|
||||
setLineDash(segments: number[]): void;
|
||||
strokeRect(x: number, y: number, w: number, h: number): void;
|
||||
};
|
||||
|
||||
export type TextBox = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type GlyphMetrics = {
|
||||
width: number;
|
||||
ascent: number;
|
||||
descent: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function positionFromPercent(
|
||||
xPercent: number,
|
||||
yPercent: number,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (xPercent / 100) * imageWidth,
|
||||
y: (yPercent / 100) * imageHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFont(ctx: Canvas2D, style: IdDrawStyle): void {
|
||||
ctx.font = buildCanvasFont(style);
|
||||
ctx.textAlign = style.align;
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
export function measureGlyphs(
|
||||
ctx: Canvas2D,
|
||||
text: string,
|
||||
style: IdDrawStyle,
|
||||
): GlyphMetrics {
|
||||
applyFont(ctx, style);
|
||||
const metrics = ctx.measureText(text);
|
||||
const ascent = metrics.actualBoundingBoxAscent ?? style.fontSize * 0.8;
|
||||
const descent = metrics.actualBoundingBoxDescent ?? style.fontSize * 0.2;
|
||||
return {
|
||||
width: metrics.width,
|
||||
ascent,
|
||||
descent,
|
||||
height: ascent + descent,
|
||||
};
|
||||
}
|
||||
|
||||
export function alphabeticBaselineY(
|
||||
visualCenterY: number,
|
||||
glyphs: GlyphMetrics,
|
||||
): number {
|
||||
return visualCenterY + (glyphs.ascent - glyphs.descent) / 2;
|
||||
}
|
||||
|
||||
export function applyIdStyle(ctx: Canvas2D, style: IdDrawStyle): void {
|
||||
applyFont(ctx, style);
|
||||
ctx.fillStyle = style.color;
|
||||
}
|
||||
|
||||
function applyShadow(ctx: Canvas2D, style: IdDrawStyle): void {
|
||||
if (!style.shadowEnabled) {
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.shadowColor = hexToRgba(style.shadowColor, style.shadowOpacity);
|
||||
ctx.shadowBlur = style.shadowBlur;
|
||||
ctx.shadowOffsetX = style.shadowOffsetX;
|
||||
ctx.shadowOffsetY = style.shadowOffsetY;
|
||||
}
|
||||
|
||||
export function drawIdText(
|
||||
ctx: Canvas2D,
|
||||
text: string,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
style: IdDrawStyle,
|
||||
): void {
|
||||
const { x, y } = positionFromPercent(
|
||||
style.xPercent,
|
||||
style.yPercent,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
);
|
||||
const glyphs = measureGlyphs(ctx, text, style);
|
||||
const baselineY = alphabeticBaselineY(y, glyphs);
|
||||
|
||||
ctx.save();
|
||||
applyIdStyle(ctx, style);
|
||||
applyShadow(ctx, style);
|
||||
|
||||
if (style.outlineEnabled && style.outlineWidth > 0) {
|
||||
ctx.strokeStyle = hexToRgba(style.outlineColor, style.outlineOpacity);
|
||||
ctx.lineWidth = style.outlineWidth;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.miterLimit = 2;
|
||||
ctx.strokeText(text, x, baselineY);
|
||||
ctx.shadowColor = "transparent";
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, baselineY);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function measureIdTextBox(
|
||||
ctx: Canvas2D,
|
||||
text: string,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
style: IdDrawStyle,
|
||||
): TextBox {
|
||||
const glyphs = measureGlyphs(ctx, text, style);
|
||||
const { x, y } = positionFromPercent(
|
||||
style.xPercent,
|
||||
style.yPercent,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
);
|
||||
|
||||
let left = x;
|
||||
if (style.align === "center") left = x - glyphs.width / 2;
|
||||
if (style.align === "right") left = x - glyphs.width;
|
||||
|
||||
const outlinePad =
|
||||
style.outlineEnabled && style.outlineWidth > 0
|
||||
? style.outlineWidth / 2
|
||||
: 0;
|
||||
|
||||
return {
|
||||
left: left - outlinePad,
|
||||
top: y - glyphs.height / 2 - outlinePad,
|
||||
width: glyphs.width + outlinePad * 2,
|
||||
height: glyphs.height + outlinePad * 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function drawIdSelection(
|
||||
ctx: Canvas2D,
|
||||
box: TextBox,
|
||||
imageWidth: number,
|
||||
): void {
|
||||
const pad = Math.max(6, imageWidth / 400);
|
||||
ctx.save();
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.strokeStyle = "rgba(99, 102, 241, 0.95)";
|
||||
ctx.lineWidth = Math.max(1, imageWidth / 700);
|
||||
ctx.setLineDash([
|
||||
Math.max(4, imageWidth / 180),
|
||||
Math.max(3, imageWidth / 220),
|
||||
]);
|
||||
ctx.strokeRect(
|
||||
box.left - pad,
|
||||
box.top - pad,
|
||||
box.width + pad * 2,
|
||||
box.height + pad * 2,
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function hitTestTextBox(
|
||||
box: TextBox,
|
||||
x: number,
|
||||
y: number,
|
||||
imageWidth: number,
|
||||
): boolean {
|
||||
const pad = Math.max(10, imageWidth / 80);
|
||||
return (
|
||||
x >= box.left - pad &&
|
||||
x <= box.left + box.width + pad &&
|
||||
y >= box.top - pad &&
|
||||
y <= box.top + box.height + pad
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type PresetFont = {
|
||||
id: string;
|
||||
label: string;
|
||||
regular: string;
|
||||
bold: string;
|
||||
};
|
||||
|
||||
export const PRESET_FONTS: PresetFont[] = [
|
||||
{
|
||||
id: "Inter",
|
||||
label: "Inter",
|
||||
regular: "Inter-Regular.ttf",
|
||||
bold: "Inter-Bold.ttf",
|
||||
},
|
||||
{
|
||||
id: "Roboto",
|
||||
label: "Roboto",
|
||||
regular: "Roboto-Regular.ttf",
|
||||
bold: "Roboto-Bold.ttf",
|
||||
},
|
||||
{
|
||||
id: "Oswald",
|
||||
label: "Oswald",
|
||||
regular: "Oswald-Regular.ttf",
|
||||
bold: "Oswald-Bold.ttf",
|
||||
},
|
||||
{
|
||||
id: "Montserrat",
|
||||
label: "Montserrat",
|
||||
regular: "Montserrat-Regular.ttf",
|
||||
bold: "Montserrat-Bold.ttf",
|
||||
},
|
||||
{
|
||||
id: "Playfair Display",
|
||||
label: "Playfair Display",
|
||||
regular: "PlayfairDisplay-Regular.ttf",
|
||||
bold: "PlayfairDisplay-Bold.ttf",
|
||||
},
|
||||
];
|
||||
|
||||
export function isPresetFont(family: string): boolean {
|
||||
return PRESET_FONTS.some((font) => font.id === family);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
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<IdDrawStyle, "fontFamily" | "fontSize" | "bold" | "italic">,
|
||||
): 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<string, unknown>;
|
||||
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})`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { GlobalFonts } from "@napi-rs/canvas";
|
||||
import path from "node:path";
|
||||
import { PRESET_FONTS } from "./fonts";
|
||||
|
||||
let registered = false;
|
||||
|
||||
export function registerPresetFonts(): void {
|
||||
if (registered) return;
|
||||
|
||||
const dir = path.join(process.cwd(), "public", "fonts");
|
||||
for (const font of PRESET_FONTS) {
|
||||
GlobalFonts.registerFromPath(path.join(dir, font.regular), font.id);
|
||||
GlobalFonts.registerFromPath(path.join(dir, font.bold), font.id);
|
||||
}
|
||||
registered = true;
|
||||
}
|
||||
Reference in New Issue
Block a user