// textHelper.ts /** * 文字定位助手模块 * @module TextPosition */ /** * 创建文字定位助手实例 * @param {Object} options - 配置选项 * @param {Function} options.setData - 设置数据的方法(用于更新响应式数据) * @returns {Object} 包含renderText方法的对象 */ export function createTextPosition({ setData }) { if (!setData) { throw new Error('setData is required'); } // 默认样式配置 const defaultStyle = { fontSize: 16, // 字体大小(px) fontFamily: 'Arial', // 字体 color: '#000000', // 文字颜色 textAlign: 'left', // 文本对齐方式: left, center, right fontWeight: 'normal', // 字体粗细: normal, bold lineHeight: 1.5, // 行高 opacity: 1, // 透明度 backgroundColor: 'transparent', // 背景颜色 padding: 0, // 内边距(px) borderRadius: 0, // 边框圆角(px) }; /** * 渲染文字到指定位置 * @param {Object} options - 文字配置 * @param {string} options.text - 要显示的文字 * @param {number} options.x - 文字左上角X坐标(基于原始容器尺寸) * @param {number} options.y - 文字左上角Y坐标(基于原始容器尺寸) * @param {number} [options.width] - 文字容器宽度(基于原始容器尺寸) * @param {number} [options.height] - 文字容器高度(基于原始容器尺寸) * @param {Object} [options.style] - 文字样式配置 * @param {string} [options.bindKey] - 绑定到页面数据的键名 */ function roundText({ shapeImage, x, y, width, height, radius, style = {}, bindKey = 'roundTextConfig', text = '', isFront = true, cachedImageInfo = null, onImageInfoCached = null }) { // 合并默认样式和用户自定义样式 const mergedStyle = { ...defaultStyle, ...style }; // 获取设备信息,计算缩放比例 const screenWidth = 320; // 计算宽度和高度的缩放比例 const scale = 0.6; // 计算字符位置的函数 const calculateTextPosition = (imageInfo) => { // 计算图片宽高比 const imageRatio = imageInfo.width / imageInfo.height; const containerRatio = 1; // 容器是正方形,宽高比为1 let ratio; // 根据图片和容器的比例关系决定如何缩放 if (imageRatio > containerRatio) { // 图片更宽,以宽度为基准缩放 ratio = screenWidth / imageInfo.width; } else { // 图片更高或等比,以高度为基准缩放 ratio = screenWidth / imageInfo.height; } // 计算缩放后的参数 const scaledX = x * ratio * scale; const scaledY = y * ratio * scale; const scaledWidth = width * ratio * scale; const scaledHeight = height * ratio * scale; const scaledRadius = radius * ratio * scale; const scaledFontSize = mergedStyle.fontSize * ratio * scale; // 计算圆心位置(基于容器的中心点) const centerX = scaledX + scaledWidth / 2; const centerY = scaledY + scaledHeight / 2; // 计算每个字符的位置信息 // 根据正面/背面使用不同的计算方法,互不影响 const charList = isFront ? calculateFrontRoundTextChars({ text: text, centerX: scaledWidth / 2, // 相对于容器的中心 centerY: scaledHeight / 2, radius: scaledRadius, fontSize: scaledFontSize, fontWeight: mergedStyle.fontWeight || 'normal', color: mergedStyle.color || '#000000' }) : calculateBackRoundTextChars({ text: text, centerX: scaledWidth / 2, // 相对于容器的中心 centerY: scaledHeight / 2, radius: scaledRadius, fontSize: scaledFontSize, fontWeight: mergedStyle.fontWeight || 'normal', color: mergedStyle.color || '#000000' }); // 构建圆形文字配置对象 const roundTextConfig = { x: scaledX, y: scaledY, width: scaledWidth, height: scaledHeight, centerX: centerX, centerY: centerY, radius: scaledRadius, text: text, charList: charList, // 字符位置列表 style: { ...mergedStyle, fontSize: scaledFontSize, } }; console.log('roundTextConfig===>', roundTextConfig); // 更新页面数据 setData({ [bindKey]: roundTextConfig }); // 返回配置对象,以便后续使用 return roundTextConfig; }; // 如果已有缓存的图片信息,直接使用 if (cachedImageInfo && cachedImageInfo.src === shapeImage) { calculateTextPosition(cachedImageInfo); return; } // 否则获取图片信息 const img = new Image(); img.onload = () => { const res = { width: img.width, height: img.height, path: shapeImage, src: shapeImage }; // 如果提供了缓存回调,调用它 if (typeof onImageInfoCached === 'function') { onImageInfoCached(res); } calculateTextPosition(res); }; img.onerror = (err) => { console.error('Failed to get image info:', err); }; img.src = shapeImage; } /** * 计算正面圆形文字中每个字符的位置和样式 * @param {Object} options - 配置选项 * @param {string} options.text - 文字内容 * @param {number} options.centerX - 圆心X坐标(相对于容器) * @param {number} options.centerY - 圆心Y坐标(相对于容器) * @param {number} options.radius - 半径 * @param {number} options.fontSize - 字体大小 * @param {string} options.fontWeight - 字体粗细 * @param {string} options.color - 文字颜色 * @returns {Array} 字符位置信息数组 */ function calculateFrontRoundTextChars({ text, centerX, centerY, radius, fontSize, fontWeight, color }) { if (!text) { return []; } // 计算字符宽度 // 使用更精确的估算方法 const charWidths = []; let totalWidth = 0; for (let i = 0; i < text.length; i++) { const char = text[i]; let charWidth; // 判断字符类型,使用不同的宽度估算 if (/[\u4e00-\u9fa5]/.test(char)) { // 中文字符:宽度约为 fontSize charWidth = fontSize; } else if (/[0-9]/.test(char)) { // 数字:宽度约为 fontSize * 0.6 charWidth = fontSize * 0.6; } else if (/[a-zA-Z]/.test(char)) { // 英文字母:宽度约为 fontSize * 0.6(大写)或 0.5(小写) charWidth = /[A-Z]/.test(char) ? fontSize * 0.6 : fontSize * 0.45; } else if (/[\u3000-\u303f\uff00-\uffef]/.test(char)) { // 全角符号:宽度约为 fontSize charWidth = fontSize; } else { // 其他字符(半角符号等):宽度约为 fontSize * 0.4 charWidth = fontSize * 0.4; } // 如果字体是粗体,稍微增加宽度 if (fontWeight === 'bold' || fontWeight === '700') { charWidth *= 1.1; } charWidths.push(charWidth); totalWidth += charWidth; } // 为文字之间添加2px间距(除了最后一个字符) const spacing = 0; const totalSpacing = (text.length > 1) ? (text.length - 1) * spacing : 0; const totalWidthWithSpacing = totalWidth + totalSpacing; // 计算文字总弧长和每个字符的角度(正面使用原始半径) const circumference = 2 * Math.PI * radius; const arcLength = totalWidthWithSpacing; const totalAngle = (arcLength / circumference) * 2 * Math.PI; // 计算起始角度(使文字居中于底部) // 从底部中心开始,顺时针排列,起始角度 = 底部中心 + 总角度的一半 const startAngle = Math.PI / 2 + totalAngle / 2; // 创建并定位每个字符(逆时针排列,所以角度递减) let currentAngle = startAngle; const charList = []; for (let i = 0; i < text.length; i++) { const char = text[i]; const charWidth = charWidths[i]; // 计算字符角度增量(包括字符宽度和间距) // 最后一个字符不需要添加后面的间距 const spacingAfter = (i < text.length - 1) ? spacing : 0; const charAngleIncrement = ((charWidth + spacingAfter) / circumference) * 2 * Math.PI; // 计算字符角度位置(逆时针排列,所以角度递减) // 字符中心位置 = 当前角度 - 字符宽度的一半对应的角度 const charWidthAngle = (charWidth / circumference) * 2 * Math.PI; const charAngle = currentAngle - charWidthAngle / 2; // 计算字符位置(正面使用原始半径) const x = centerX + radius * Math.cos(charAngle); const y = centerY + radius * Math.sin(charAngle) - fontSize / 3; // 计算旋转角度(正面:文字底部向外,顶部朝向圆心) const rotateAngle = charAngle + Math.PI * 1.5; // 转换为度数(用于CSS transform) const rotateDeg = (rotateAngle * 180) / Math.PI; charList.push({ char: char, x: x, y: y, rotate: rotateAngle, rotateDeg: rotateDeg, // 度数版本 fontSize: fontSize, fontWeight: fontWeight, color: color }); // 更新当前角度(逆时针排列,所以角度递减) currentAngle -= charAngleIncrement; } return charList; } /** * 计算背面圆形文字中每个字符的位置和样式 * @param {Object} options - 配置选项 * @param {string} options.text - 文字内容 * @param {number} options.centerX - 圆心X坐标(相对于容器) * @param {number} options.centerY - 圆心Y坐标(相对于容器) * @param {number} options.radius - 半径 * @param {number} options.fontSize - 字体大小 * @param {string} options.fontWeight - 字体粗细 * @param {string} options.color - 文字颜色 * @returns {Array} 字符位置信息数组 */ function calculateBackRoundTextChars({ text, centerX, centerY, radius, fontSize, fontWeight, color }) { if (!text) { return []; } // 计算字符宽度 // 使用更精确的估算方法 const charWidths = []; let totalWidth = 0; for (let i = 0; i < text.length; i++) { const char = text[i]; let charWidth; // 判断字符类型,使用不同的宽度估算 if (/[\u4e00-\u9fa5]/.test(char)) { // 中文字符:宽度约为 fontSize charWidth = fontSize; } else if (/[0-9]/.test(char)) { // 数字:宽度约为 fontSize * 0.6 charWidth = fontSize * 0.6; } else if (/[a-zA-Z]/.test(char)) { // 英文字母:宽度约为 fontSize * 0.6(大写)或 0.5(小写) charWidth = /[A-Z]/.test(char) ? fontSize * 0.65 : fontSize * 0.5; } else if (/[\u3000-\u303f\uff00-\uffef]/.test(char)) { // 全角符号:宽度约为 fontSize charWidth = fontSize; } else { // 其他字符(半角符号等):宽度约为 fontSize * 0.4 charWidth = fontSize * 0.4; } // 如果字体是粗体,稍微增加宽度 if (fontWeight === 'bold' || fontWeight === '700') { charWidth *= 1.1; } charWidths.push(charWidth); totalWidth += charWidth; } // 为文字之间添加2px间距(除了最后一个字符) const spacing = 1; const totalSpacing = (text.length > 1) ? (text.length - 1) * spacing : 0; const totalWidthWithSpacing = totalWidth + totalSpacing; // 计算文字总弧长和每个字符的角度 // 背面:文字到圆心的距离 = 半径 + 文字高度的一半 const adjustedRadius = radius + fontSize / 2; const circumference = 2 * Math.PI * adjustedRadius; const arcLength = totalWidthWithSpacing; const totalAngle = (arcLength / circumference) * 2 * Math.PI; // 计算起始角度(使文字居中于底部) // 从底部中心开始,顺时针排列,起始角度 = 底部中心 - 总角度的一半 const startAngle = Math.PI / 2 - totalAngle / 2; // 创建并定位每个字符(顺时针排列,所以角度递增) let currentAngle = startAngle; const charList = []; for (let i = 0; i < text.length; i++) { const char = text[i]; const charWidth = charWidths[i]; // 计算字符角度增量(包括字符宽度和间距) // 最后一个字符不需要添加后面的间距 const spacingAfter = (i < text.length - 1) ? spacing : 0; const charAngleIncrement = ((charWidth + spacingAfter) / circumference) * 2 * Math.PI; // 计算字符角度位置(顺时针排列,所以角度递增) // 字符中心位置 = 当前角度 + 字符宽度的一半对应的角度 const charWidthAngle = (charWidth / circumference) * 2 * Math.PI; const charAngle = currentAngle + charWidthAngle / 2; // 计算字符位置(背面:文字到圆心的距离 = 半径 + 文字高度的一半) const x = centerX + adjustedRadius * Math.cos(charAngle); const y = centerY + adjustedRadius * Math.sin(charAngle); // 计算旋转角度(背面:文字底部朝向圆心,顶部向外) const rotateAngle = charAngle + Math.PI * 0.5; // 转换为度数(用于CSS transform) const rotateDeg = (rotateAngle * 180) / Math.PI; charList.push({ char: char, x: x, y: y, rotate: rotateAngle, rotateDeg: rotateDeg, // 度数版本 fontSize: fontSize, fontWeight: fontWeight, color: color }); // 更新当前角度(顺时针排列,所以角度递增) currentAngle += charAngleIncrement; } return charList; } /** * 初始化圆形文字 Canvas * @param {string} canvasId - Canvas 的 ID 或选择器 * @param {Object} config - 配置对象,包含 width 和 height * @returns {Promise} 返回包含 canvas 和 ctx 的对象 */ function initRoundTextCanvas(canvasId, config) { return new Promise((resolve) => { const tryInit = () => { // 如果是选择器字符串,去掉 # 或 . 前缀 const selector = canvasId.startsWith('#') ? canvasId.slice(1) : canvasId.startsWith('.') ? canvasId.slice(1) : canvasId; // 尝试通过 ID 查找 let canvasElement: HTMLCanvasElement | null = document.getElementById(selector) as HTMLCanvasElement | null; // 如果找不到,尝试通过类名查找(取第一个) if (!canvasElement && canvasId.startsWith('.')) { const elements = document.getElementsByClassName(selector); if (elements.length > 0) { canvasElement = elements[0] as HTMLCanvasElement; } } // 如果还是找不到,尝试直接使用 canvasId 作为选择器 if (!canvasElement) { canvasElement = document.querySelector(canvasId) as HTMLCanvasElement | null; } if (canvasElement && canvasElement instanceof HTMLCanvasElement) { const canvas = canvasElement; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('无法获取 Canvas 2D 上下文'); } const dpr = window.devicePixelRatio || 1; // 使用配置中的尺寸,如果没有则使用 Canvas 的实际尺寸或默认值 const width = (config && config.width) || canvas.offsetWidth || 700; const height = (config && config.height) || canvas.offsetHeight || 700; canvas.width = width * dpr; canvas.height = height * dpr; ctx.scale(dpr, dpr); resolve({ canvas, ctx }); } else { // 如果 Canvas 还未渲染,延迟重试 setTimeout(() => { tryInit(); }, 100); } }; tryInit(); }); } /** * 在 Canvas 上绘制圆形文字 * @param {Object} options - 绘制选项 * @param {Object} options.ctx - Canvas 2D 上下文 * @param {Object} options.config - 圆形文字配置对象 * @param {Function} options.setData - 设置数据的方法 * @param {HTMLCanvasElement} options.canvas - Canvas 元素 * @param {string} options.imageKey - 图片数据键名,用于存储生成的图片路径 * @returns {Promise} 返回生成的图片路径(Data URL) */ function drawRoundTextOnCanvas({ ctx, config, setData, canvas, imageKey = 'roundTextImage' }) { return new Promise((resolve, reject) => { if (!ctx || !config) { console.log('drawRoundTextOnCanvas: ctx 或 config 不存在', { ctx: !!ctx, config: !!config }); if (setData) { setData({ [imageKey]: '' }); } resolve(''); return; } const text = config.text; if (!text) { console.log('drawRoundTextOnCanvas: 文字为空'); // 如果没有文字,清空图片 if (setData) { setData({ [imageKey]: '' }); } resolve(''); return; } console.log('drawRoundTextOnCanvas: 开始绘制文字', { text, config }); // 使用配置中的宽高 const canvasWidth = config.width || 700; const canvasHeight = config.height || 700; // 圆心位置:根据需求,圆心应该在容器的中心 // 由于容器就是 Canvas,所以圆心就是 Canvas 的中心 const centerX = canvasWidth / 2; const centerY = canvasHeight / 2; const radius = config.radius; const fontSize = config.style.fontSize; const color = config.style.color || '#000000'; const fontWeight = config.style.fontWeight || 'normal'; // 清空画布(注意:Canvas 的实际尺寸已经乘以了 dpr,但绘制时使用的是逻辑尺寸) ctx.clearRect(0, 0, canvasWidth, canvasHeight); // 设置字体样式 ctx.font = `${fontWeight} ${fontSize}px sans-serif`; ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // 使用 middle 基线,文字中心对齐,更适合圆形排列 // 计算每个字符的角度 const circumference = 2 * Math.PI * radius; const charWidths = []; let totalWidth = 0; // 测量每个字符的宽度 for (let i = 0; i < text.length; i++) { const char = text[i]; const metrics = ctx.measureText(char); const width = metrics.width; charWidths.push(width); totalWidth += width; } // 计算文字总弧长和每个字符的角度 const arcLength = totalWidth; const totalAngle = (arcLength / circumference) * 2 * Math.PI; // 计算起始角度(使文字居中于底部) // 从底部中心开始,顺时针排列,起始角度 = 底部中心 + 总角度的一半 const startAngle = Math.PI / 2 + totalAngle / 2; // 创建并定位每个字符(逆时针排列,所以角度递减) let currentAngle = startAngle; for (let i = 0; i < text.length; i++) { const char = text[i]; const charWidth = charWidths[i]; // 计算字符角度增量 const charAngleIncrement = (charWidth / circumference) * 2 * Math.PI; // 计算字符角度位置(逆时针排列,所以角度递减) const charAngle = currentAngle - charAngleIncrement / 2; // 计算字符位置 const x = centerX + radius * Math.cos(charAngle); const y = centerY + radius * Math.sin(charAngle); // 保存上下文 ctx.save(); // 移动到字符位置 ctx.translate(x, y); // 旋转文字,使其沿着圆形路径排列 // 文字底部向外,顶部朝向圆心,所以旋转角度为字符角度 + π * 1.5 ctx.rotate(charAngle + Math.PI * 1.5); // 绘制文字(使用 0, 0 作为中心点,因为已经 translate 到字符位置) ctx.fillText(char, 0, 0); // 恢复上下文 ctx.restore(); // 更新当前角度(逆时针排列,所以角度递减) currentAngle -= charAngleIncrement; } // 将 Canvas 转换为图片(Data URL) try { const dataURL = canvas.toDataURL('image/png'); if (setData) { setData({ [imageKey]: dataURL }); } resolve(dataURL); } catch (err) { console.error('Canvas 转图片失败:', err); reject(err); } }); } return { roundText, initRoundTextCanvas, drawRoundTextOnCanvas, calculateFrontRoundTextChars, calculateBackRoundTextChars }; }