From 758a67cec748d65577e6f5d476222f6636ba08f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=91=9E?= Date: Mon, 8 Dec 2025 00:03:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=88=86=E4=BA=ABcanvas?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E5=8F=8A=E5=8A=A0=E7=B2=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ShareCardCanvas/index.tsx | 348 ++++++++++++----------- src/utils/share.ts | 225 ++++----------- 2 files changed, 244 insertions(+), 329 deletions(-) diff --git a/src/components/ShareCardCanvas/index.tsx b/src/components/ShareCardCanvas/index.tsx index 20a35b6..da83484 100644 --- a/src/components/ShareCardCanvas/index.tsx +++ b/src/components/ShareCardCanvas/index.tsx @@ -34,6 +34,9 @@ const ShareCardCanvas: React.FC = ({ }) => { const [tempImagePath, setTempImagePath] = useState('') // 存储Canvas生成的图片路径 const [isDrawing, setIsDrawing] = useState(false) // 防止重复绘制 + const [canvasNode, setCanvasNode] = useState(null) // 2D Canvas 节点 + const [ctx2d, setCtx2d] = useState(null) // 2D 上下文 + const [is2dCtx, setIs2dCtx] = useState(false) // 是否为 2D 上下文 // 设计稿尺寸 const designWidth = 500 @@ -54,87 +57,28 @@ const ShareCardCanvas: React.FC = ({ const canvasWidth = designWidth * scale const canvasHeight = designHeight * scale - // 绘制加粗文字(单行) - const drawBoldText = (ctx: any, text: string, x: number, y: number, fontSize: number, color: string, fontFamily?: string) => { - // 设置字体样式 - if (fontFamily) { - try { - // 尝试使用setFont方法(如果支持) - ctx.setFont(`${fontSize}px ${fontFamily}`) - } catch (error) { - // 如果不支持setFont,回退到setFontSize - ctx.setFontSize(fontSize) - } - } else { - ctx.setFontSize(fontSize) - } + // 2D Canvas 字体设置 + const setFont2D = (ctx: any, fontSize: number, family?: string, weight?: string) => { + const fam = family || 'Noto Sans SC' + const wt = weight || '500' + ctx.font = `${wt} ${fontSize}px "${fam}"` + } - ctx.setFillStyle(color) - ctx.setTextAlign('left') - ctx.setTextBaseline('top') + // 绘制加粗文字(单行) + const drawBoldText = (ctx: any, text: string, x: number, y: number, fontSize: number, color: string, fontFamily: string = 'Noto Sans SC', fontWeight: string = '500') => { + // 设置字体样式 + setFont2D(ctx, fontSize, fontFamily, fontWeight) + ctx.fillStyle = color + ctx.textAlign = 'left' + ctx.textBaseline = 'top' // 绘制加粗效果:多次绘制并偏移 ctx.fillText(text, x, y) - ctx.fillText(text, x + 1, y) - ctx.fillText(text, x, y + 1) - ctx.fillText(text, x + 1, y + 1) - } - - // 绘制文字(支持自动换行)- 微信小程序版本 - const drawText = (ctx: any, text: string, x: number, y: number, maxWidth: number, fontSize: number, color: string, bold: boolean = false, fontFamily?: string) => { - // 设置字体样式 - if (fontFamily) { - try { - // 尝试使用setFont方法(如果支持) - ctx.setFont(`${fontSize}px ${fontFamily}`) - } catch (error) { - // 如果不支持setFont,回退到setFontSize - ctx.setFontSize(fontSize) - } - } else { - ctx.setFontSize(fontSize) - } - - ctx.setFillStyle(color) - ctx.setTextAlign('left') - ctx.setTextBaseline('top') - - const words = text.split('') - let line = '' - let lineY = y - - for (let i = 0; i < words.length; i++) { - const testLine = line + words[i] - // 微信小程序中measureText返回的是对象,需要访问width属性 - const metrics = ctx.measureText(testLine) - const testWidth = metrics.width - - if (testWidth > maxWidth && i > 0) { - if (bold) { - // 绘制加粗效果:多次绘制并偏移 - ctx.fillText(line, x, lineY) - ctx.fillText(line, x + 1, lineY) - ctx.fillText(line, x, lineY + 1) - ctx.fillText(line, x + 1, lineY + 1) - } else { - ctx.fillText(line, x, lineY) - } - line = words[i] - lineY += fontSize * 1.2 - } else { - line = testLine - } - } - - if (bold) { - // 绘制加粗效果:多次绘制并偏移 - ctx.fillText(line, x, lineY) - ctx.fillText(line, x + 1, lineY) - ctx.fillText(line, x, lineY + 1) - ctx.fillText(line, x + 1, lineY + 1) - } else { - ctx.fillText(line, x, lineY) - } + // if (isBold) { + // ctx.fillText(text, x + 1, y) + // ctx.fillText(text, x, y + 1) + // ctx.fillText(text, x + 1, y + 1) + // } } // 绘制圆角矩形函数 @@ -156,22 +100,22 @@ const ShareCardCanvas: React.FC = ({ // 绘制标签函数(通用) const drawLabel = (ctx: any, x: number, y: number, width: number, height: number, radius: number, text: string, fontSize: number, textColor: string = '#000000', bgColor: string = '#FFFFFF', borderColor: string = '#E0E0E0') => { // 绘制背景 - ctx.setFillStyle(bgColor) + ctx.fillStyle = bgColor drawRoundedRect(ctx, x, y, width, height, radius) // 绘制边框 - ctx.setStrokeStyle(borderColor) - ctx.setLineWidth(1 * dpr) + ctx.strokeStyle = borderColor + ctx.lineWidth = 1 * dpr ctx.stroke() // 绘制文字 const textCenterX = x + width / 2 const textCenterY = y + height / 2 - ctx.setFillStyle(textColor) - ctx.setTextAlign('center') - ctx.setTextBaseline('middle') - ctx.setFontSize(fontSize) + ctx.fillStyle = textColor + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + setFont2D(ctx, fontSize) ctx.save() ctx.translate(textCenterX, textCenterY) @@ -180,28 +124,35 @@ const ShareCardCanvas: React.FC = ({ } // 加载图片 - 微信小程序版本 - const loadImage = (src: string): Promise => { + const loadImage = (src: string, canvas?: any): Promise => { return new Promise((resolve, reject) => { - Taro.getImageInfo({ - src: src, - success: (res) => resolve(res.path), - fail: reject - }) + if (!canvas || typeof canvas.createImage !== 'function') { + reject(new Error('2D canvas is required to load images')) + return + } + try { + const img = canvas.createImage() + img.onload = () => resolve(img) + img.onerror = (e: any) => reject(e) + img.src = src + } catch (e) { + reject(e) + } }) } // 绘制SVG路径到Canvas const drawSVGPathToCanvas = (ctx: any) => { // 设置绘制样式 - ctx.setStrokeStyle('#48D800'); - ctx.setLineWidth(scale * 3 * dpr); - ctx.setLineCap('round'); - ctx.setLineJoin('round'); + ctx.strokeStyle = '#00E5AD'; + ctx.lineWidth = scale * 3 * dpr; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; ctx.save(); // 移动到指定位置并缩放 - ctx.translate(scale * 210 * dpr, scale * 90 * dpr); + ctx.translate(scale * 200 * dpr, scale * 90 * dpr); const scaleValue = 0.8 ctx.scale(scaleValue, scaleValue); @@ -226,7 +177,7 @@ const ShareCardCanvas: React.FC = ({ } // 绘制右上角场地图片 - const drawVenueImages = async (ctx: any, venueImageConfig: any) => { + const drawVenueImages = async (ctx: any, venueImageConfig: any, canvas?: any) => { // 如果只有一张图 const playerImgX = venueImageConfig.venueImgX const playerImgY = venueImageConfig.venueImgY @@ -237,7 +188,7 @@ const ShareCardCanvas: React.FC = ({ const venueImage = venueImageConfig.venueImage try { - const playerImgPath = await loadImage(venueImage) + const playerImgPath = await loadImage(venueImage, canvas) ctx.save() // 移动到旋转中心点 @@ -249,7 +200,7 @@ const ShareCardCanvas: React.FC = ({ ctx.rotate((rotation * Math.PI) / 180) // 1. 先绘制白色圆角矩形背景 - ctx.setFillStyle('#FFFFFF') + ctx.fillStyle = '#FFFFFF' ctx.beginPath() // 使用更精确的圆角矩形绘制 @@ -338,7 +289,7 @@ const ShareCardCanvas: React.FC = ({ ctx.rotate((rotation * Math.PI) / 180) // 绘制白色圆角矩形背景 - ctx.setFillStyle('#FFFFFF') + ctx.fillStyle = '#FFFFFF' ctx.beginPath() const rectX = -playerImgSize / 2 @@ -380,7 +331,7 @@ const ShareCardCanvas: React.FC = ({ const imgY = -playerImgSize / 2 + padding const imgSize = playerImgSize - padding * 2 - ctx.setFillStyle('#E0E0E0') + ctx.fillStyle = '#E0E0E0' ctx.beginPath() const imgRadius = borderRadius - padding @@ -448,15 +399,15 @@ const ShareCardCanvas: React.FC = ({ // 绘制背景 - 渐变色 已完成 const gradient = ctx.createLinearGradient(0, 0, 0, canvasHeightPx) - gradient.addColorStop(0, '#D8FFE5') - gradient.addColorStop(1, '#F9FFFB') - ctx.setFillStyle(gradient) + gradient.addColorStop(0, '#BFFFEF') + gradient.addColorStop(1, '#F2FFFC') + ctx.fillStyle = gradient ctx.fillRect(0, 0, canvasWidthPx, canvasHeightPx) console.log('背景绘制完成') // 绘制背景条纹 已完成 - ctx.setStrokeStyle('rgba(0, 0, 0, 0.03)') - ctx.setLineWidth(2) + ctx.strokeStyle = 'rgba(0, 0, 0, 0.03)' + ctx.lineWidth = 2 for (let i = 0; i < canvasWidthPx; i += 4) { ctx.beginPath() ctx.moveTo(i, 0) @@ -470,7 +421,7 @@ const ShareCardCanvas: React.FC = ({ const avatarY = scale * 35 * dpr // 距离顶部35px try { - const avatarPath = await loadImage(data.userAvatar) + const avatarPath = await loadImage(data.userAvatar, canvasNode) // 微信小程序中绘制圆形头像需要特殊处理 ctx.save() ctx.beginPath() @@ -480,7 +431,7 @@ const ShareCardCanvas: React.FC = ({ ctx.restore() } catch (error) { // 如果头像加载失败,绘制默认头像 - ctx.setFillStyle('#CCCCCC') + ctx.fillStyle = '#CCCCCC' ctx.beginPath() ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, 2 * Math.PI) ctx.fill() @@ -490,7 +441,8 @@ const ShareCardCanvas: React.FC = ({ const nicknameX = avatarX + avatarSize + 8 * dpr // 距离头像8px const nicknameY = avatarY + (avatarSize - 18 * dpr) / 2 // 与头像水平居中对齐 const nicknameFontSize = scale * 18 * dpr - drawText(ctx, data.userNickname, nicknameX, nicknameY, 200 * dpr, nicknameFontSize, '#000000', true, '"Noto Sans SC"') + // drawText(ctx, data.userNickname, nicknameX, nicknameY, 200 * dpr, nicknameFontSize, '#000000', true, 'Noto Sans SC') + drawBoldText(ctx, data.userNickname, nicknameX, nicknameY, nicknameFontSize, '#000000', 'Noto Sans SC', '900') // 绘制"邀你加入球局"文案 const inviteX = scale * 35 * dpr // 距离画布左侧35px @@ -498,12 +450,12 @@ const ShareCardCanvas: React.FC = ({ const inviteFontSize = scale * 44 * dpr // 绘制"邀你加入" - drawBoldText(ctx, '邀你加入', inviteX, inviteY, inviteFontSize, '#000000', "Noto Sans SC") + drawBoldText(ctx, '邀你加入', inviteX, inviteY, inviteFontSize, '#000000', 'Noto Sans SC', '900') // 绘制"球局"特殊样式 - const qiuJuX = inviteX + ctx.measureText('邀你加入').width + 5 * dpr + const qiuJuX = inviteX + ctx.measureText('邀你加入').width + 4 * dpr const qiuJuFontSize = scale * 44 * dpr - drawBoldText(ctx, '球局', qiuJuX, inviteY, qiuJuFontSize, '#48D800', '"Noto Sans SC"') + drawBoldText(ctx, '球局', qiuJuX, inviteY, qiuJuFontSize, '#00E5AD', 'Noto Sans SC', '900') // 测试绘制网络图片 drawSVGPathToCanvas(ctx) @@ -528,16 +480,16 @@ const ShareCardCanvas: React.FC = ({ venueImgY: scale * 35 * dpr, rotation: scale * -10, // 旋转-10度 } - await drawVenueImages(ctx, venueBackConfig) + await drawVenueImages(ctx, venueBackConfig, canvasNode) // 前面的图 const venueFrontConfig = { ...venueBaseConfig, venueImage: data.venueImages?.[0], rotation: scale * 8, // 旋转-8度 } - await drawVenueImages(ctx, venueFrontConfig) + await drawVenueImages(ctx, venueFrontConfig, canvasNode) } else { - await drawVenueImages(ctx, venueBaseConfig) + await drawVenueImages(ctx, venueBaseConfig, canvasNode) } // 绘制球局信息区域 @@ -554,26 +506,35 @@ const ShareCardCanvas: React.FC = ({ const textX = iconX + iconSize + 20 // 绘制网球图标 - const tennisBallPath = await loadImage(`${OSS_BASE_URL}/images/b3eaf45e-ef28-4e45-9195-823b832e0451.jpg`) + const tennisBallPath = await loadImage(`${OSS_BASE_URL}/images/b3eaf45e-ef28-4e45-9195-823b832e0451.jpg`, canvasNode) ctx.drawImage(tennisBallPath, iconX, gameInfoY, iconSize, iconSize) // 绘制"单打"标签 const danDaX = scale * 100 const danDaY = scale * 196 - const danDaWidth = scale * 76 * dpr const danDaHeight = scale * 40 * dpr const danDaRadius = scale * 20 * dpr const danDaFontSize = scale * 22 * dpr + // 根据内容动态计算标签宽度(左右内边距) + const danDaPaddingX = scale * 16 * dpr + setFont2D(ctx, danDaFontSize) + const danDaTextWidth = ctx.measureText(data.gameType).width + const danDaWidth = danDaTextWidth + danDaPaddingX * 2 drawLabel(ctx, danDaX, danDaY, danDaWidth, danDaHeight, danDaRadius, data.gameType, danDaFontSize) - // 绘制技能等级标签 - const skillX = scale * 190 + // 绘制技能等级标签(基于“单打”标签实际宽度后移) + const labelGap = scale * 16 // 两个标签之间的间距(不乘 dpr,保持视觉间距) + const skillX = danDaX + danDaWidth + labelGap const skillY = scale * 196 - const skillWidth = scale * 180 * dpr const skillHeight = scale * 40 * dpr const skillRadius = scale * 20 * dpr const skillFontSize = scale * 22 * dpr + // 根据内容动态计算技能标签宽度 + const skillPaddingX = scale * 20 * dpr + setFont2D(ctx, skillFontSize) + const skillTextWidth = ctx.measureText(data.skillLevel).width + const skillWidth = skillTextWidth + skillPaddingX * 2 drawLabel(ctx, skillX, skillY, skillWidth, skillHeight, skillRadius, data.skillLevel, skillFontSize) @@ -581,48 +542,56 @@ const ShareCardCanvas: React.FC = ({ const dateX = danDaX const timeInfoY = infoStartY + infoSpacing const timeInfoFontSize = scale * 24 * dpr - const calendarPath = await loadImage(`${OSS_BASE_URL}/images/ea792a5d-b105-4c95-bfc4-8af558f2b33b.jpg`) + const calendarPath = await loadImage(`${OSS_BASE_URL}/images/ea792a5d-b105-4c95-bfc4-8af558f2b33b.jpg`, canvasNode) ctx.drawImage(calendarPath, iconX, timeInfoY, iconSize, iconSize) // 绘制日期(绿色) - drawText(ctx, data.gameDate, dateX, timeInfoY + 8, 300, timeInfoFontSize, '#4CAF50') + drawBoldText(ctx, data.gameDate, dateX, timeInfoY + 8, timeInfoFontSize, '#00E5AD') // 绘制时间(黑色) const timeX = textX + ctx.measureText(data.gameDate).width + 10 * dpr - drawText(ctx, data.gameTime, timeX, timeInfoY + 8, 300, timeInfoFontSize, '#000000') + // drawText(ctx, data.gameTime, timeX, timeInfoY + 8, 300, timeInfoFontSize, '#000000') + drawBoldText(ctx, data.gameTime, timeX, timeInfoY + 8, timeInfoFontSize, '#000000') // 绘制地点 const locationInfoY = infoStartY + infoSpacing * 2 const locationFontSize = scale * 22 * dpr - const locationPath = await loadImage(`${OSS_BASE_URL}/images/adc9a167-2ea9-4e3b-b963-6a894a1fd91b.jpg`) + const locationPath = await loadImage(`${OSS_BASE_URL}/images/adc9a167-2ea9-4e3b-b963-6a894a1fd91b.jpg`, canvasNode) ctx.drawImage(locationPath, iconX, locationInfoY, iconSize, iconSize) - drawText(ctx, data.venueName, danDaX, locationInfoY + 10, 600, locationFontSize, '#000000') + drawBoldText(ctx, data.venueName, danDaX, locationInfoY + 10, locationFontSize, '#000000') // 绘制完成,调用draw方法 console.log('开始调用ctx.draw()') - ctx.draw(false, () => { + const doExport = () => { console.log('Canvas绘制完成,开始生成图片...') - // 延迟一下再生成图片,确保绘制完成 - setTimeout(() => { - Taro.canvasToTempFilePath({ - canvasId: 'shareCardCanvas', - fileType: 'png', - quality: 1, - success: (res) => { - console.log('图片生成成功:', res.tempFilePath) - setIsDrawing(false) // 绘制完成,重置状态 - resolve(res.tempFilePath) - onGenerated?.(res.tempFilePath) - setTempImagePath(res.tempFilePath) - }, - fail: (error) => { - console.error('图片生成失败:', error) - setIsDrawing(false) // 绘制失败,重置状态 - reject(error) - } - }) - }, 500) // 延迟500ms确保Canvas完全渲染 - }) + const opts: any = { + fileType: 'png', + quality: 1, + success: (res: any) => { + console.log('图片生成成功:', res.tempFilePath) + setIsDrawing(false) + resolve(res.tempFilePath) + onGenerated?.(res.tempFilePath) + setTempImagePath(res.tempFilePath) + }, + fail: (error: any) => { + console.error('图片生成失败:', error) + setIsDrawing(false) + reject(error) + } + } + if (canvasNode) { + opts.canvas = canvasNode + } else { + opts.canvasId = 'shareCardCanvas' + } + ;(Taro as any).canvasToTempFilePath(opts) + } + if (typeof (ctx as any).draw === 'function') { + ;(ctx as any).draw(false, () => setTimeout(doExport, 300)) + } else { + setTimeout(doExport, 100) + } console.log('Canvas绘制命令已发送') } catch (error) { @@ -639,20 +608,79 @@ const ShareCardCanvas: React.FC = ({ // 手动分享方法(已移除,由父组件处理分享) - - // 组件挂载后绘制 + // 使用 HTTPS 远程字体(woff2)加载到小程序渲染层;不改字号 useEffect(() => { - if (data && !isDrawing && !tempImagePath) { - console.log('组件挂载,开始绘制分享卡片') - // 延迟一下确保Canvas已经渲染 - setTimeout(() => { - // 在微信小程序中,需要使用Taro.createCanvasContext - const ctx = Taro.createCanvasContext('shareCardCanvas') - console.log('Canvas上下文创建成功:', ctx) - drawShareCard(ctx) - }, 500) + try { + (Taro as any).loadFontFace({ + global: true, + family: 'Noto Sans SC', + source: 'url("https://fonts.gstatic.com/s/notosanssc/v39/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaGzjCnYlNbPzT7HEL7j12XCOHJKg4RgZw3nFTvwZ8atTsBvwlvRUk7mYP2g.24.woff2")', + desc: { style: 'normal', weight: '700' }, + success: () => { + console.log('===Noto Sans SC 远程字体加载成功') + try { + if (ctx2d && is2dCtx) { + drawShareCard(ctx2d) + } else { + console.log('字体已加载,但 2D 上下文尚未就绪,等待初始化后再绘制') + } + } catch (e) { + console.warn('===字体加载成功但重绘失败(忽略)', e) + } + }, + fail: (err: any) => { + console.warn('===Noto Sans SC 远程字体加载失败:', err) + } + }) + } catch (e) { + console.warn('===loadFontFace 不可用,跳过远程字体加载', e) } - }, [data]) // 只依赖data,移除canvasWidth避免无限循环 + }, [ctx2d, is2dCtx]) + + + // 初始化 2D Canvas(可回退旧版) + useEffect(() => { + try { + ;(Taro as any).createSelectorQuery() + .select('#shareCardCanvas') + .fields({ node: true, size: true }) + .exec((res: any[]) => { + const data = res && res[0] + if (data && data.node) { + const canvas = data.node + const context = canvas.getContext('2d') + // DPR 缩放,提升清晰度(当前 dpr = 1 也可正常显示) + const sys = (Taro as any).getSystemInfoSync?.() || {} + const ratio = sys.pixelRatio || 1 + canvas.width = canvasWidth * ratio + canvas.height = canvasHeight * ratio + context.scale(ratio, ratio) + setCanvasNode(canvas) + setCtx2d(context) + setIs2dCtx(true) + console.log('2D Canvas 初始化成功') + } else { + setIs2dCtx(false) + console.log('2D Canvas 不可用,回退旧版 Canvas') + } + }) + } catch (e) { + console.warn('初始化 2D Canvas 失败,回退旧版', e) + setIs2dCtx(false) + } + }, []) + + // 组件挂载后绘制(仅在 2D 上下文就绪后绘制,避免回退导致 getImageInfo 404) + useEffect(() => { + if (data && !isDrawing && !tempImagePath && ctx2d && is2dCtx) { + console.log('组件挂载,开始绘制分享卡片') + // 延迟一下确保Canvas已经渲染 + setTimeout(() => { + console.log('使用 2D Canvas 开始绘制') + drawShareCard(ctx2d) + }, 500) + } + }, [data, ctx2d, is2dCtx]) // 等 2D 上下文可用后再绘制 // 暴露分享方法给父组件 useEffect(() => { @@ -666,10 +694,12 @@ const ShareCardCanvas: React.FC = ({ { - if (ctx && typeof ctx === 'object') { - if (typeof ctx.setFillStyle !== 'function') { - ctx.setFillStyle = (v: any) => { ctx.fillStyle = v } - } - if (typeof ctx.setStrokeStyle !== 'function') { - ctx.setStrokeStyle = (v: any) => { ctx.strokeStyle = v } - } - if (typeof ctx.setLineWidth !== 'function') { - ctx.setLineWidth = (v: number) => { ctx.lineWidth = v } - } - if (typeof ctx.setLineCap !== 'function') { - ctx.setLineCap = (v: any) => { ctx.lineCap = v } - } - if (typeof ctx.setLineJoin !== 'function') { - ctx.setLineJoin = (v: any) => { ctx.lineJoin = v } - } - if (typeof ctx.setTextAlign !== 'function') { - ctx.setTextAlign = (v: any) => { ctx.textAlign = v } - } - if (typeof ctx.setTextBaseline !== 'function') { - ctx.setTextBaseline = (v: any) => { ctx.textBaseline = v } - } - if (typeof ctx.setFontSize !== 'function') { - ctx.setFontSize = (size: number) => { ctx.font = `${size}px sans-serif` } - } - if (typeof ctx.setFont !== 'function') { - ctx.setFont = (fontStr: string) => { ctx.font = fontStr } - } - } +// 2D Canvas 字体设置 +const setFont2D = (ctx: any, fontSize: number, family?: string, weight?: string) => { + const fam = family || 'Noto Sans SC' + const wt = weight || '500' + ctx.font = `${wt} ${fontSize}px "${fam}"` } -// 绘制加粗文字(单行) -const drawBoldText = (ctx: any, text: string, x: number, y: number, fontSize: number, color: string, fontFamily?: string) => { - // 设置字体样式 - if (fontFamily) { - try { - // 尝试使用setFont方法(如果支持) - ctx.setFont(`${fontSize}px ${fontFamily}`) - } catch (error) { - // 如果不支持setFont,回退到setFontSize - ctx.setFontSize(fontSize) - } - } else { - ctx.setFontSize(fontSize) - } - - ctx.setFillStyle(color) - ctx.setTextAlign('left') - ctx.setTextBaseline('top') - - // 绘制加粗效果:多次绘制并偏移 +// 绘制加粗文字(单行,支持可选描边式加粗) +const drawBoldText = (ctx: any, text: string, x: number, y: number, fontSize: number, color: string, fontFamily: string = 'Noto Sans SC', fontWeight: string = '500') => { + setFont2D(ctx, fontSize, fontFamily, fontWeight) + ctx.fillStyle = color + ctx.textAlign = 'left' + ctx.textBaseline = 'top' ctx.fillText(text, x, y) - ctx.fillText(text, x + 1, y) - ctx.fillText(text, x, y + 1) - ctx.fillText(text, x + 1, y + 1) -} - -// 绘制文字(支持自动换行)- 微信小程序版本 -const drawText = (ctx: any, text: string, x: number, y: number, maxWidth: number, fontSize: number, color: string, bold: boolean = false, fontFamily?: string) => { - // 设置字体样式 - if (fontFamily) { - try { - // 尝试使用setFont方法(如果支持) - ctx.setFont(`${fontSize}px ${fontFamily}`) - } catch (error) { - // 如果不支持setFont,回退到setFontSize - ctx.setFontSize(fontSize) - } - } else { - ctx.setFontSize(fontSize) - } - - ctx.setFillStyle(color) - ctx.setTextAlign('left') - ctx.setTextBaseline('top') - - const words = text.split('') - let line = '' - let lineY = y - - for (let i = 0; i < words.length; i++) { - const testLine = line + words[i] - // 微信小程序中measureText返回的是对象,需要访问width属性 - const metrics = ctx.measureText(testLine) - const testWidth = metrics.width - - if (testWidth > maxWidth && i > 0) { - if (bold) { - // 绘制加粗效果:多次绘制并偏移 - ctx.fillText(line, x, lineY) - ctx.fillText(line, x + 1, lineY) - ctx.fillText(line, x, lineY + 1) - ctx.fillText(line, x + 1, lineY + 1) - } else { - ctx.fillText(line, x, lineY) - } - line = words[i] - lineY += fontSize * 1.2 - } else { - line = testLine - } - } - - if (bold) { - // 绘制加粗效果:多次绘制并偏移 - ctx.fillText(line, x, lineY) - ctx.fillText(line, x + 1, lineY) - ctx.fillText(line, x, lineY + 1) - ctx.fillText(line, x + 1, lineY + 1) - } else { - ctx.fillText(line, x, lineY) - } + // if (isBold) { + // ctx.fillText(text, x + 1, y) + // ctx.fillText(text, x, y + 1) + // ctx.fillText(text, x + 1, y + 1) + // } } // 绘制圆角矩形函数 @@ -178,22 +83,22 @@ const drawRoundedRect = (ctx: any, x: number, y: number, width: number, height: // 绘制标签函数(通用) const drawLabel = (ctx: any, x: number, y: number, width: number, height: number, radius: number, text: string, fontSize: number, textColor: string = '#000000', bgColor: string = '#FFFFFF', borderColor: string = '#E0E0E0') => { // 绘制背景 - ctx.setFillStyle(bgColor) + ctx.fillStyle = bgColor drawRoundedRect(ctx, x, y, width, height, radius) // 绘制边框 - ctx.setStrokeStyle(borderColor) - ctx.setLineWidth(1 * dpr) + ctx.strokeStyle = borderColor + ctx.lineWidth = 1 * dpr ctx.stroke() // 绘制文字 const textCenterX = x + width / 2 const textCenterY = y + height / 2 - ctx.setFillStyle(textColor) - ctx.setTextAlign('center') - ctx.setTextBaseline('middle') - ctx.setFontSize(fontSize) + ctx.fillStyle = textColor + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + setFont2D(ctx, fontSize) ctx.save() ctx.translate(textCenterX, textCenterY) @@ -201,49 +106,31 @@ const drawLabel = (ctx: any, x: number, y: number, width: number, height: number ctx.restore() } -// 加载图片 - 微信小程序版本 -// const loadImage = (src: string): Promise => { -// return new Promise((resolve, reject) => { -// Taro.getImageInfo({ -// src: src, -// success: (res) => resolve(res.path), -// fail: reject -// }) -// }) -// } - -// 工具函数 - OffscreenCanvas 下加载图片(从 runtime.offscreen 读取) +// 工具函数 - OffscreenCanvas 下加载图片(使用 offscreen.createImage) const loadImage = (src: string): Promise => { return new Promise((resolve, reject) => { - Taro.getImageInfo({ - src, - success: (res) => { - try { - // @ts-ignore - createImage 为小程序 OffscreenCanvas 能力 - const off = runtime.offscreen - if (!off || typeof off.createImage !== 'function') { - throw new Error('OffscreenCanvas 未初始化或不支持 createImage') - } - const img = off.createImage() - img.onload = () => resolve(img) - img.onerror = reject - img.src = res.path - } catch (e) { - reject(e) - } - }, - fail: reject - }) + try { + const off = runtime.offscreen + if (!off || typeof off.createImage !== 'function') { + throw new Error('OffscreenCanvas 未初始化或不支持 createImage') + } + const img = off.createImage() + img.onload = () => resolve(img) + img.onerror = reject + img.src = src + } catch (e) { + reject(e) + } }) } // 绘制SVG路径到Canvas const drawSVGPathToCanvas = (ctx: any) => { // 设置绘制样式 - ctx.setStrokeStyle('#00E5AD'); - ctx.setLineWidth(scale * 3 * dpr); - ctx.setLineCap('round'); - ctx.setLineJoin('round'); + ctx.strokeStyle = '#00E5AD'; + ctx.lineWidth = scale * 3 * dpr; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; ctx.save(); @@ -296,7 +183,7 @@ const drawVenueImages = async (ctx: any, venueImageConfig: any) => { ctx.rotate((rotation * Math.PI) / 180) // 1. 先绘制白色圆角矩形背景 - ctx.setFillStyle('#FFFFFF') + ctx.fillStyle = '#FFFFFF' ctx.beginPath() // 使用更精确的圆角矩形绘制 @@ -385,7 +272,7 @@ const drawVenueImages = async (ctx: any, venueImageConfig: any) => { ctx.rotate((rotation * Math.PI) / 180) // 绘制白色圆角矩形背景 - ctx.setFillStyle('#FFFFFF') + ctx.fillStyle = '#FFFFFF' ctx.beginPath() const rectX = -playerImgSize / 2 @@ -427,7 +314,7 @@ const drawVenueImages = async (ctx: any, venueImageConfig: any) => { const imgY = -playerImgSize / 2 + padding const imgSize = playerImgSize - padding * 2 - ctx.setFillStyle('#E0E0E0') + ctx.fillStyle = '#E0E0E0' ctx.beginPath() const imgRadius = borderRadius - padding @@ -471,8 +358,6 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro console.log('开始绘制分享卡片...') try { - // 先对 2D 上下文做一次 API 兼容处理 - polyfillCanvasContext(ctx) // 设置Canvas的实际尺寸(使用dpr确保高清显示) const canvasWidthPx = canvasWidth * dpr const canvasHeightPx = canvasHeight * dpr @@ -492,13 +377,13 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro const gradient = ctx.createLinearGradient(0, 0, 0, canvasHeightPx) gradient.addColorStop(0, '#BFFFEF') gradient.addColorStop(1, '#F2FFFC') - ctx.setFillStyle(gradient) + ctx.fillStyle = gradient ctx.fillRect(0, 0, canvasWidthPx, canvasHeightPx) console.log('背景绘制完成') // 绘制背景条纹 已完成 - ctx.setStrokeStyle('rgba(0, 0, 0, 0.03)') - ctx.setLineWidth(2) + ctx.strokeStyle = 'rgba(0, 0, 0, 0.03)' + ctx.lineWidth = 2 for (let i = 0; i < canvasWidthPx; i += 4) { ctx.beginPath() ctx.moveTo(i, 0) @@ -532,7 +417,7 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro const nicknameX = avatarX + avatarSize + 8 * dpr // 距离头像8px const nicknameY = avatarY + (avatarSize - 18 * dpr) / 2 // 与头像水平居中对齐 const nicknameFontSize = scale * 18 * dpr - drawText(ctx, data.userNickname, nicknameX, nicknameY, 200 * dpr, nicknameFontSize, '#000000', true, '"Noto Sans SC"') + drawBoldText(ctx, data.userNickname, nicknameX, nicknameY, nicknameFontSize, '#000000', 'Noto Sans SC', '900') // 绘制"邀你加入球局"文案 const inviteX = scale * 35 * dpr // 距离画布左侧35px @@ -540,12 +425,12 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro const inviteFontSize = scale * 44 * dpr // 绘制"邀你加入" - drawBoldText(ctx, '邀你加入', inviteX, inviteY, inviteFontSize, '#000000', "Noto Sans SC") + drawBoldText(ctx, '邀你加入', inviteX, inviteY, inviteFontSize, '#000000', 'Noto Sans SC', '900') // 绘制"球局"特殊样式 const qiuJuX = inviteX + ctx.measureText('邀你加入').width + 4 * dpr const qiuJuFontSize = scale * 44 * dpr - drawBoldText(ctx, '球局', qiuJuX, inviteY, qiuJuFontSize, '#00E5AD', '"Noto Sans SC"') + drawBoldText(ctx, '球局', qiuJuX, inviteY, qiuJuFontSize, '#00E5AD', 'Noto Sans SC', '900') // 测试绘制网络图片 drawSVGPathToCanvas(ctx) @@ -607,7 +492,7 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro const danDaFontSize = scale * 22 * dpr // 根据内容动态计算标签宽度(左右内边距) const danDaPaddingX = scale * 16 * dpr - ctx.setFontSize(danDaFontSize) + setFont2D(ctx, danDaFontSize) const danDaTextWidth = ctx.measureText(data.gameType).width const danDaWidth = danDaTextWidth + danDaPaddingX * 2 @@ -622,7 +507,7 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro const skillFontSize = scale * 22 * dpr // 根据内容动态计算技能标签宽度 const skillPaddingX = scale * 20 * dpr - ctx.setFontSize(skillFontSize) + setFont2D(ctx, skillFontSize) const skillTextWidth = ctx.measureText(data.skillLevel).width const skillWidth = skillTextWidth + skillPaddingX * 2 @@ -635,19 +520,19 @@ const drawShareCard = async (ctx: any, data: ShareCardData, offscreen: any): Pro const calendarPath = await loadImage(`${OSS_BASE_URL}/images/ea792a5d-b105-4c95-bfc4-8af558f2b33b.jpg`) ctx.drawImage(calendarPath, iconX, timeInfoY, iconSize, iconSize) - // 绘制日期(绿色) - drawText(ctx, data.gameDate, dateX, timeInfoY + 8, 300, timeInfoFontSize, '#00E5AD') + // 绘制日期(绿色,非描边粗体) + drawBoldText(ctx, data.gameDate, dateX, timeInfoY + 8, timeInfoFontSize, '#00E5AD') // 绘制时间(黑色) const timeX = textX + ctx.measureText(data.gameDate).width + 10 * dpr - drawText(ctx, data.gameTime, timeX, timeInfoY + 8, 300, timeInfoFontSize, '#000000') + drawBoldText(ctx, data.gameTime, timeX, timeInfoY + 8, timeInfoFontSize, '#000000') // 绘制地点 const locationInfoY = infoStartY + infoSpacing * 2 const locationFontSize = scale * 22 * dpr const locationPath = await loadImage(`${OSS_BASE_URL}/images/adc9a167-2ea9-4e3b-b963-6a894a1fd91b.jpg`) ctx.drawImage(locationPath, iconX, locationInfoY, iconSize, iconSize) - drawText(ctx, data.venueName, danDaX, locationInfoY + 10, 600, locationFontSize, '#000000') + drawBoldText(ctx, data.venueName, danDaX, locationInfoY + 10, locationFontSize, '#000000') try { const wxAny: any = (typeof (globalThis as any) !== 'undefined' && (globalThis as any).wx) ? (globalThis as any).wx : null