json-poster
Version:
Generate posters by configuring json
563 lines (562 loc) • 27 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createPoster = exports.loadFont = void 0;
const sharp_1 = __importDefault(require("sharp"));
const axios_1 = __importDefault(require("axios"));
const fontkit_1 = __importDefault(require("fontkit"));
const types_1 = require("./types");
// import { posterDatas } from './demoData';
// 使用fontkit或者canvas加载字体,或者直接将新加字体放到系统字体目录
// 加载字体
const loadFont = (filename, postscriptName) => {
return fontkit_1.default.openSync(filename, postscriptName);
};
exports.loadFont = loadFont;
/**
* 创建海报 多种对齐方式
* 1、图片(高斯模糊、圆角、缩放模式)
* 2、矩形(线性渐变、径向渐变、高斯模糊、圆角)
* 3、文本(多行、省略号、字体设置)
* 4、分片文本(多行、省略号、分片样式、字体设置)
* 5、直线
* 6、旋转【计划更新】
* 7、贝塞尔曲线【计划更新】
* 8、其他形状【计划更新】
*/
const createPoster = (data) => __awaiter(void 0, void 0, void 0, function* () {
const { width, height, background, elements } = data;
// 创建背景渐变
const backgroundGradient = createGradientSVG(width, height, background);
let image = (0, sharp_1.default)(Buffer.from(backgroundGradient));
let composites = [];
// 按照zIndex排序
const sortedElements = elements.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
for (const element of sortedElements) {
const position = yield getElementPosition(element, width, height);
const type = element.type.toLocaleLowerCase();
if (type === types_1.ElementType.IMG) {
composites.push(yield drawImgWithSharp(element, position));
}
else if (type === types_1.ElementType.TEXT) {
composites.push(yield drawTextWithSharp(element, position));
}
else if (type === types_1.ElementType.RECT) {
composites.push(yield drawRectWithSharp(element, position));
}
else if (type === types_1.ElementType.LINE) {
composites.push(yield drawLineWithSharp(element, position));
}
else if (type === types_1.ElementType.MUTIPLE_TEXT) {
composites.push(yield drawMutipleTextWithSharp(element, position));
}
}
yield image.composite(composites);
return image.png(); // stream
// return image.png().toBuffer(); // buffer
// return image.png().toBuffer().then(buf => `data:image/png;base64,${buf.toString('base64')}`); // base64
// 将生成的海报写入文件
// const outputPath = path.resolve(__dirname, './test.png');
// await image.toFile(outputPath);
// console.log(`Poster saved to ${outputPath}`);
});
exports.createPoster = createPoster;
function drawImgWithSharp(element, position) {
return __awaiter(this, void 0, void 0, function* () {
const response = yield axios_1.default.get(element.content, { responseType: 'arraybuffer' });
const imgBuffer = Buffer.from(response.data);
const originalImage = (0, sharp_1.default)(imgBuffer);
const metadata = yield originalImage.metadata();
const cropInfo = getCropImageInfo(element.mode, metadata.width, metadata.height, element.width, element.height);
let processedBuffer = yield originalImage
.extract({ left: cropInfo.sx, top: cropInfo.sy, width: cropInfo.sw, height: cropInfo.sh })
.resize(cropInfo.dw, cropInfo.dh)
.toBuffer();
if (element.gaussBlur) {
let blurValue = (element.gaussRadius || 10) / 2 + 1;
processedBuffer = yield (0, sharp_1.default)(processedBuffer).blur(blurValue).toBuffer();
}
if (element.borderRadius && element.borderRadius > 0) {
let radiusMask = getRadiusMask({ width: cropInfo.dw, height: cropInfo.dh, borderRadius: element.borderRadius });
processedBuffer = yield (0, sharp_1.default)(processedBuffer).png().composite([radiusMask]).toBuffer();
}
return { input: processedBuffer, top: position.y + cropInfo.offsetY, left: position.x + cropInfo.offsetX };
});
}
function drawTextWithSharp(element, position) {
return __awaiter(this, void 0, void 0, function* () {
const content = yield getTextContent(element);
const lineHeight = element.lineHeight || (element.maxLine ? element.height / element.maxLine : element.fontSize);
const padding = (lineHeight - element.fontSize) / 2;
let textSvg = '';
for (let index = 0; index < content.length; index++) {
const text = content[index];
textSvg +=
`<text x="0" y="${lineHeight * (index + 1) - padding}" font-family="${element.fontFamily || 'Arial'}" font-size="${element.fontSize}" letter-spacing="${element.letterSpacing || 0}" dominant-baseline="text-before-edge" text-anchor="start">
${text}
</text>`;
}
const svg = `
<svg width="${element.width}" height="${element.height}" xmlns="http://www.w3.org/2000/svg">
${textSvg}
</svg>
`;
const svgBuffer = Buffer.from(svg);
return { input: svgBuffer, top: position.y, left: position.x };
});
}
function drawMutipleTextWithSharp(element, position) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
let textFragments = [];
for (let j = 0; j < element.content.length; j++) {
let text = removeRichTextLabel(element.content[j].content).split('');
let subTextFragments = [];
for (let index = 0; index < text.length; index++) {
let subTextFragment = getNewSubTextFragment(element, Object.assign(Object.assign({}, element.content[j]), { content: text[index], fragmentId: j }));
subTextFragments.push(subTextFragment);
}
textFragments = textFragments.concat(subTextFragments);
}
const textFragmentsList = yield getTextFragmentContent(element, textFragments);
const lineHeight = element.lineHeight || (element.maxLine ? element.height / element.maxLine : element.fontSize);
const padding = (lineHeight - element.fontSize) / 2;
let textSvg = '';
for (let i = 0; i < textFragmentsList.length; i++) {
const textFragmentsListItem = textFragmentsList[i];
let tspanSvg = '';
let textFragmentsItemId = null;
for (let j = 0; j < textFragmentsListItem.length; j++) {
const textFragmentsItem = textFragmentsListItem[j];
if (textFragmentsItemId !== textFragmentsItem.fragmentId) {
textFragmentsItemId = textFragmentsItem.fragmentId;
tspanSvg +=
`<tspan
font-family="${textFragmentsItem.fontFamily || 'Arial'}"
font-weight="${textFragmentsItem.fontWeight}"
font-size="${textFragmentsItem.fontSize}"
letter-spacing="${textFragmentsItem.letterSpacing || 0}"
fill="${textFragmentsItem.color}"
>`;
}
tspanSvg += textFragmentsItem.content;
if (textFragmentsItemId !== ((_a = textFragmentsListItem[j + 1]) === null || _a === void 0 ? void 0 : _a.fragmentId) || j === textFragmentsListItem.length - 1) {
tspanSvg += '</tspan>';
}
}
textSvg += `<text x="0" y="${lineHeight * (i + 1) - padding}" text-anchor="start">${tspanSvg}</text>`;
}
const svg = `
<svg width="${element.width}" height="${element.height}" xmlns="http://www.w3.org/2000/svg">
${textSvg}
</svg>
`;
const svgBuffer = Buffer.from(svg);
return { input: svgBuffer, top: position.y, left: position.x };
});
}
function drawRectWithSharp(element, position) {
return __awaiter(this, void 0, void 0, function* () {
let defsColor = getDefsColor(element.color);
const svgRect = `
<svg width="${element.width}" height="${element.height}" xmlns="http://www.w3.org/2000/svg">
${defsColor.defs}
<rect width="${element.width}" height="${element.height}" ${defsColor.fillStyle} rx="${element.borderRadius}" ry="${element.borderRadius}" opacity="${element.opacity || 1}" />
</svg>
`;
let svgBuffer = Buffer.from(svgRect);
if (element.gaussBlur) {
let blurValue = (element.gaussRadius || 10) / 2 + 1;
let radiusMask = getRadiusMask(element);
svgBuffer = yield (0, sharp_1.default)(svgBuffer).blur(blurValue).composite([radiusMask]).toBuffer();
}
return { input: svgBuffer, top: position.y, left: position.x };
});
}
// 创建圆角蒙版
function getRadiusMask(element) {
const mask = Buffer.from(`
<svg><rect
x="0"
y="0"
width="${element.width}"
height="${element.height}"
rx="${element.borderRadius || 0}"
ry="${element.borderRadius || 0}"
/></svg>
`);
return {
input: mask,
blend: 'dest-in'
};
}
function drawLineWithSharp(element, position) {
return __awaiter(this, void 0, void 0, function* () {
const svgLine = `
<svg width="${element.width}" height="${element.height}" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="${element.height / 2}" x2="${element.width}" y2="${element.height / 2}" stroke="${element.color}" stroke-width="${element.height}" opacity="${element.opacity || 1}" />
</svg>
`;
const svgBuffer = Buffer.from(svgLine);
return { input: svgBuffer, top: position.y, left: position.x };
});
}
function createGradientSVG(width, height, color) {
let defsColor = getDefsColor(color);
let svgstr = `
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
${defsColor.defs}
<rect width="100%" height="100%" ${defsColor.fillStyle} />
</svg>
`;
return svgstr;
}
function getDefsColor(color) {
if (typeof color === 'object' && color !== null && 'colors' in color) {
let defs;
if (color.type === 'radial') {
let { colors = [[0, '#fff'], [1, '#fff']], center = [0.5, 0.5], radius = 1.0 } = color;
// 将中心点从0-1的比例转换为百分比
const centerX = center[0] * 100;
const centerY = center[1] * 100;
// 将半径从0-1的比例转换为百分比
const radiusPercent = radius * 100;
defs = `
<defs>
<radialGradient id="rectGradient" cx="${centerX}%" cy="${centerY}%" r="${radiusPercent}%" fx="${centerX}%" fy="${centerY}%">
${colors.map(([offset, color]) => `
<stop offset="${offset * 100}%" style="stop-color:${color};stop-opacity:1" />
`).join('')}
</radialGradient>
</defs>
`;
}
else {
let { colors = [[0, '#fff'], [1, '#fff']], rotate = 0 } = color;
// 标准化角度到 0-360 范围内
const normalizedRotate = ((rotate % 360) + 360) % 360;
// 计算渐变的起点和终点百分比坐标
let x1, y1, x2, y2;
// 处理特殊角度,避免三角函数计算误差
if (normalizedRotate === 0) {
// 从左到右
x1 = 0;
y1 = 0;
x2 = 100;
y2 = 0;
}
else if (normalizedRotate === 90) {
// 从上到下
x1 = 0;
y1 = 0;
x2 = 0;
y2 = 100;
}
else if (normalizedRotate === 180) {
// 从右到左
x1 = 100;
y1 = 0;
x2 = 0;
y2 = 0;
}
else if (normalizedRotate === 270) {
// 从下到上
x1 = 0;
y1 = 100;
x2 = 0;
y2 = 0;
}
else {
// 其他角度使用三角函数计算
const radian = (normalizedRotate * Math.PI) / 180;
const distance = 50; // 使用固定距离确保渐变覆盖
// 以50%,50%为中心点计算
x1 = 50 - Math.cos(radian) * distance;
y1 = 50 - Math.sin(radian) * distance;
x2 = 50 + Math.cos(radian) * distance;
y2 = 50 + Math.sin(radian) * distance;
}
// 处理渐变色
defs = `
<defs>
<linearGradient id="rectGradient" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">
${colors.map(([offset, color]) => `
<stop offset="${offset * 100}%" style="stop-color:${color};stop-opacity:1" />
`).join('')}
</linearGradient>
</defs>
`;
}
return { defs, fill: 'url(#rectGradient)', fillStyle: 'fill="url(#rectGradient)"' };
}
else {
// 处理单一颜色
return {
defs: '',
fill: typeof color === 'string' ? color : '',
fillStyle: typeof color === 'string' ? `fill="${color}"` : '',
};
}
}
// 计算图片相关信息
// mode 图片裁剪、缩放的模式
// iw:图片实际宽度 ih:图片实际高度
// dw:图片在画布上需要绘制的宽度 dh:图片在画布上需要绘制的高度
// sx:图片裁剪的x起始坐标 sy:图片裁剪的y起始坐标
// sw:图片裁剪的宽度 sh图片裁剪的高度
// offsetX、offsetY 在aspectFit模式下,图片可能存在留白,需要进行偏移以保持坐标的准确性
function getCropImageInfo(mode = 'scaleToFill', iw, ih, dw, dh) {
// 默认模式 scaleToFill 缩放模式,不保持纵横比缩放图片,使图片的宽高完全拉伸至填满 image 元素
let sw = iw, sh = ih, dh1 = dh, dw1 = dw, sx = 0, sy = 0;
let offsetX = 0, offsetY = 0, iScale = iw / ih, dScale = dw / dh;
if (mode === 'aspectFit') {
// aspectFit 缩放模式,保持纵横比缩放图片,使图片的长边能完全显示出来。也就是说,可以完整地将图片显示出来。(返回新的绘制大小 需要重新计算绘制坐标)
if (iScale > dScale) {
dh1 = dw / iScale;
offsetY = (dh - dh1) / 2;
}
else if (iScale < dScale) {
dw1 = dh * iScale;
offsetX = (dw - dw1) / 2;
}
}
else if (mode === 'aspectFill') {
// aspectFill 缩放模式,保持纵横比缩放图片,只保证图片的短边能完全显示出来。也就是说,图片通常只在水平或垂直方向是完整的,另一个方向将会发生截取。
if (iScale > dScale) {
sw = ih * dScale;
}
else if (iScale < dScale) {
sh = iw / dScale;
}
sx = (iw - sw) / 2;
sy = (ih - sh) / 2;
}
return {
sx: Number(sx.toFixed()), sy: Number(sy.toFixed()),
sw: Number(sw.toFixed()), sh: Number(sh.toFixed()),
dh: Number(dh1.toFixed()), dw: Number(dw1.toFixed()),
offsetX: Number(offsetX.toFixed()), offsetY: Number(offsetY.toFixed())
};
}
function getElementPosition(element, canvasWidth, canvasHeight) {
return __awaiter(this, void 0, void 0, function* () {
let x = 0, y = 0, elementWidth = element.width;
const type = element.type.toLocaleLowerCase();
if (type === types_1.ElementType.TEXT) {
const elementText = element;
let content = removeRichTextLabel(elementText.content);
let TextFragment = Object.assign(Object.assign({}, element), { content });
let contentWidth = yield getTextWidth(TextFragment);
if (contentWidth < elementWidth) {
elementWidth = contentWidth;
}
}
else if (type === types_1.ElementType.MUTIPLE_TEXT) {
const elementMutipleText = element;
let contentWidth = yield getFragmentsTextWidth(elementMutipleText.content);
if (contentWidth < elementWidth) {
elementWidth = contentWidth;
}
}
if (element.x === types_1.xAlign.LEFT || element.align === types_1.ElementAlign.LEFT) {
x = 0;
}
else if (element.x === types_1.xAlign.RIGHT || element.align === types_1.ElementAlign.RIGHT) {
x = canvasWidth - elementWidth;
}
else if (element.x === types_1.xAlign.CENTER || element.align === types_1.ElementAlign.CENTER) {
x = (canvasWidth - elementWidth) / 2;
}
else {
x = element.x;
}
if (element.y === types_1.yAlign.TOP) {
y = 0;
}
else if (element.y === types_1.yAlign.BOTTOM) {
y = canvasHeight - element.height;
}
else if (element.y === types_1.yAlign.CENTER) {
y = (canvasHeight - element.height) / 2;
}
else {
y = element.y;
}
return { x: Number(x.toFixed()), y: Number(y.toFixed()) };
});
}
function getTextContent(element) {
return __awaiter(this, void 0, void 0, function* () {
let maxLine = element.maxLine || Infinity;
let content = removeRichTextLabel(element.content); // 提取标签中的文案
let strList = []; // 每一行的字符串
let lineCount = 1; // 当前操作行为第几行
let starIndex = 0; // 某一行第一个字符串在所有字符串中的索引
let maxWidth = element.width; // 最大宽度
let ellipsisText = '...'; // 省略号
let lastLineAllTextWidth = 0; // 最后一行所有文字的宽度(包含溢出的总长度)
for (let index = 1; index < content.length; index++) {
let currentLineText = content.substring(starIndex, index); // 截取某一行第1个到第index个字符串片段
let currentLineWidth = yield getTextWidth(getNewSubTextFragment(element, { content: currentLineText })); // 获取该字符串片段长度
if (!lastLineAllTextWidth && lineCount >= maxLine) {
lastLineAllTextWidth = yield getTextWidth(getNewSubTextFragment(element, { content: currentLineText + content.substring(index, content.length) })); // 获取该字符串片段长度
}
let addEllipsis = lineCount >= maxLine && (lastLineAllTextWidth > maxWidth); // 当前行是否添加省略号
let ellipsisLineWidth = yield getTextWidth(getNewSubTextFragment(element, { content: currentLineText + ellipsisText })); // 获取该字符串片段长度
let compareWidth = addEllipsis ? ellipsisLineWidth : currentLineWidth; // 要对比的宽度
if (content[index] === '\n') { // 匹配到换行符 直接换行
let endIndex = index;
strList.push(content.substring(starIndex, endIndex)); // 记录一行字符串
starIndex = endIndex; // 设置下一行首个字符串索引
lineCount++; // 换行
}
else if (compareWidth >= maxWidth) { // 当前行字符串宽度超过了最大宽度 开始换行操作
let endIndex = compareWidth === maxWidth ? index : index - 1; // 最后一个字符串索引
strList.push(content.substring(starIndex, endIndex) + (addEllipsis ? ellipsisText : '')); // 记录一行字符串 和末尾符号
starIndex = endIndex; // 设置下一行首个字符串索引
lineCount++; // 换行
}
else if (index === content.length - 1) { // 如果当前行字符串宽度没有超过最大宽度,但是已经到了最后一个字符串则直接截取最后一行字符串 结束整个循环
strList.push(content.substring(starIndex, content.length));
break;
}
if (lineCount > maxLine)
break; // 超出最大行数不在循环剩余文字 结束整个循环
}
return strList;
});
}
function getTextFragmentContent(element, textFragments) {
return __awaiter(this, void 0, void 0, function* () {
let maxLine = element.maxLine || Infinity; // 最多显示几行文本
let textFragmentsList = []; // 每一行的字符串
let lineCount = 1; // 当前操作行为第几行
let starIndex = 0; // 某一行第一个字符串在所有字符串中的索引
let maxWidth = element.width; // 最大宽度
let ellipsisTextFragment = getNewSubTextFragment(element, { content: '...' });
let lastLineAllTextWidth = 0; // 最后一行所有文字的宽度(包含溢出的总长度)
for (let index = 1; index < textFragments.length; index++) {
let fragments = textFragments.slice(starIndex, index); // 截取某一行第1个到第index个字符串片段
let currentLineWidth = yield getFragmentsTextWidth(fragments); // 获取该字符串片段长度
if (!lastLineAllTextWidth && lineCount >= maxLine) {
lastLineAllTextWidth = yield getFragmentsTextWidth([...fragments, ...textFragments.slice(index, textFragments.length)]); // 获取该字符串片段长度
}
let addEllipsis = lineCount >= maxLine && (lastLineAllTextWidth > maxWidth); // 当前行是否添加省略号
let ellipsisLineWidth = yield getFragmentsTextWidth([...fragments, ellipsisTextFragment]); // 获取该字符串片段长度
let compareWidth = addEllipsis ? ellipsisLineWidth : currentLineWidth; // 要对比的宽度
if (textFragments[index].content === '\n') { // 匹配到换行符 直接换行
let endIndex = index;
textFragmentsList.push(textFragments.slice(starIndex, endIndex)); // 记录一行字符串
starIndex = endIndex; // 设置下一行首个字符串索引
lineCount++; // 换行
}
else if (compareWidth >= maxWidth) { // 当前行字符串宽度超过了最大宽度 开始换行操作
let endIndex = compareWidth === maxWidth ? index : index - 1; // 最后一个字符串索引
textFragmentsList.push([...textFragments.slice(starIndex, endIndex), ...(addEllipsis ? [ellipsisTextFragment] : [])]); // 记录一行字符串 和末尾符号
starIndex = endIndex; // 设置下一行首个字符串索引
lineCount++; // 换行
}
else if (index === textFragments.length - 1) { // 如果当前行字符串宽度没有超过最大宽度,但是已经到了最后一个字符串则直接截取最后一行字符串 结束整个循环
textFragmentsList.push(textFragments.slice(starIndex, textFragments.length));
break;
}
if (lineCount > maxLine)
break; // 超出最大行数不在循环剩余文字 结束整个循环
}
return textFragmentsList;
});
}
function getNewSubTextFragment(father, params) {
return Object.assign({ fontFamily: father.fontFamily || 'Arial', fontWeight: father.fontWeight, color: father.color, fontSize: father.fontSize, letterSpacing: father.letterSpacing }, params);
}
function getFragmentsTextWidth(textFragments) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
let tspanSvg = '';
let textFragmentId = null;
for (let i = 0; i < textFragments.length; i++) {
const textFragmentsItem = textFragments[i];
if (textFragmentId !== textFragmentsItem.fragmentId) {
textFragmentId = textFragmentsItem.fragmentId;
tspanSvg +=
`
<tspan
font-family="${textFragmentsItem.fontFamily || 'Arial'}"
font-weight="${textFragmentsItem.fontWeight}"
font-size="${textFragmentsItem.fontSize}"
letter-spacing="${textFragmentsItem.letterSpacing || 0}"
fill="${textFragmentsItem.color}"
>
`;
}
tspanSvg += textFragmentsItem.content;
if (textFragmentId !== ((_a = textFragments[i + 1]) === null || _a === void 0 ? void 0 : _a.fragmentId) || i === textFragments.length - 1) {
tspanSvg +=
`
</tspan>
`;
}
}
const svg = `
<svg xmlns="http://www.w3.org/2000/svg">
<text x="50%" y="100%" text-anchor="middle">
${tspanSvg}
</text>
</svg>
`;
let img = yield (0, sharp_1.default)(Buffer.from(svg)).metadata();
return img.width || 0;
});
}
function getTextWidth(fontObj) {
return __awaiter(this, void 0, void 0, function* () {
if (fontObj.content === '\n' || !fontObj.content)
return 0;
const svgText = `
<svg xmlns="http://www.w3.org/2000/svg">
<text
x="50%"
y="100%"
font-size="${fontObj.fontSize || 16}"
font-weight="${fontObj.fontWeight || 'normal'}"
font-family="${fontObj.fontFamily || 'Arial'}"
letter-spacing="${fontObj.letterSpacing || 0}"
text-anchor="middle"
>
${fontObj.content}
</text>
</svg>
`;
let img = yield (0, sharp_1.default)(Buffer.from(svgText)).metadata();
return img.width || 0;
});
}
// 去除富文本标签
function removeRichTextLabel(richText) {
let reg = new RegExp(/<.+?>/g);
let content = '';
if (reg.test(richText)) {
/* 去除富文本中的html标签 */
/* *、+限定符都是贪婪的,因为它们会尽可能多的匹配文字,只有在它们的后面加上一个?就可以实现非贪婪或最小匹配。*/
content = richText.replace(reg, '');
// /* 去除 */
// content = content.replace(/ /ig, '')
// /* 去除空格 */
// content = content.replace(/\s/ig, '')
}
else {
content = richText;
}
return content;
}