152 lines
4.8 KiB
TypeScript
152 lines
4.8 KiB
TypeScript
import sharp from "sharp"
|
|
|
|
// Character to vestaboard code mapping
|
|
const charToCode: Record<string, number> = {
|
|
" ": 0,
|
|
A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, I: 9,
|
|
J: 10, K: 11, L: 12, M: 13, N: 14, O: 15, P: 16, Q: 17,
|
|
R: 18, S: 19, T: 20, U: 21, V: 22, W: 23, X: 24, Y: 25, Z: 26,
|
|
"1": 27, "2": 28, "3": 29, "4": 30, "5": 31, "6": 32, "7": 33, "8": 34, "9": 35, "0": 36,
|
|
"!": 37, "@": 38, "#": 39, "$": 40, "(": 41, ")": 42, "-": 44, "+": 46,
|
|
"&": 47, "=": 48, ";": 49, ":": 50, "'": 52, '"': 53, "%": 54, ",": 55,
|
|
".": 56, "/": 59, "?": 60, "°": 62,
|
|
"🟥": 63, "🟧": 64, "🟨": 65, "🟩": 66, "🟦": 67, "🟪": 68, "⬜": 69, "⬛": 70,
|
|
}
|
|
|
|
// Code to display info for rendering
|
|
const codeToDisplay: Record<number, { bg: string; fg: string; char?: string }> = {
|
|
0: { bg: "#1a1a1a", fg: "#ffffff" }, // space (empty black tile)
|
|
63: { bg: "#e63946", fg: "#ffffff" }, // red
|
|
64: { bg: "#f4a261", fg: "#1a1a1a" }, // orange
|
|
65: { bg: "#e9c46a", fg: "#1a1a1a" }, // yellow
|
|
66: { bg: "#2a9d8f", fg: "#ffffff" }, // green
|
|
67: { bg: "#0077b6", fg: "#ffffff" }, // blue
|
|
68: { bg: "#9b5de5", fg: "#ffffff" }, // purple
|
|
69: { bg: "#ffffff", fg: "#1a1a1a" }, // white
|
|
70: { bg: "#1a1a1a", fg: "#ffffff" }, // black
|
|
}
|
|
|
|
// Code to character (for letters/numbers/symbols)
|
|
const codeToChar: Record<number, string> = {}
|
|
for (const [char, code] of Object.entries(charToCode)) {
|
|
if (code >= 1 && code <= 62) {
|
|
codeToChar[code] = char
|
|
}
|
|
}
|
|
|
|
const TILE_SIZE = 24
|
|
const GAP = 2
|
|
const COLS = 22
|
|
const ROWS = 6
|
|
|
|
// Parse emoji grid string to number array
|
|
export const parseEmojiGrid = (grid: string): number[][] => {
|
|
const lines = grid.trim().split("\n")
|
|
const result: number[][] = []
|
|
|
|
for (const line of lines) {
|
|
const row: number[] = []
|
|
const chars = [...line] // Handle multi-byte emoji correctly
|
|
|
|
for (const char of chars) {
|
|
const code = charToCode[char.toUpperCase()]
|
|
if (code !== undefined) {
|
|
row.push(code)
|
|
}
|
|
}
|
|
|
|
// Pad or trim to 22 columns
|
|
while (row.length < COLS) row.push(0)
|
|
if (row.length > COLS) row.length = COLS
|
|
|
|
result.push(row)
|
|
}
|
|
|
|
// Pad or trim to 6 rows
|
|
while (result.length < ROWS) result.push(Array(COLS).fill(0))
|
|
if (result.length > ROWS) result.length = ROWS
|
|
|
|
return result
|
|
}
|
|
|
|
// Convert number grid to emoji string
|
|
export const gridToEmoji = (grid: number[][]): string => {
|
|
const emojiMap: Record<number, string> = {
|
|
0: " ",
|
|
63: "🟥", 64: "🟧", 65: "🟨", 66: "🟩", 67: "🟦", 68: "🟪", 69: "⬜", 70: "⬛",
|
|
}
|
|
|
|
return grid
|
|
.map((row) =>
|
|
row
|
|
.map((code) => {
|
|
if (emojiMap[code] !== undefined) return emojiMap[code]
|
|
if (codeToChar[code]) return codeToChar[code]
|
|
return " "
|
|
})
|
|
.join("")
|
|
)
|
|
.join("\n")
|
|
}
|
|
|
|
// Render grid to SVG string
|
|
const gridToSvg = (grid: number[][]): string => {
|
|
const width = COLS * TILE_SIZE + (COLS - 1) * GAP
|
|
const height = ROWS * TILE_SIZE + (ROWS - 1) * GAP
|
|
|
|
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
|
|
svg += `<rect width="${width}" height="${height}" fill="#0d0d0d"/>` // background
|
|
|
|
for (let row = 0; row < ROWS; row++) {
|
|
for (let col = 0; col < COLS; col++) {
|
|
const code = grid[row]![col]!
|
|
const x = col * (TILE_SIZE + GAP)
|
|
const y = row * (TILE_SIZE + GAP)
|
|
|
|
// Determine tile appearance
|
|
let bg = "#1a1a1a"
|
|
let fg = "#ffffff"
|
|
let char: string | undefined
|
|
|
|
const display = codeToDisplay[code]
|
|
if (display) {
|
|
bg = display.bg
|
|
fg = display.fg
|
|
} else if (codeToChar[code]) {
|
|
char = codeToChar[code]
|
|
bg = "#1a1a1a"
|
|
fg = "#ffffff"
|
|
}
|
|
|
|
// Draw tile
|
|
svg += `<rect x="${x}" y="${y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="2" fill="${bg}"/>`
|
|
|
|
// Draw character if present
|
|
if (char) {
|
|
const fontSize = 14
|
|
const textX = x + TILE_SIZE / 2
|
|
const textY = y + TILE_SIZE / 2 + fontSize * 0.35
|
|
// Escape XML special characters
|
|
const escaped = char.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
svg += `<text x="${textX}" y="${textY}" font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold" fill="${fg}" text-anchor="middle">${escaped}</text>`
|
|
}
|
|
}
|
|
}
|
|
|
|
svg += "</svg>"
|
|
return svg
|
|
}
|
|
|
|
// Render emoji grid string to PNG buffer
|
|
export const renderToPng = async (emojiGrid: string): Promise<Buffer> => {
|
|
const grid = parseEmojiGrid(emojiGrid)
|
|
const svg = gridToSvg(grid)
|
|
return sharp(Buffer.from(svg)).png().toBuffer()
|
|
}
|
|
|
|
// Render number grid to PNG buffer
|
|
export const renderGridToPng = async (grid: number[][]): Promise<Buffer> => {
|
|
const svg = gridToSvg(grid)
|
|
return sharp(Buffer.from(svg)).png().toBuffer()
|
|
}
|