import type { SceneElement, ImageElement, TextElement, } from '../components/canvas' /** * 直接加载图片(不做跨域处理) */ function loadImageDirectly(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => resolve(img) img.onerror = () => reject(new Error(`Failed to load image: ${src}`)) img.src = src }) } /** * 加载图片(通过 fetch 获取 blob,避免跨域污染 canvas) * 对于跨域图片,先 fetch 为 blob,再创建 object URL */ async function loadImage(src: string): Promise { // 检查是否是 data URL 或 blob URL(这些不需要特殊处理) if (src.startsWith('data:') || src.startsWith('blob:')) { return loadImageDirectly(src) } // 检查是否是同源 try { const url = new URL(src, window.location.origin) if (url.origin === window.location.origin) { // 同源图片直接加载 return loadImageDirectly(src) } } catch { // URL 解析失败,尝试直接加载 return loadImageDirectly(src) } // 跨域图片:通过 fetch 获取 blob try { const response = await fetch(src, { mode: 'cors' }) if (!response.ok) { throw new Error(`Failed to fetch image: ${response.status}`) } const blob = await response.blob() const blobUrl = URL.createObjectURL(blob) try { const img = await loadImageDirectly(blobUrl) // 注意:这里不立即 revoke,因为 canvas 可能还需要使用 // 在 exportFrameAsCanvas 完成后会释放 return img } catch (err) { URL.revokeObjectURL(blobUrl) throw err } } catch { // fetch 失败,回退到直接加载(可能仍会污染 canvas) console.warn( `[Export] Failed to fetch image via blob, falling back to direct load: ${src}`, ) return loadImageDirectly(src) } } /** * 绘制单行文字 */ function drawTextLine( ctx: CanvasRenderingContext2D, line: string, boxX: number, y: number, maxWidth: number, textAlign: string, ) { let x = boxX if (textAlign === 'center') { x = boxX + maxWidth / 2 ctx.textAlign = 'center' } else if (textAlign === 'right') { x = boxX + maxWidth ctx.textAlign = 'right' } else { ctx.textAlign = 'left' } ctx.fillText(line, x, y) } /** * 绘制文本元素并处理换行 */ function drawTextElement( ctx: CanvasRenderingContext2D, element: TextElement, boxX: number, boxY: number, ) { const { content, fontSize = 32, fontFamily = 'Inter, sans-serif', color = '#000000', textAlign = 'left', fontWeight = 'normal', fontStyle = 'normal', width: maxWidth, } = element ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}` ctx.fillStyle = color ctx.textBaseline = 'top' const lineHeight = fontSize * 1.2 const paragraphs = (content || '').split('\n') let currentY = boxY for (const paragraph of paragraphs) { if (paragraph === '') { currentY += lineHeight continue } // 字符级换行,匹配 break-words 逻辑 const chars = paragraph.split('') let currentLine = '' for (let n = 0; n < chars.length; n++) { const testLine = currentLine + chars[n] const metrics = ctx.measureText(testLine) const testWidth = metrics.width if (testWidth > maxWidth && n > 0 && currentLine !== '') { drawTextLine(ctx, currentLine, boxX, currentY, maxWidth, textAlign) currentLine = chars[n] currentY += lineHeight } else { currentLine = testLine } } drawTextLine(ctx, currentLine, boxX, currentY, maxWidth, textAlign) currentY += lineHeight } } export function useDownLoadImageGroup( selectionBounds: { left: number top: number width: number height: number } | null, elements: Array, ) { const { left = 0, top = 0, width = 0, height = 0 } = selectionBounds ?? {} const scale = 2 // 放大倍数,提升图片质量 const drawCanvas = async () => { // 创建 canvas const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') if (!ctx) return canvas.width = width * scale canvas.height = height * scale ctx.scale(scale, scale) // 填充白色背景 ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, width, height) // 渲染子元素 for (const element of elements) { if (!element) continue const { x, y, width: elWidth, height: elHeight, rotation = 0 } = element const boxX = x - left const boxY = y - top ctx.save() // 旋转处理 if (rotation !== 0) { ctx.translate(boxX + elWidth / 2, boxY + elHeight / 2) ctx.rotate((rotation * Math.PI) / 180) ctx.translate(-(boxX + elWidth / 2), -(boxY + elHeight / 2)) } if (element.type === 'image') { const img = await loadImage((element as ImageElement).src) ctx.drawImage(img, boxX, boxY, elWidth, elHeight) } else if (element.type === 'text') { drawTextElement(ctx, element as TextElement, boxX, boxY) } ctx.restore() } return canvas } const downloadCompositeImage = async () => { const canvas = await drawCanvas() if (!canvas) return // 生成图片并下载 canvas.toBlob( blob => { if (!blob) return const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `composite-${Date.now()}.jpg` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) }, 'image/jpeg', 0.95, ) } const downloadImage = async ( src: string, fileName: string, useCanvas = false, ) => { if (useCanvas) { const img = await loadImage(src) // 生成图片并下载 const canvas = document.createElement('canvas') canvas.width = img.width canvas.height = img.height const ctx = canvas.getContext('2d') if (!ctx) return ctx.drawImage(img, 0, 0) canvas.toBlob( blob => { if (!blob) return const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = fileName || 'image.jpeg' document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) }, 'image/jpeg', 0.95, ) } else { // 直接使用a标签下载图片 const link = document.createElement('a') link.href = src link.download = fileName || 'image.jpeg' document.body.appendChild(link) link.click() document.body.removeChild(link) } } return { downloadCompositeImage, downloadImage } }