- Remove production DB dump (db_backup_*.sql) and update.log from git tracking - Add DB backups to .gitignore - Replace all console.log/console.error with structured logger module - Translate Dutch error messages to English (link-discord.ts) - Remove dead code blocks (register-form.tsx false && pattern) - Add ESLint flat config with TypeScript, React, Next.js, jsx-a11y, and security plugins - Add Prettier config - Add eslint-plugin-security for security-aware linting - Fix all 119+ ESLint warnings across the codebase: - Resolve security/detect-object-injection with safe access patterns - Resolve security/detect-non-literal-fs-filename with path traversal validation - Replace <img> with next/image <Image> component - Remove unused variables and imports - Replace non-null assertions with proper type guards - Replace <a> with <Link> for internal navigation - Use next/script Script component for external scripts - Fix setState-in-useEffect anti-patterns (navbar-color-picker, logo-generator, theme-switcher) - Add lint and format scripts to package.json All checks: typecheck ✓, tests 58/58 ✓, lint 0 errors 0 warnings ✓
156 lines
4.2 KiB
TypeScript
156 lines
4.2 KiB
TypeScript
const CHAR_ORDER = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
|
|
interface CharInfo {
|
|
char: string;
|
|
x: number;
|
|
w: number;
|
|
}
|
|
|
|
const fontCache = new Map<string, { chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }>();
|
|
|
|
function isTransparent(data: Uint8ClampedArray, x: number, y: number, w: number): boolean {
|
|
return data[(y * w + x) * 4 + 3] === 0;
|
|
}
|
|
|
|
function colEmpty(data: Uint8ClampedArray, x: number, w: number, h: number): boolean {
|
|
for (let y = 0; y < h; y++) {
|
|
if (!isTransparent(data, x, y, w)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function scanCharWidths(data: Uint8ClampedArray, sw: number, sh: number): CharInfo[] {
|
|
const chars: CharInfo[] = [];
|
|
let ci = 0;
|
|
let x = 0;
|
|
|
|
while (x < sw && ci < CHAR_ORDER.length) {
|
|
// skip transparent columns
|
|
while (x < sw && colEmpty(data, x, sw, sh)) x++;
|
|
if (x >= sw) break;
|
|
|
|
const start = x;
|
|
// find end of character
|
|
while (x < sw && !colEmpty(data, x, sw, sh)) x++;
|
|
const end = x;
|
|
|
|
const w = end - start;
|
|
if (w > 0) {
|
|
// eslint-disable-next-line security/detect-object-injection -- ci increments within CHAR_ORDER bounds
|
|
chars.push({ char: CHAR_ORDER[ci], x: start, w });
|
|
ci++;
|
|
}
|
|
}
|
|
|
|
return chars;
|
|
}
|
|
|
|
export async function getFontInfo(font: string): Promise<{ chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }> {
|
|
const cached = fontCache.get(font);
|
|
if (cached) return cached;
|
|
|
|
const img = new Image();
|
|
img.crossOrigin = "anonymous";
|
|
await new Promise<void>((resolve, reject) => {
|
|
img.onload = () => resolve();
|
|
img.onerror = () => reject(new Error(`Failed to load sprite for ${font}`));
|
|
img.src = `/assets/fonts/sheets/${font}.gif`;
|
|
});
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = img.naturalWidth;
|
|
canvas.height = img.naturalHeight;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) throw new Error("Could not get 2D context");
|
|
ctx.drawImage(img, 0, 0);
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
|
|
const chars = scanCharWidths(imageData.data, canvas.width, canvas.height);
|
|
|
|
// measure total height by finding first/last non-empty row
|
|
let top = canvas.height, bottom = 0;
|
|
for (let y = 0; y < canvas.height; y++) {
|
|
for (let x = 0; x < canvas.width; x++) {
|
|
if (!isTransparent(imageData.data, x, y, canvas.width)) {
|
|
top = Math.min(top, y);
|
|
bottom = Math.max(bottom, y);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const h = bottom - top + 1;
|
|
|
|
const avgW = chars.length > 0 ? Math.round(chars.reduce((s, c) => s + c.w, 0) / chars.length) : 12;
|
|
|
|
const result = { chars, h, top, sheet: img, avgW };
|
|
fontCache.set(font, result);
|
|
return result;
|
|
}
|
|
|
|
export function measureText(chars: CharInfo[], text: string, avgW: number): { totalW: number } {
|
|
const map = new Map(chars.map((c) => [c.char, c]));
|
|
let totalW = 0;
|
|
for (const ch of text.toLowerCase()) {
|
|
if (ch === " ") {
|
|
totalW += avgW;
|
|
} else {
|
|
const info = map.get(ch);
|
|
totalW += info?.w ?? 0;
|
|
}
|
|
}
|
|
return { totalW };
|
|
}
|
|
|
|
export function getMissingChars(chars: CharInfo[], text: string): Set<string> {
|
|
const available = new Set(chars.map((c) => c.char));
|
|
const missing = new Set<string>();
|
|
for (const ch of text.toLowerCase()) {
|
|
if (ch !== " " && !available.has(ch)) {
|
|
missing.add(ch);
|
|
}
|
|
}
|
|
return missing;
|
|
}
|
|
|
|
export function renderToCanvas(
|
|
chars: CharInfo[],
|
|
h: number,
|
|
top: number,
|
|
sheet: HTMLImageElement,
|
|
text: string,
|
|
avgW: number,
|
|
dpr = 1,
|
|
): HTMLCanvasElement | null {
|
|
const map = new Map(chars.map((c) => [c.char, c]));
|
|
const safeText = text.toLowerCase();
|
|
|
|
let totalW = 0;
|
|
for (const ch of safeText) {
|
|
totalW += ch === " " ? avgW : (map.get(ch)?.w ?? 0);
|
|
}
|
|
|
|
if (totalW === 0) return null;
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = Math.round(totalW * dpr);
|
|
canvas.height = Math.round(h * dpr);
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return null;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
let x = 0;
|
|
for (const ch of safeText) {
|
|
if (ch === " ") {
|
|
x += avgW;
|
|
} else {
|
|
const info = map.get(ch);
|
|
if (info) {
|
|
ctx.drawImage(sheet, info.x, top, info.w, h, x, 0, info.w, h);
|
|
x += info.w;
|
|
}
|
|
}
|
|
}
|
|
|
|
return canvas;
|
|
}
|