Canvas 绘制荣誉证书等(下载、自定义)
摘要:代码 /** * Canvas图片生成器类(增强版) * 支持百分比坐标、元素宽度控制、CSS样式配置和高级文本布局 * 支持多个内容模块循环渲染,类似Vue插槽的默认配置机制 */ /** * Canvas图片生成器类(增强版) * 支持基于文本精确高度的自动定位 */ class CanvasImageGenerator { /** * 构造函数 * @param {Object} options 配置<!--autointro-->...
代码
/**
* Canvas图片生成器类(增强版)
* 支持百分比坐标、元素宽度控制、CSS样式配置和高级文本布局
* 支持多个内容模块循环渲染,类似Vue插槽的默认配置机制
*/
/**
* Canvas图片生成器类(增强版)
* 支持基于文本精确高度的自动定位
*/
class CanvasImageGenerator {
/**
* 构造函数
* @param {Object} options 配置选项
*/
constructor(options) {
// 默认配置
options = options || {};
this.config = {
width: 800,
height: 500,
backgroundColor: '#3498db',
backgroundPadding: 0 // 背景图片内边距
};
// 合并选项
if (options.width !== undefined) this.config.width = options.width;
if (options.height !== undefined) this.config.height = options.height;
if (options.backgroundColor !== undefined) this.config.backgroundColor = options.backgroundColor;
if (options.backgroundPadding !== undefined) this.config.backgroundPadding = options.backgroundPadding;
// 创建Canvas元素
this.canvas = document.createElement('canvas');
this.canvas.width = this.config.width;
this.canvas.height = this.config.height;
this.ctx = this.canvas.getContext('2d');
// 文字元素默认配置模板
this.elementTemplates = {
title: {
text: '结业证书',
fontFamily: 'Arial, "Microsoft YaHei", sans-serif',
fontSize: 32,
color: '#ff6b6b',
x: '10%',
y: '28%',
width: '80%',
bold: true,
italic: false,
shadow: false,
textAlign: 'center',
textBaseline: 'middle',
overflow: 'wrap',
lineHeight: 1.2,
padding: 0,
maxLines: 0,
whiteSpace: 'normal',
textIndent: 0,
letterSpacing: 0,
wordSpacing: '50px',
textAlignLast: 'start',
enabled: true
},
content: {
text: '请在此处填写证书内容...',
fontFamily: 'Arial, "SimSun", sans-serif',
fontSize: 18,
color: '#333',
x: '15%',
y: '33%',
width: '70%',
bold: false,
italic: false,
shadow: false,
textAlign: 'justify',
textBaseline: 'top',
overflow: 'wrap',
lineHeight: 1.5,
padding: 0,
maxLines: 0,
whiteSpace: 'normal',
textIndent: 36,
letterSpacing: 0,
wordSpacing: 0,
textAlignLast: 'start',
enabled: true,
autoPosition: true // 默认启用自动定位
},
date: {
text: '2023年1月1日',
fontFamily: 'Arial, "Microsoft YaHei", sans-serif',
fontSize: 14,
color: '#666',
x: '60%',
y: '80%',
width: '40%',
bold: false,
italic: false,
shadow: false,
textAlign: 'left',
textBaseline: 'middle',
overflow: 'ellipsis',
lineHeight: 1.2,
padding: 0,
maxLines: 1,
whiteSpace: 'nowrap',
textIndent: 0,
letterSpacing: 0,
wordSpacing: 0,
textAlignLast: 'start',
enabled: true
},
unit: {
text: '某某机构',
fontFamily: 'Arial, "Microsoft YaHei", sans-serif',
fontSize: 14,
color: '#666',
x: '60%',
y: '75%',
width: '40%',
bold: false,
italic: false,
shadow: false,
textAlign: 'left',
textBaseline: 'middle',
overflow: 'ellipsis',
lineHeight: 1.2,
padding: 0,
maxLines: 1,
whiteSpace: 'nowrap',
textIndent: 0,
letterSpacing: 0,
wordSpacing: 0,
textAlignLast: 'start',
enabled: true
},
certificateNo: {
text: 'NO.20230001',
fontFamily: 'Arial, "Microsoft YaHei", sans-serif',
fontSize: 14,
color: '#666',
x: '17%',
y: '80%',
width: '50%',
bold: false,
italic: false,
shadow: false,
textAlign: 'left',
textBaseline: 'middle',
overflow: 'ellipsis',
lineHeight: 1.2,
padding: 0,
maxLines: 1,
whiteSpace: 'nowrap',
textIndent: 0,
letterSpacing: 1,
wordSpacing: 0,
textAlignLast: 'start',
enabled: true
}
};
// 当前激活的元素配置
this.elements = {};
// 文本测量缓存
this._textWidthCache = new Map();
// 字体特性缓存
this._fontMetricsCache = new Map();
}
/**
* 生成图片(支持多个内容模块)
* @param {Object} params 生成参数
* @returns {Promise<string>} 图片DataURL
*/
async generate(params) {
try {
// 合并参数
params = params || {};
const background = params.background;
const title = params.title;
const content = params.content;
const date = params.date;
const unit = params.unit;
const certificateNo = params.certificateNo;
const contents = params.contents || [];
// 更新配置
if (params.width) this.config.width = params.width;
if (params.height) this.config.height = params.height;
if (params.backgroundColor) this.config.backgroundColor = params.backgroundColor;
if (params.backgroundPadding !== undefined) this.config.backgroundPadding = params.backgroundPadding;
// 更新Canvas尺寸
this.canvas.width = this.config.width;
this.canvas.height = this.config.height;
// 重置元素配置
this.elements = {};
// 处理单个元素配置
if (title) this.addElement('title', title);
if (content) this.addElement('content', content);
if (date) this.addElement('date', date);
if (unit) this.addElement('unit', unit);
if (certificateNo) this.addElement('certificateNo', certificateNo);
// 处理多个内容模块(核心改进:基于精确高度的自动定位)
if (contents && contents.length > 0) {
this.processMultipleContents(contents);
}
// 绘制
await this.drawBackground(background);
this.drawAllElements();
return this.canvas.toDataURL('image/png');
} catch (error) {
console.error('生成图片时出错:', error);
throw error;
}
}
/**
* 处理多个内容模块(基于内容高度的智能定位)
* @param {Array} contents 内容模块数组
*/
processMultipleContents(contents) {
// 计算起始Y位置(考虑顶部内容的高度)
let currentY = this.calculateInitialYPosition();
contents.forEach((contentConfig, index) => {
const elementKey = `content_${index}`;
// 合并默认配置和自定义配置
const mergedConfig = this.mergeWithTemplate('content', contentConfig);
// 仅对启用自动定位的模块进行位置计算
if (mergedConfig.autoPosition) {
// 设置当前内容模块的位置为当前Y坐标
mergedConfig.y = `${currentY}%`;
// 计算当前内容模块的精确高度
const contentHeight = this.calculateExactContentHeight(mergedConfig);
// 累加当前模块高度和间距作为下一个模块的起始位置
currentY += contentHeight + 3; // 3% 的间距
console.log(`内容模块${index + 1} - Y位置: ${mergedConfig.y}, 高度: ${contentHeight}%, 下一模块起始Y: ${currentY}%`);
} else {
// 不启用自动定位时,保持原有Y坐标,但仍需计算其高度用于后续模块
const contentHeight = this.calculateExactContentHeight(mergedConfig);
currentY = parseFloat(mergedConfig.y) + contentHeight + 3;
console.log(`内容模块${index + 1} (不自动定位) - Y位置: ${mergedConfig.y}, 高度: ${contentHeight}%, 下一模块起始Y: ${currentY}%`);
}
this.elements[elementKey] = mergedConfig;
});
}
/**
* 计算初始Y位置(考虑标题等顶部元素的高度)
* @returns {number} 初始Y位置百分比
*/
calculateInitialYPosition() {
// 如果有标题元素,从标题下方开始
if (this.elements.title && this.elements.title.enabled) {
const titleHeight = this.calculateExactContentHeight(this.elements.title);
const titleY = parseFloat(this.elements.title.y);
return titleY + titleHeight + 3; // 标题Y坐标 + 标题高度 + 3%间距
}
return 35; // 默认起始位置
}
/**
* 计算内容模块的精确高度(修复首行缩进和高度计算问题)
* @param {Object} config 内容配置
* @returns {number} 高度百分比
*/
calculateExactContentHeight(config) {
// 保存当前字体设置
const originalFont = this.ctx.font;
// 设置当前配置的字体
let fontStyle = '';
if (config.bold) fontStyle += 'bold ';
if (config.italic) fontStyle += 'italic ';
const font = `${fontStyle}${config.fontSize}px ${config.fontFamily}`;
this.ctx.font = font;
// 获取字体度量信息
const fontMetrics = this.getFontMetrics(font, config.fontSize);
const {
text,
lineHeight,
maxLines,
width,
padding,
textIndent,
whiteSpace,
overflow
} = config;
// 计算实际内容宽度(像素)
const contentWidth = this.parseValueToPixel(width, 'width') - (padding || 0) * 2;
// 关键修复:计算实际可用宽度时考虑首行缩进
let firstLineMaxWidth = contentWidth;
if (textIndent > 0) {
firstLineMaxWidth = contentWidth - textIndent;
}
// 分割文本行(使用优化后的分割方法)
let lines = [];
let isEllipsisOrClip = overflow === 'ellipsis' || overflow === 'clip';
if (whiteSpace === 'nowrap' || isEllipsisOrClip) {
// 不换行、省略号或裁剪模式 - 只处理一行
lines = [text];
} else {
// 按段落分割并使用严格换行逻辑
const paragraphs = text.split('\n');
paragraphs.forEach((paragraph, paraIndex) => {
if (paragraph.trim() === '') {
lines.push('');
return;
}
// 处理每个段落,第一个段落的第一行考虑首行缩进
let isFirstParaFirstLine = paraIndex === 0 && lines.length === 0;
let currentLine = '';
for (let i = 0; i < paragraph.length; i++) {
const char = paragraph[i];
const testLine = currentLine + char;
const currentMaxWidth = isFirstParaFirstLine ? firstLineMaxWidth : contentWidth;
const testWidth = this.measureTextWidth(testLine);
// 严格截断逻辑:只要超出宽度就立即换行
if (testWidth > currentMaxWidth) {
if (currentLine) {
lines.push(currentLine);
currentLine = char;
} else {
// 即使是单个字符也强制截断(避免极端情况)
lines.push(char);
currentLine = '';
}
// 只有第一段第一行需要首行缩进
isFirstParaFirstLine = false;
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
});
}
// 限制最大行数
let actualLines = lines;
if (maxLines > 0 && lines.length > maxLines) {
actualLines = lines.slice(0, maxLines);
// 最后一行添加省略号(如果需要)
if (config.overflow === 'ellipsis') {
const lastLine = actualLines[actualLines.length - 1];
let truncatedLine = lastLine;
const currentMaxWidth = actualLines.length === 1 && textIndent > 0 ? firstLineMaxWidth : contentWidth;
while (truncatedLine.length > 0 &&
this.measureTextWidth(truncatedLine + '...') > currentMaxWidth) {
truncatedLine = truncatedLine.slice(0, -1);
}
actualLines[actualLines.length - 1] = truncatedLine + '...';
}
}
// 确定最终显示的行数
let finalLineCount = actualLines.length;
if (whiteSpace === 'nowrap' || isEllipsisOrClip) {
finalLineCount = 1; // 强制单行高度
}
// 计算总高度(像素)
const lineHeightPx = config.fontSize * lineHeight;
const totalContentHeightPx = finalLineCount * lineHeightPx;
const totalHeightPx = totalContentHeightPx + (padding || 0) * 2;
// 恢复原始字体
this.ctx.font = originalFont;
// 转换为百分比(相对于画布高度)
const heightPercent = (totalHeightPx / this.config.height) * 100;
console.log(`计算高度 - 实际行数: ${actualLines.length}, 显示行数: ${finalLineCount}, 像素高度: ${totalHeightPx}, 百分比高度: ${heightPercent}%`);
return heightPercent;
}
/**
* 获取字体度量信息( ascent, descent, 行高 等)
* @param {string} font 字体字符串
* @param {number} fontSize 字体大小
* @returns {Object} 字体度量信息
*/
getFontMetrics(font, fontSize) {
const cacheKey = font;
// 检查缓存
if (this._fontMetricsCache.has(cacheKey)) {
return this._fontMetricsCache.get(cacheKey);
}
// 保存当前状态
const originalFont = this.ctx.font;
const originalTextBaseline = this.ctx.textBaseline;
// 设置测量环境
this.ctx.font = font;
this.ctx.textBaseline = 'top';
// 创建一个参考文本(使用大写和小写字母以及descender)
const referenceText = 'Hg';
const textMetrics = this.ctx.measureText(referenceText);
// 计算 ascent (基线到顶部的距离) 和 descent (基线到底部的距离)
const ascent = textMetrics.actualBoundingBoxAscent;
const descent = textMetrics.actualBoundingBoxDescent;
const lineGap = 0; // 简化计算
// 恢复原始状态
this.ctx.font = originalFont;
this.ctx.textBaseline = originalTextBaseline;
// 计算实际行高
const actualLineHeight = ascent + descent + lineGap;
// 缓存结果
const metrics = {
ascent,
descent,
lineGap,
actualLineHeight,
fontSize
};
this._fontMetricsCache.set(cacheKey, metrics);
return metrics;
}
/**
* 添加元素(合并默认配置和自定义配置)
* @param {string} elementName 元素名称
* @param {Object} customConfig 自定义配置
*/
addElement(elementName, customConfig) {
if (!customConfig || !customConfig.text) {
return;
}
const mergedConfig = this.mergeWithTemplate(elementName, customConfig);
this.elements[elementName] = mergedConfig;
}
/**
* 合并默认配置和自定义配置
* @param {string} elementName 元素名称
* @param {Object} customConfig 自定义配置
* @returns {Object} 合并后的配置
*/
mergeWithTemplate(elementName, customConfig) {
const template = this.elementTemplates[elementName];
if (!template) {
return Object.assign({}, customConfig);
}
const merged = Object.assign({}, template, customConfig);
merged.enabled = customConfig.enabled !== undefined ? customConfig.enabled : template.enabled;
merged.autoPosition = customConfig.autoPosition !== undefined ? customConfig.autoPosition : template.autoPosition;
return merged;
}
/**
* 将值转换为像素值(支持百分比)
* @param {string|number} value 值
* @param {string} dimension 维度('width' 或 'height')
* @returns {number} 像素值
*/
parseValueToPixel(value, dimension) {
dimension = dimension || 'width';
if (typeof value === 'string' && value.includes('%')) {
const percent = parseFloat(value) / 100;
if (dimension === 'width') {
return this.config.width * percent;
} else {
return this.config.height * percent;
}
}
return parseFloat(value) || 0;
}
/**
* 获取元素的实际像素坐标和尺寸
* @param {Object} element 元素配置
* @returns {Object} 包含位置和尺寸的对象
*/
getElementLayout(element) {
const x = this.parseValueToPixel(element.x, 'width');
const y = this.parseValueToPixel(element.y, 'height');
const width = this.parseValueToPixel(element.width, 'width');
const padding = element.padding || 0;
return {
x,
y,
width,
padding,
contentWidth: width - padding * 2,
startX: this.getTextStartX(x, width, element.textAlign, padding),
// 计算首行缩进后的起始X
firstLineStartX: element.textIndent ? this.getTextStartX(x, width, element.textAlign, padding) + element.textIndent : this.getTextStartX(x, width, element.textAlign, padding)
};
}
/**
* 根据对齐方式获取文本起始X坐标
* @param {number} x 元素X坐标
* @param {number} width 元素宽度
* @param {string} textAlign 文本对齐方式
* @param {number} padding 内边距
* @returns {number} 文本起始X坐标
*/
getTextStartX(x, width, textAlign, padding) {
padding = padding || 0;
switch (textAlign) {
case 'left':
return x + padding;
case 'right':
return x + width - padding;
case 'center':
default:
return x + width / 2;
}
}
/**
* 绘制背景
* @param {string|HTMLImageElement} background 背景图片
*/
async drawBackground(background) {
this.ctx.clearRect(0, 0, this.config.width, this.config.height);
if (background) {
try {
const image = await this.loadImage(background);
const padding = this.config.backgroundPadding || 0;
const availableWidth = this.config.width - padding * 2;
const availableHeight = this.config.height - padding * 2;
const scale = Math.min(
availableWidth / image.width,
availableHeight / image.height
);
const scaledWidth = image.width * scale;
const scaledHeight = image.height * scale;
const x = padding + (availableWidth - scaledWidth) / 2;
const y = padding + (availableHeight - scaledHeight) / 2;
this.ctx.drawImage(image, x, y, scaledWidth, scaledHeight);
} catch (error) {
console.warn('背景图片加载失败,使用背景颜色:', error);
this.drawColorBackground();
}
} else {
this.drawColorBackground();
}
}
/**
* 绘制颜色背景
*/
drawColorBackground() {
this.ctx.fillStyle = this.config.backgroundColor;
this.ctx.fillRect(0, 0, this.config.width, this.config.height);
}
/**
* 加载图片
* @param {string|HTMLImageElement} source 图片源
* @returns {Promise<HTMLImageElement>}
*/
loadImage(source) {
return new Promise((resolve, reject) => {
if (typeof source === 'string') {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = source;
} else if (source instanceof HTMLImageElement) {
if (source.complete) {
resolve(source);
} else {
source.onload = () => resolve(source);
source.onerror = reject;
}
} else {
reject(new Error('不支持的图片源类型'));
}
});
}
/**
* 绘制所有元素
*/
drawAllElements() {
Object.keys(this.elements).forEach(elementName => {
const element = this.elements[elementName];
if (element.enabled !== false && element.text) {
this.drawElement(element);
}
});
}
/**
* 绘制单个元素
* @param {Object} element 元素配置
*/
drawElement(element) {
if (!element.text) return;
// 获取布局信息
const layout = this.getElementLayout(element);
// 构建字体字符串
let fontStyle = '';
if (element.bold) fontStyle += 'bold ';
if (element.italic) fontStyle += 'italic ';
this.ctx.font = `${fontStyle}${element.fontSize}px ${element.fontFamily}`;
this.ctx.fillStyle = element.color;
this.ctx.textBaseline = element.textBaseline;
// 设置阴影
if (element.shadow) {
this.ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
this.ctx.shadowBlur = 5;
this.ctx.shadowOffsetX = 2;
this.ctx.shadowOffsetY = 2;
} else {
this.ctx.shadowColor = 'transparent';
this.ctx.shadowBlur = 0;
this.ctx.shadowOffsetX = 0;
this.ctx.shadowOffsetY = 0;
}
// 根据overflow策略绘制文本
switch (element.overflow) {
case 'ellipsis':
this.drawTextWithEllipsis(element, layout);
break;
case 'clip':
this.drawTextWithClip(element, layout);
break;
case 'wrap':
default:
this.drawTextWithWrap(element, layout);
break;
}
// 重置阴影
this.ctx.shadowColor = 'transparent';
this.ctx.shadowBlur = 0;
this.ctx.shadowOffsetX = 0;
this.ctx.shadowOffsetY = 0;
}
/**
* 测量文本宽度(缓存优化)
* @param {string} text 文本
* @returns {number} 文本宽度
*/
measureTextWidth(text) {
const cacheKey = `${this.ctx.font}|${text}`;
if (this._textWidthCache.has(cacheKey)) {
return this._textWidthCache.get(cacheKey);
}
const width = this.ctx.measureText(text).width;
this._textWidthCache.set(cacheKey, width);
return width;
}
/**
* 绘制自动换行文本
* @param {Object} element 元素配置
* @param {Object} layout 布局信息
*/
drawTextWithWrap(element, layout) {
const {
text,
fontSize,
lineHeight,
maxLines,
textBaseline,
textAlign,
textIndent,
textAlignLast,
whiteSpace
} = element;
const { contentWidth, y, padding, x } = layout;
let lines = [];
if (whiteSpace === 'nowrap') {
lines = text.split('\n');
} else {
lines = this.splitTextToLines(text, contentWidth, element);
}
// 限制最大行数
if (maxLines > 0 && lines.length > maxLines) {
lines = lines.slice(0, maxLines);
if (lines.length > 0) {
const lastLine = lines[lines.length - 1];
let truncatedLine = lastLine;
const currentMaxWidth = lines.length === 1 && textIndent > 0 ? contentWidth - textIndent : contentWidth;
while (truncatedLine.length > 0 &&
this.measureTextWidth(truncatedLine + '...') > currentMaxWidth) {
truncatedLine = truncatedLine.slice(0, -1);
}
lines[lines.length - 1] = truncatedLine + '...';
}
}
const lineHeightPx = fontSize * lineHeight;
let startY = y + padding;
// 根据文本基线调整起始位置
switch (textBaseline) {
case 'middle':
startY -= (lines.length * lineHeightPx) / 2;
break;
case 'bottom':
startY -= lines.length * lineHeightPx;
break;
case 'top':
default:
break;
}
// 绘制每一行
lines.forEach((line, index) => {
const isLastLine = index === lines.length - 1;
// 计算每行的起始X坐标
let lineStartX = x + padding;
let lineContentWidth = contentWidth;
// 首行缩进处理
if (index === 0 && textIndent > 0) {
if (textAlign === 'left' || textAlign === 'justify') {
lineStartX += textIndent;
lineContentWidth -= textIndent;
}
}
// 根据对齐方式绘制文本
if (textAlign === 'justify' && !isLastLine && line.trim()) {
this.drawJustifiedText(line, lineStartX, startY + index * lineHeightPx, lineContentWidth);
} else {
const currentAlign = (textAlign === 'justify' && isLastLine) ? textAlignLast : textAlign;
this.drawAlignedText(line, lineStartX, startY + index * lineHeightPx, lineContentWidth, currentAlign);
}
});
}
/**
* 绘制对齐文本
* @param {string} text 文本
* @param {number} x 起始X坐标
* @param {number} y Y坐标
* @param {number} maxWidth 最大宽度
* @param {string} align 对齐方式
*/
drawAlignedText(text, x, y, maxWidth, align) {
const originalAlign = this.ctx.textAlign;
switch (align) {
case 'left':
case 'start':
this.ctx.textAlign = 'left';
this.ctx.fillText(text, x, y);
break;
case 'right':
case 'end':
this.ctx.textAlign = 'right';
this.ctx.fillText(text, x + maxWidth, y);
break;
case 'center':
this.ctx.textAlign = 'center';
this.ctx.fillText(text, x + maxWidth / 2, y);
break;
default:
this.ctx.textAlign = 'left';
this.ctx.fillText(text, x, y);
}
this.ctx.textAlign = originalAlign;
}
/**
* 绘制两端对齐的文本
* @param {string} text 文本
* @param {number} x 起始X坐标
* @param {number} y Y坐标
* @param {number} maxWidth 最大宽度
*/
drawJustifiedText(text, x, y, maxWidth) {
const words = text.split(/(\s+)/).filter(word => word.trim() !== '');
if (words.length <= 1) {
this.ctx.textAlign = 'left';
this.ctx.fillText(text, x, y);
return;
}
// 计算非空格字符总宽度
let contentWidth = 0;
let spaceCount = 0;
for (const word of words) {
if (word.trim() === '') {
spaceCount++;
} else {
contentWidth += this.measureTextWidth(word);
}
}
// 计算需要分配的空格总宽度
const totalSpaceWidth = maxWidth - contentWidth;
const spaceWidth = spaceCount > 0 ? totalSpaceWidth / spaceCount : 0;
let currentX = x;
// 绘制每个部分
this.ctx.textAlign = 'left';
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (word.trim() === '') {
currentX += spaceWidth;
} else {
this.ctx.fillText(word, currentX, y);
currentX += this.measureTextWidth(word);
}
}
}
/**
* 绘制省略号文本(修复首行缩进问题)
* @param {Object} element 元素配置
* @param {Object} layout 布局信息
*/
drawTextWithEllipsis(element, layout) {
const { text, textIndent, textAlign } = element;
const { contentWidth, y, firstLineStartX } = layout;
// 计算实际可用宽度(考虑首行缩进)
const availableWidth = textIndent > 0 ? contentWidth - textIndent : contentWidth;
let displayText = text;
const ellipsis = '...';
const ellipsisWidth = this.measureTextWidth(ellipsis);
if (this.measureTextWidth(text) > availableWidth) {
let truncatedText = text;
while (truncatedText.length > 0 &&
this.measureTextWidth(truncatedText + ellipsis) > availableWidth) {
truncatedText = truncatedText.slice(0, -1);
}
displayText = truncatedText + ellipsis;
}
// 使用考虑首行缩进的起始X坐标
this.ctx.textAlign = textAlign === 'left' ? 'left' : this.ctx.textAlign;
this.ctx.fillText(displayText, firstLineStartX, y);
}
/**
* 绘制裁剪文本(修复首行缩进问题)
* @param {Object} element 元素配置
* @param {Object} layout 布局信息
*/
drawTextWithClip(element, layout) {
const { text, textIndent, fontSize } = element;
const { x, y, width, padding, firstLineStartX } = layout;
// 计算裁剪区域(考虑首行缩进)
const clipWidth = textIndent > 0 ? width - padding * 2 - textIndent : width - padding * 2;
const clipX = textIndent > 0 ? x + padding + textIndent : x + padding;
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(clipX, y - padding, clipWidth, fontSize * 2);
this.ctx.clip();
this.ctx.textAlign = 'left';
this.ctx.fillText(text, firstLineStartX, y);
this.ctx.restore();
}
/**
* 分割文本为适合宽度的行(严格按宽度截断,不考虑字符类型)
* @param {string} text 文本
* @param {number} maxWidth 最大宽度
* @param {Object} element 元素配置
* @returns {string[]} 分割后的行数组
*/
splitTextToLines(text, maxWidth, element) {
const paragraphs = text.split('\n');
const lines = [];
const textIndent = element.textIndent || 0;
const firstLineMaxWidth = maxWidth - textIndent;
const otherLinesMaxWidth = maxWidth;
// 处理每个段落
paragraphs.forEach((paragraph, paraIndex) => {
if (paragraph.trim() === '') {
lines.push('');
return;
}
// 第一个段落的第一行需要考虑首行缩进
// 修复:使用let声明变量,允许重新赋值
let isFirstParaFirstLine = paraIndex === 0 && lines.length === 0;
let currentLine = '';
for (let i = 0; i < paragraph.length; i++) {
const char = paragraph[i];
const testLine = currentLine + char;
const currentMaxWidth = isFirstParaFirstLine ? firstLineMaxWidth : otherLinesMaxWidth;
const testWidth = this.measureTextWidth(testLine);
// 严格判断:只要超出宽度就立即换行
if (testWidth > currentMaxWidth) {
// 如果当前行不为空,添加到结果并开始新行
if (currentLine) {
lines.push(currentLine);
currentLine = char;
} else {
// 即使是单个字符也强制截断(避免极端情况)
lines.push(char);
currentLine = '';
}
// 只有第一段第一行需要首行缩进
isFirstParaFirstLine = false;
} else {
currentLine = testLine;
}
}
// 添加剩余内容
if (currentLine) {
lines.push(currentLine);
}
});
return lines;
}
// 其他辅助方法
getElementLayoutInfo(elementName) {
if (this.elements[elementName]) {
return this.getElementLayout(this.elements[elementName]);
}
return null;
}
updateElementConfig(elementName, config) {
if (this.elements[elementName]) {
this.elements[elementName] = Object.assign({}, this.elements[elementName], config);
}
}
setElementTemplate(elementName, templateConfig) {
if (this.elementTemplates[elementName]) {
this.elementTemplates[elementName] = Object.assign({}, this.elementTemplates[elementName], templateConfig);
}
}
setElementStyle(elementName, style) {
if (this.elements[elementName]) {
this.elements[elementName] = Object.assign({}, this.elements[elementName], style);
}
}
setElementLayout(elementName, x, y, width) {
if (this.elements[elementName]) {
this.elements[elementName].x = x;
this.elements[elementName].y = y;
this.elements[elementName].width = width;
}
}
setBackgroundPadding(padding) {
this.config.backgroundPadding = padding;
}
getCanvas() {
return this.canvas;
}
getBlob() {
return new Promise((resolve) => {
this.canvas.toBlob(resolve, 'image/png');
});
}
async download(filename) {
if (!filename) {
filename = '结业证书_' + new Date().toLocaleString() + '.png';
}
const dataUrl = this.canvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = filename;
link.href = dataUrl;
link.click();
}
setSize(width, height) {
this.config.width = width;
this.config.height = height;
this.canvas.width = width;
this.canvas.height = height;
}
previewTextLayout(elementName) {
if (!this.elements[elementName]) return;
const element = this.elements[elementName];
const layout = this.getElementLayout(element);
const testLines = this.splitTextToLines(element.text, layout.contentWidth, element);
console.log(`元素 "${elementName}" 布局信息:`, {
配置: {
文本: element.text,
位置: `${element.x}, ${element.y}`,
宽度: element.width,
对齐: element.textAlign,
最后一行对齐: element.textAlignLast,
首行缩进: element.textIndent,
启用状态: element.enabled
},
实际像素: {
x: layout.x,
y: layout.y,
宽度: layout.width,
内容宽度: layout.contentWidth
},
文本测量: {
单行宽度: this.measureTextWidth(element.text),
分割行数: testLines.length,
各行内容: testLines
}
});
}
destroy() {
this.canvas.width = 0;
this.canvas.height = 0;
this.ctx = null;
this.canvas = null;
this._textWidthCache.clear();
this._fontMetricsCache.clear();
}
}
export default CanvasImageGenerator;
使用方法
基本使用
const generator = new CanvasImageGenerator();
// 生成图片
const imageDataUrl = await generator.generate({
background: 'path/to/background.jpg',
title: {
text: '多内容模块示例'
},
contents: [
{
text: '这是第一个内容模块,包含较短的文本。',
fontSize: 20
},
{
text: '这是第二个内容模块,包含较长的文本内容。这个模块会自动计算高度,并基于上一个模块的底部位置进行精确定位,避免内容重叠问题。',
fontSize: 18,
lineHeight: 1.6
},
{
text: '这是第三个内容模块,可以禁用自动定位,使用自定义位置。',
autoPosition: false,
x: '50%',
y: '70%'
}
],
date: {
text: '2023年10月15日'
},
unit: {
text: '报告单位:技术部'
}
});
// 在页面上显示
const img = document.createElement('img');
img.src = imageDataUrl;
document.body.appendChild(img);
高级使用
javascript
// 创建带初始配置的实例
const generator = new CanvasImageGenerator({
width: 1000,
height: 600,
backgroundColor: '#2c3e50'
});
// 生成图片
const imageDataUrl = await generator.generate({
background: 'https://example.com/background.jpg',
title: {
text: '多内容模块示例'
},
contents: [
{
text: '这是第一个内容模块,包含较短的文本。',
fontSize: 20
},
{
text: '这是第二个内容模块,包含较长的文本内容。这个模块会自动计算高度,并基于上一个模块的底部位置进行精确定位,避免内容重叠问题。',
fontSize: 18,
lineHeight: 1.6
},
{
text: '这是第三个内容模块,可以禁用自动定位,使用自定义位置。',
autoPosition: false,
x: '50%',
y: '70%'
}
],
date: {
text: '2023年12月31日',
fontSize: 24,
color: '#f1c40f',
x: 800,
y: 500
},
unit: {
text: '编制:市场部',
fontSize: 20,
color: '#f1c40f',
x: 800,
y: 540
}
});
// 直接下载
await generator.download('年度报告.png');
链式调用模式
// 可以创建链式调用的包装函数
async function createCustomImage() {
const generator = new CanvasImageGenerator();
// 设置尺寸
generator.setSize(1200, 800);
// 生成并下载
await generator.generate({
background: 'background.jpg',
title: { text: '自定义标题', fontSize: 72 },
content: { text: '详细内容描述' },
date: { text: '2024-01-01' },
unit: { text: '单位:自定义' }
});
await generator.download('custom-image.png');
// 清理资源
generator.destroy();
}
在React/Vue中使用
// React组件中使用
function ImageGeneratorComponent() {
const generateImage = async () => {
const generator = new CanvasImageGenerator();
try {
const imageUrl = await generator.generate({
background: '/assets/bg.jpg',
title: { text: 'React生成的图片' },
content: { text: '这是在React中生成的图片' },
date: { text: new Date().toLocaleDateString() },
unit: { text: '来源:React应用' }
});
// 更新state或直接使用
setGeneratedImage(imageUrl);
} catch (error) {
console.error('生成失败:', error);
}
};
return (
<div>
<button onClick={generateImage}>生成图片</button>
{/* 显示生成的图片 */}
</div>
);
}
本文链接:https://blog.smallhao.fun/?id=41 转载需授权!
Chen’Blog版权声明:以上内容作者已申请原创保护,未经允许不得转载,侵权必究!授权事宜、对本内容有异议或投诉,敬请联系网站管理员,我们将尽快回复您,谢谢合作!