首页前端工具函数Canvas 绘制荣誉证书等(下载、自定义)

Canvas 绘制荣誉证书等(下载、自定义)

分类前端工具函数时间2025-11-14 08:35:48发布RustStream浏览344
摘要:代码 /** * 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版权声明:以上内容作者已申请原创保护,未经允许不得转载,侵权必究!授权事宜、对本内容有异议或投诉,敬请联系网站管理员,我们将尽快回复您,谢谢合作!

vscode等IDE 一键生成 代码片段 免费API

游客 回复需填写必要信息
召唤伊斯特瓦尔