const fs = require('fs'); const path = require('path'); const { createCanvas, GlobalFonts } = require('@napi-rs/canvas'); const W = 1932; const H = 828; const outDir = __dirname; const fontCandidates = [ '/usr/share/fonts/truetype/nanum/NanumGothic.ttf', '/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf', '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', '/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc', ]; for (const fp of fontCandidates) { if (fs.existsSync(fp)) { GlobalFonts.registerFromPath(fp, path.basename(fp)); } } function pickFamily(weight = 'regular') { if (weight === 'bold' && fs.existsSync('/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf')) { return 'NanumGothicBold.ttf'; } if (fs.existsSync('/usr/share/fonts/truetype/nanum/NanumGothic.ttf')) { return 'NanumGothic.ttf'; } if (weight === 'bold' && fs.existsSync('/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc')) { return 'NotoSansCJK-Bold.ttc'; } if (fs.existsSync('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc')) { return 'NotoSansCJK-Regular.ttc'; } return 'sans-serif'; } function roundedRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function drawSteam(ctx, x, y, color, width = 18) { ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x, y + 56); ctx.bezierCurveTo(x - 22, y + 16, x + 28, y - 6, x + 10, y - 54); ctx.stroke(); } function drawV5Bowl(ctx, x, y, theme) { const bowlGrad = ctx.createLinearGradient(x - 220, y + 80, x + 220, y + 300); bowlGrad.addColorStop(0, theme.bowlTop); bowlGrad.addColorStop(1, theme.bowlBottom); ctx.fillStyle = bowlGrad; roundedRect(ctx, x - 250, y + 120, 500, 260, 120); ctx.fill(); ctx.fillStyle = theme.lipOuter; ctx.beginPath(); ctx.ellipse(x, y + 120, 280, 76, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = theme.lipInner; ctx.beginPath(); ctx.ellipse(x, y + 130, 185, 44, 0, 0, Math.PI * 2); ctx.fill(); const riceGrad = ctx.createLinearGradient(x - 150, y + 20, x + 150, y + 150); riceGrad.addColorStop(0, theme.riceA); riceGrad.addColorStop(1, theme.riceB); ctx.fillStyle = riceGrad; ctx.beginPath(); ctx.ellipse(x, y + 74, 230, 96, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.65)'; for (let i = 0; i < 18; i++) { const gx = x - 130 + i * 15; const gy = y + 54 + ((i % 2) * 7 - 3); ctx.beginPath(); ctx.ellipse(gx, gy, 6, 3, -0.2, 0, Math.PI * 2); ctx.fill(); } drawSteam(ctx, x - 110, y - 120, theme.steam, 16); drawSteam(ctx, x, y - 145, theme.steam, 18); drawSteam(ctx, x + 110, y - 120, theme.steam, 16); } function drawThumbnail({ filename, theme }) { const canvas = createCanvas(W, H); const ctx = canvas.getContext('2d'); const bg = ctx.createLinearGradient(0, 0, W, H); bg.addColorStop(0, theme.bgFrom); bg.addColorStop(1, theme.bgTo); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); ctx.fillStyle = theme.blob1; ctx.beginPath(); ctx.ellipse(240, 140, 290, 190, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = theme.blob2; ctx.beginPath(); ctx.ellipse(1680, 710, 380, 210, 0, 0, Math.PI * 2); ctx.fill(); roundedRect(ctx, 72, 62, W - 144, H - 124, 64); ctx.fillStyle = theme.card; ctx.fill(); drawV5Bowl(ctx, 520, 300, theme); ctx.fillStyle = theme.title; ctx.font = `700 112px "${pickFamily('bold')}"`; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('점메추', 910, 220); ctx.fillStyle = theme.subtitle; ctx.font = `700 54px "${pickFamily('bold')}"`; ctx.fillText('직장인 점심 추천', 915, 364); ctx.fillStyle = theme.desc; ctx.font = `600 36px "${pickFamily('regular')}"`; ctx.fillText('가까운 식당 찾기 · 카테고리별 탐색 · 리뷰/별점', 915, 448); roundedRect(ctx, 915, 520, 540, 94, 46); ctx.fillStyle = theme.badge; ctx.fill(); ctx.fillStyle = theme.badgeText; ctx.font = `700 42px "${pickFamily('bold')}"`; ctx.fillText('오늘 점심, 더 빠르게 결정', 965, 545); const output = path.join(outDir, filename); fs.writeFileSync(output, canvas.toBuffer('image/png')); return output; } const light = drawThumbnail({ filename: 'thumbnail-1932x828-light-v5.png', theme: { bgFrom: '#F6FAFF', bgTo: '#E5F1FF', blob1: 'rgba(108,174,255,0.16)', blob2: 'rgba(41,130,255,0.12)', card: 'rgba(255,255,255,0.9)', bowlTop: '#31B4F4', bowlBottom: '#0A85E8', lipOuter: '#DDE9F8', lipInner: '#8EA6C5', riceA: '#FFFFFF', riceB: '#EFF6FF', steam: '#5EA8EE', title: '#114B95', subtitle: '#1D5FB0', desc: '#3A5A84', badge: '#0B86E9', badgeText: '#FFFFFF', }, }); const dark = drawThumbnail({ filename: 'thumbnail-1932x828-dark-v5.png', theme: { bgFrom: '#07142A', bgTo: '#0F284C', blob1: 'rgba(102,180,255,0.12)', blob2: 'rgba(59,148,255,0.1)', card: 'rgba(18,42,78,0.84)', bowlTop: '#66CBFF', bowlBottom: '#1A9EF4', lipOuter: '#DDE6F2', lipInner: '#8CA2BC', riceA: '#FFFFFF', riceB: '#F2F7FF', steam: '#B8DCFF', title: '#EAF4FF', subtitle: '#B8D9FF', desc: '#9EC2E8', badge: '#2A9DF8', badgeText: '#FFFFFF', }, }); console.log(light); console.log(dark);