/** * 小红书封面 Playground * 用 node-canvas 画一张「截图风格」的封面,验证能否做出来。 * * 运行:在 backend 目录下执行 * npm run playground:xhs-cover * 或 * npx ts-node scripts/xhs-cover-playground.ts * * 输出:backend/playground-xhs-cover.png(用系统看图打开即可) */ import { createCanvas, CanvasRenderingContext2D } from 'canvas'; import fs from 'fs/promises'; import path from 'path'; // 小红书常用比例 3:4,先用小尺寸快速验证(可改为 1080×1440 出高清) const WIDTH = 540; const HEIGHT = 720; function wrapText( ctx: CanvasRenderingContext2D, text: string, maxWidth: number, fontSize: number, maxLines = 6 ): string[] { const lines: string[] = []; let currentLine = ''; for (const char of text.split('')) { const testLine = currentLine + char; const metrics = ctx.measureText(testLine); if (metrics.width > maxWidth && currentLine.length > 0) { lines.push(currentLine); currentLine = char; if (lines.length >= maxLines) break; } else { currentLine = testLine; } } if (currentLine.length > 0) lines.push(currentLine); return lines; } async function main() { const canvas = createCanvas(WIDTH, HEIGHT); const ctx = canvas.getContext('2d'); // 1. 浅黄背景 ctx.fillStyle = '#FFFDE7'; ctx.fillRect(0, 0, WIDTH, HEIGHT); // 2. 网格(细线) const gridStep = 24; ctx.strokeStyle = 'rgba(0,0,0,0.06)'; ctx.lineWidth = 0.5; for (let x = 0; x <= WIDTH; x += gridStep) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, HEIGHT); ctx.stroke(); } for (let y = 0; y <= HEIGHT; y += gridStep) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(WIDTH, y); ctx.stroke(); } // 3. 白色圆角卡片(居中,留边距) const padding = 48; const cardX = padding; const cardY = 120; const cardW = WIDTH - padding * 2; const cardH = 380; const radius = 20; // 圆角矩形(兼容无 roundRect 的环境) function drawRoundRect(x: number, y: number, w: number, h: number, r: number) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } ctx.fillStyle = '#FFFFFF'; drawRoundRect(cardX, cardY, cardW, cardH, radius); ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.04)'; ctx.lineWidth = 0.5; drawRoundRect(cardX, cardY, cardW, cardH, radius); ctx.stroke(); // 4. 卡片顶部黄色小条(像截图里的 accent tab) const tabW = 60; const tabH = 12; const tabX = cardX + (cardW - tabW) / 2; const tabY = cardY - tabH / 2; ctx.fillStyle = '#FFD93D'; drawRoundRect(tabX, tabY, tabW, tabH, 4); ctx.fill(); // 5. 卡片内多行文字(加粗、黑色) const textX = cardX + 32; const textY = cardY + 44; const lineHeight = 38; const maxTextWidth = cardW - 64; // 尽量用系统里有的中文字体(Mac 有 PingFang SC) ctx.font = 'bold 26px "PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif'; ctx.fillStyle = '#000000'; ctx.textAlign = 'left'; const lines = [ '谁懂!', '人生最好的事情', '不过是:', '拥有好朋友和', '幸福生活!', ]; let y = textY; for (const line of lines) { const wrapped = wrapText(ctx, line, maxTextWidth, 26, 1); for (const seg of wrapped) { ctx.fillText(seg, textX, y); y += lineHeight; } } // 6. 卡片左下角装饰虚线(几条短横线) const dashY = cardY + cardH - 40; ctx.strokeStyle = '#FFD93D'; ctx.lineWidth = 2; ctx.setLineDash([6, 6]); for (let i = 0; i < 4; i++) { ctx.beginPath(); ctx.moveTo(textX + i * 36, dashY); ctx.lineTo(textX + i * 36 + 24, dashY); ctx.stroke(); } ctx.setLineDash([]); // 7. 右下角「贴纸」占位(画一个圆 + 文字,模拟 emoji 位;真 emoji 可后续用图片贴) const stickerX = cardX + cardW - 72; const stickerY = cardY + cardH - 72; ctx.fillStyle = '#FFF3CD'; ctx.beginPath(); ctx.arc(stickerX, stickerY, 32, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.08)'; ctx.lineWidth = 1; ctx.stroke(); ctx.font = '20px "PingFang SC", sans-serif'; ctx.fillStyle = '#333'; ctx.textAlign = 'center'; ctx.fillText('贴纸', stickerX, stickerY + 6); // 8. 导出 PNG const outPath = path.join(process.cwd(), 'playground-xhs-cover.png'); const buffer = canvas.toBuffer('image/png'); await fs.writeFile(outPath, buffer); console.log('✅ 已生成:', outPath); console.log(' 用系统看图/预览打开即可查看效果。'); } main().catch((e) => { console.error('生成失败:', e); process.exit(1); });