const { createCanvas, loadImage } = (() => { try { return require('canvas') } catch { return {} } })() const http = require('http') const fs = require('fs') const path = require('path') const W = 2560, H = 1440 const PRESETS = [ { id: 'glyph', name: 'Glyph', desc: '5x5', tag: 'VERY EASY', icon: 'feather' }, { id: 'rune', name: 'Rune', desc: '5x5', tag: 'EASY', icon: 'diamond' }, { id: 'scroll', name: 'Scroll', desc: '9x9', tag: 'MEDIUM', icon: 'compass' }, { id: 'codex', name: 'Codex', desc: '15x15', tag: 'HARD', icon: 'flame' }, { id: 'grimoire', name: 'Grimoire', desc: '21x21', tag: 'EXPERT', icon: 'meteor' }, ] function fetchJSON(urlPath) { return new Promise((resolve) => { http.get(`http://127.0.0.1:8080${urlPath}`, res => { if (res.statusCode !== 200) { resolve(null); res.resume(); return } let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)) } catch { resolve(null) } }) }).on('error', () => resolve(null)) }) } async function loadIcon(name, size) { const dataDir = process.env.DATA_DIR || '/app/data' const svgPath = path.join(dataDir, 'icons', `${name}.svg`) try { let svg = fs.readFileSync(svgPath, 'utf8') // set viewBox and size for rendering at target size svg = svg.replace(/width="24"/, `width="${size}"`) svg = svg.replace(/height="24"/, `height="${size}"`) const buf = Buffer.from(svg) return await loadImage(buf) } catch (e) { console.log(`could not load icon ${name}: ${e.message}`) return null } } async function main() { console.log('daily image generator starting') if (!createCanvas) { console.log('canvas module not available, skipping'); process.exit(0) } const today = new Date().toISOString().split('T')[0] console.log('generating for', today) const dateDisplay = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).toUpperCase() const grids = await Promise.all(PRESETS.map(async p => { const res = await fetchJSON(`/api/puzzles/daily/${today}/${p.id}`) return res ? { width: res.puzzle.width, height: res.puzzle.height, cells: res.puzzle.cells } : null })) // preload icons const iconSize = 56 const icons = await Promise.all(PRESETS.map(p => loadIcon(p.icon, iconSize))) const canvas = createCanvas(W, H) const ctx = canvas.getContext('2d') ctx.fillStyle = '#111111' ctx.fillRect(0, 0, W, H) const cardW = 400, cardGap = 32, headerH = 240, gridPad = 24, bottomPad = 24 const gridAreaW = cardW - gridPad * 2, maxGridH = 600 let tallestCard = headerH + bottomPad for (const grid of grids) { if (grid) { const cellSize = Math.min(gridAreaW / grid.width, maxGridH / grid.height) tallestCard = Math.max(tallestCard, headerH + cellSize * grid.height + bottomPad) } } const totalCardsW = 5 * cardW + 4 * cardGap const startX = (W - totalCardsW) / 2 const topRuleH = 3, nameH = 190, dateH = 120, bottomRuleH = 3 const gapAfterRule = 80, todaysH = 52, gapBeforeCards = 40 const accentGap = 50, accentH = 3 const totalContentH = topRuleH + nameH + dateH + bottomRuleH + gapAfterRule + todaysH + gapBeforeCards + tallestCard + accentGap + accentH let y = Math.max(0, (H - totalContentH) / 2) // top rule ctx.fillStyle = '#ededed' ctx.fillRect(140, y, W - 280, topRuleH) y += topRuleH // title ctx.fillStyle = '#ededed' ctx.font = 'bold 180px serif' ctx.textAlign = 'center' ctx.fillText('CRUCIVERB', W / 2, y + nameH) y += nameH // date ctx.fillStyle = '#ababab' ctx.font = '32px monospace' ctx.fillText(dateDisplay, W / 2, y + 55) y += dateH // bottom rule ctx.fillStyle = '#ededed' ctx.fillRect(140, y, W - 280, bottomRuleH) y += bottomRuleH + gapAfterRule // subtitle ctx.fillStyle = '#b5b5b5' ctx.font = 'italic 52px serif' ctx.fillText("Today's puzzles", W / 2, y + todaysH * 0.75) y += todaysH + gapBeforeCards const cardY = y let maxCardBottom = cardY // cards for (let i = 0; i < 5; i++) { const p = PRESETS[i], grid = grids[i] const cx = startX + i * (cardW + cardGap) let cardH = headerH + bottomPad let gridDrawH = 0 if (grid) { const cellSize = Math.min(gridAreaW / grid.width, maxGridH / grid.height) gridDrawH = cellSize * grid.height cardH = headerH + gridDrawH + bottomPad } maxCardBottom = Math.max(maxCardBottom, cardY + cardH) // card background ctx.fillStyle = '#1a1a1a' ctx.fillRect(cx, cardY, cardW, cardH) ctx.strokeStyle = '#2a2a2a' ctx.lineWidth = 1 ctx.strokeRect(cx, cardY, cardW, cardH) // icon if (icons[i]) { ctx.drawImage(icons[i], cx + cardW / 2 - iconSize / 2, cardY + 28, iconSize, iconSize) } // preset name ctx.fillStyle = '#ededed' ctx.font = 'bold 56px serif' ctx.textAlign = 'center' ctx.fillText(p.name, cx + cardW / 2, cardY + 150) // size and tag ctx.fillStyle = '#ababab' ctx.font = '20px monospace' ctx.fillText(`${p.desc} ${p.tag}`, cx + cardW / 2, cardY + 185) // grid if (grid) { const cellSize = Math.min(gridAreaW / grid.width, maxGridH / grid.height) const gridW = cellSize * grid.width, gridH = cellSize * grid.height const ox = cx + gridPad + (gridAreaW - gridW) / 2 const oy = cardY + headerH for (let r = 0; r < grid.height; r++) { for (let c = 0; c < grid.width; c++) { const px = ox + c * cellSize, py = oy + r * cellSize ctx.fillStyle = grid.cells[r][c] === 'black' ? '#090909' : '#1e1e1e' ctx.fillRect(px, py, cellSize, cellSize) ctx.strokeStyle = '#555555' ctx.lineWidth = 0.5 ctx.strokeRect(px, py, cellSize, cellSize) } } } } // accent line ctx.fillStyle = '#d4432f' ctx.fillRect(140, maxCardBottom + accentGap, W - 280, accentH) // save with today's date and as canonical name const buf = canvas.toBuffer('image/jpeg', { quality: 0.85 }) const dataDir = process.env.DATA_DIR || '/app/data' fs.writeFileSync(`${dataDir}/daily-image-${today}.jpg`, buf) fs.writeFileSync(`${dataDir}/daily-image.jpg`, buf) console.log(`saved daily image for ${today} (${buf.length} bytes)`) } main().catch(e => { console.error(e); process.exit(1) })