199 lines
5.4 KiB
JavaScript
199 lines
5.4 KiB
JavaScript
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);
|