180 lines
4.9 KiB
TypeScript
180 lines
4.9 KiB
TypeScript
/**
|
||
* 小红书封面 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);
|
||
});
|