import fabric from '../PictureEditor/js/fabric-adapter' import { buildRenderPlans, normalizeGoods } from './generateImagesPlan' /** * 根据渲染计划和拍平后的货号数据,真正生成图片(dataURL),按 canvasIndex 返回。 * * 注意: * - 不做任何上传,只返回 dataURL,方便在 EXE 或其他环境里落地到本地文件。 * - 需要在外部保证 canvasList / plans / skus 的来源一致。 * * @param {Array} plans 由 buildRenderPlans 生成的渲染计划 * @param {Array} canvasList 画布配置数组(包含 canvas_json / width / height / bg_color 等) * @param {Array} skus normalizeGoods(goodsList) 的结果 * @returns {Promise>} */ // export async function renderImagesByPlans(plans, canvasList, skus) { // const results = [] // // 工具:从 canvas_json 创建一个离屏 fabric.Canvas,并应用一次性的渲染逻辑 // const renderOne = (canvasItem, planForCanvas, imagePlan) => // new Promise((resolve) => { // if (!canvasItem) { // return resolve(null) // } // const { canvasIndex } = planForCanvas // const { skuIndexes } = imagePlan // // 模特/场景占位:直接返回类型,不渲染 // if (canvasItem.canvas_json === 'model') { // return resolve({ // canvasIndex, // dataUrl: 'model', // }) // } // if (canvasItem.canvas_json === 'scene') { // return resolve({ // canvasIndex, // dataUrl: 'scene', // }) // } // // 解析 JSON 为可修改的对象 // let json // try { // json = typeof canvasItem.canvas_json === 'string' // ? JSON.parse(canvasItem.canvas_json) // : JSON.parse(JSON.stringify(canvasItem.canvas_json)) // } catch (e) { // return resolve(null) // } // const width = Number(canvasItem.width) || 395 // const height = Number(canvasItem.height) || 600 // const bgColor = canvasItem.bg_color || '#fff' // try { // const mode = canvasItem.multi_goods_mode || '' // const perCanvasSlots = planForCanvas.perCanvasSlots || 1 // const usedSkus = (skuIndexes || []).map((idx) => (idx != null ? skus[idx] : null)) // // 先在原始 JSON 上做数据替换,避免 setSrc 的异步问题; // // loadFromJSON 会在所有图片加载完成后才触发回调。 // const objs = (json && Array.isArray(json.objects)) ? json.objects : [] // // 1) 处理图片占位(data-type = img) // const imgPlaceholders = objs.filter((o) => o && o['data-type'] === 'img') // if (mode === 'multiple') { // imgPlaceholders.forEach((obj, idx) => { // const slotIndex = idx % perCanvasSlots // const sku = usedSkus[slotIndex] // const angleKey = obj['data-key'] // if (!sku) { // obj.visible = false // return // } // const url = (sku.pics && sku.pics[angleKey]) || '' // if (!url) { // obj.visible = false // return // } // obj.visible = true // obj['data-value'] = url // obj.src = url // }) // } else { // // 默认 / single:一个货号多角度,一张图只用一个货号 // const sku = usedSkus[0] // imgPlaceholders.forEach((obj) => { // if (!sku) { // obj.visible = false // return // } // const angleKey = obj['data-key'] // const url = (sku.pics && sku.pics[angleKey]) || '' // if (!url) { // obj.visible = false // return // } // obj.visible = true // obj['data-value'] = url // obj.src = url // }) // } // // 2) 处理文字占位(data-type = text) // const textPlaceholders = objs.filter((o) => o && o['data-type'] === 'text') // if (textPlaceholders.length) { // // 通用的 key -> 文本 映射函数, // const mapKeyToText = (sku, key, defaultVal) => { // if (!sku) return defaultVal // let textVal = defaultVal || '' // if (key === '颜色') { // textVal = sku.color || textVal // } else if (key === '货号') { // textVal = sku.sku || textVal // } // // 兜底:去 raw 里找同名字段(支持 卖点 / 使用场景 / 其他自定义字段) // if ((!textVal || textVal === defaultVal) && sku.raw && sku.raw[key] != null) { // textVal = sku.raw[key] // } // // 再兜底:如果 sku 上有同名字段 // if ((!textVal || textVal === defaultVal) && sku[key] != null) { // textVal = sku[key] // } // return textVal // } // if (mode === 'multiple') { // // 多个货号同角度: // // - 按 data-key + 出现顺序把文字分配给不同货号 // // - 如果该 slot 没有对应货号(usedSkus[slotIndex] 为 null),则隐藏该文字层 // const keyCounter = {} // textPlaceholders.forEach((obj) => { // const key = obj['data-key'] // if (!key) return // const idxForKey = keyCounter[key] || 0 // keyCounter[key] = idxForKey + 1 // const slotIndex = idxForKey % perCanvasSlots // const sku = usedSkus[slotIndex] // if (!sku) { // obj.visible = false // return // } // const origin = obj['data-value'] || '' // const textVal = mapKeyToText(sku, key, origin) // obj.visible = true // obj.text = textVal // obj['data-value'] = textVal // }) // } else { // // 默认 / single:全部文字都使用同一个货号(默认模式只生成 1 张,用第一个货号) // const sku = usedSkus[0] // if (sku) { // textPlaceholders.forEach((obj) => { // const key = obj['data-key'] // if (!key) return // const origin = obj['data-value'] || '' // const textVal = mapKeyToText(sku, key, origin) // obj.visible = true // obj.text = textVal // obj['data-value'] = textVal // }) // } // } // } // // 创建离屏 canvas // const el = document.createElement('canvas') // el.width = width // el.height = height // const fcanvas = new fabric.Canvas(el, { // backgroundColor: bgColor, // width, // height, // renderOnAddRemove: false, // }) // // 把已经替换好动态数据的 JSON 加载进 fabric; // // loadFromJSON 会等所有图片资源加载完之后才调用回调函数 // fcanvas.loadFromJSON(json, () => { // try { // fcanvas.renderAll() // // 获取商品图宽高,并在导出时根据宽度做压缩控制 // const MAX_WIDTH = 2048 // // 默认导出倍数(原来为 2,保持清晰度) // let exportMultiplier = 2 // // 计算按当前导出倍数后的宽度 // const expectedWidth = width * exportMultiplier // if (expectedWidth > MAX_WIDTH) { // // 根据最大宽度反推需要的缩放倍数(<= 1) // exportMultiplier = MAX_WIDTH / width // } // const finalWidth = Math.round(width * exportMultiplier) // const finalHeight = Math.round(height * exportMultiplier) // const dataUrl = fcanvas.toDataURL({ // format: 'jpeg', // multiplier: exportMultiplier, // enableRetinaScaling: true, // }) // fcanvas.dispose() // resolve({ // canvasIndex, // dataUrl, // width: finalWidth, // height: finalHeight, // }) // } catch (e) { // try { // fcanvas.dispose() // } catch (e2) {} // resolve(null) // } // }) // } catch (e) { // resolve(null) // } // }) // for (const plan of plans || []) { // const canvasItem = canvasList[plan.canvasIndex] // if (!canvasItem) continue // for (const imgPlan of plan.images || []) { // // eslint-disable-next-line no-await-in-loop // const res = await renderOne(canvasItem, plan, imgPlan) // if (res) results.push(res) // } // } // return results // } export async function renderImagesByPlans(plans, canvasList, skus) { const results = [] // 工具函数:只检查并压缩图片,返回压缩后的图片URL const compressImageIfNeeded = (url, maxWidth = 2048) => { return new Promise((resolve) => { if (!url) { resolve(null) return } const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => { // 检查图片是否需要压缩 if (img.width <= maxWidth) { resolve(url) return } // 计算压缩后的尺寸(等比例压缩) const scale = maxWidth / img.width const newWidth = maxWidth const newHeight = Math.round(img.height * scale) // 创建canvas进行压缩 const canvas = document.createElement('canvas') canvas.width = newWidth canvas.height = newHeight const ctx = canvas.getContext('2d') if (!ctx) { console.error('无法获取canvas 2d上下文') resolve(url) return } // 重要:设置canvas背景为透明 // 方法1:清除画布,设置为透明 // ctx.clearRect(0, 0, newWidth, newHeight) // 方法2:或者使用透明矩形填充 ctx.fillStyle = 'rgba(0, 0, 0, 0)' ctx.fillRect(0, 0, newWidth, newHeight) // 设置高质量压缩参数 ctx.imageSmoothingEnabled = true ctx.imageSmoothingQuality = 'high' // 绘制压缩后的图片 ctx.drawImage(img, 0, 0, newWidth, newHeight) // 返回压缩后的dataURL,使用PNG格式保持透明通道(如果原图有透明背景) // 但如果是JPG商品图,通常没有透明通道,也可以用PNG const dataUrl = canvas.toDataURL('image/png') // 使用PNG确保无黑色背景 canvas.remove() resolve(dataUrl) } img.onerror = (error) => { resolve(url) // 返回原始URL作为fallback } img.src = url }) } // 工具:从 canvas_json 创建一个离屏 fabric.Canvas,并应用一次性的渲染逻辑 const renderOne = async (canvasItem, planForCanvas, imagePlan) => { return new Promise(async (resolve) => { if (!canvasItem) { return resolve(null) } const { canvasIndex } = planForCanvas const { skuIndexes } = imagePlan // 模特/场景占位:直接返回类型,不渲染 if (canvasItem.canvas_json === 'model') { return resolve({ canvasIndex, dataUrl: 'model', }) } if (canvasItem.canvas_json === 'scene') { return resolve({ canvasIndex, dataUrl: 'scene', }) } // 解析 JSON 为可修改的对象 let json try { json = typeof canvasItem.canvas_json === 'string' ? JSON.parse(canvasItem.canvas_json) : JSON.parse(JSON.stringify(canvasItem.canvas_json)) } catch (e) { return resolve(null) } const width = Number(canvasItem.width) || 395 const height = Number(canvasItem.height) || 600 const bgColor = canvasItem.bg_color || '#fff' try { const mode = canvasItem.multi_goods_mode || '' const perCanvasSlots = planForCanvas.perCanvasSlots || 1 const usedSkus = (skuIndexes || []).map((idx) => (idx != null ? skus[idx] : null)) // 获取画布对象 const objs = (json && Array.isArray(json.objects)) ? json.objects : [] // 1) 处理图片占位(data-type = img) const imgPlaceholders = objs.filter((o) => o && o['data-type'] === 'img') // 为每个图片占位生成压缩后的URL const imageUrlMap = new Map() // 收集所有需要压缩的图片URL const urlsToCompress = [] if (mode === 'multiple') { imgPlaceholders.forEach((obj, idx) => { const slotIndex = idx % perCanvasSlots const sku = usedSkus[slotIndex] const angleKey = obj['data-key'] if (!sku) { return } const url = (sku.pics && sku.pics[angleKey]) || '' if (!url) { return } urlsToCompress.push({ url, objId: `${idx}_${angleKey}`, originalObj: obj, angleKey, skuIndex: slotIndex }) }) } else { // 默认 / single:一个货号多角度,一张图只用一个货号 const sku = usedSkus[0] if (!sku) { } imgPlaceholders.forEach((obj, idx) => { if (!sku) return const angleKey = obj['data-key'] const url = (sku.pics && sku.pics[angleKey]) || '' if (!url) { return } urlsToCompress.push({ url, objId: `${idx}_${angleKey}`, originalObj: obj, angleKey }) }) } // 并发压缩所有图片 const compressResults = await Promise.all( urlsToCompress.map(async ({ url, objId, originalObj, angleKey }) => { try { const compressedUrl = await compressImageIfNeeded(url , originalObj.width) return { objId, compressedUrl, originalObj, angleKey } } catch (e) { return { objId, compressedUrl: null, originalObj, angleKey } } }) ) // 更新imageUrlMap compressResults.forEach(({ objId, compressedUrl }) => { if (compressedUrl) { imageUrlMap.set(objId, compressedUrl) } }) // 更新JSON中的图片URL if (mode === 'multiple') { imgPlaceholders.forEach((obj, idx) => { const slotIndex = idx % perCanvasSlots const sku = usedSkus[slotIndex] const angleKey = obj['data-key'] if (!sku) { obj.visible = false return } const objId = `${idx}_${angleKey}` const compressedUrl = imageUrlMap.get(objId) if (compressedUrl) { obj.visible = true obj['data-value'] = compressedUrl obj.src = compressedUrl } else { const url = (sku.pics && sku.pics[angleKey]) || '' if (!url) { obj.visible = false return } obj.visible = true obj['data-value'] = url obj.src = url } }) } else { // 默认 / single:一个货号多角度,一张图只用一个货号 const sku = usedSkus[0] imgPlaceholders.forEach((obj, idx) => { if (!sku) { obj.visible = false return } const angleKey = obj['data-key'] const objId = `${idx}_${angleKey}` const compressedUrl = imageUrlMap.get(objId) if (compressedUrl) { obj.visible = true obj['data-value'] = compressedUrl obj.src = compressedUrl } else { const url = (sku.pics && sku.pics[angleKey]) || '' if (!url) { obj.visible = false return } obj.visible = true obj['data-value'] = url obj.src = url } }) } // 2) 处理文字占位(data-type = text) const textPlaceholders = objs.filter((o) => o && o['data-type'] === 'text') if (textPlaceholders.length) { // 通用的 key -> 文本 映射函数, const mapKeyToText = (sku, key, defaultVal) => { if (!sku) return defaultVal let textVal = defaultVal || '' if (key === '颜色') { textVal = sku.color || textVal } else if (key === '货号') { textVal = sku.sku || textVal } // 兜底:去 raw 里找同名字段(支持 卖点 / 使用场景 / 其他自定义字段) if ((!textVal || textVal === defaultVal) && sku.raw && sku.raw[key] != null) { textVal = sku.raw[key] } // 再兜底:如果 sku 上有同名字段 if ((!textVal || textVal === defaultVal) && sku[key] != null) { textVal = sku[key] } return textVal } if (mode === 'multiple') { // 多个货号同角度: // - 按 data-key + 出现顺序把文字分配给不同货号 // - 如果该 slot 没有对应货号(usedSkus[slotIndex] 为 null),则隐藏该文字层 const keyCounter = {} textPlaceholders.forEach((obj) => { const key = obj['data-key'] if (!key) return const idxForKey = keyCounter[key] || 0 keyCounter[key] = idxForKey + 1 const slotIndex = idxForKey % perCanvasSlots const sku = usedSkus[slotIndex] if (!sku) { obj.visible = false return } const origin = obj['data-value'] || '' const textVal = mapKeyToText(sku, key, origin) obj.visible = true obj.text = textVal obj['data-value'] = textVal }) } else { // 默认 / single:全部文字都使用同一个货号(默认模式只生成 1 张,用第一个货号) const sku = usedSkus[0] if (sku) { textPlaceholders.forEach((obj) => { const key = obj['data-key'] if (!key) return const origin = obj['data-value'] || '' const textVal = mapKeyToText(sku, key, origin) obj.visible = true obj.text = textVal obj['data-value'] = textVal }) } } } // 创建离屏 canvas const el = document.createElement('canvas') el.width = width el.height = height const fcanvas = new fabric.Canvas(el, { backgroundColor: bgColor, width, height, renderOnAddRemove: false, }) // 添加fabric事件监听器来调试图片加载 let loadedImageCount = 0 const totalImagePlaceholders = imgPlaceholders.filter(obj => obj.visible !== false).length // 重要:使用reviver函数确保dataURL图片正确加载 const reviver = (key, value, object) => { // 处理图片加载 if (key === 'src' && value && typeof value === 'string') { // 对于dataURL,确保fabric正确处理 return value } // 记录图片加载状态 if (key === 'type' && value === 'image' && object && object.src) { } return value } // 把已经替换好动态数据的 JSON 加载进 fabric; // loadFromJSON 会等所有图片资源加载完之后才调用回调函数 fcanvas.loadFromJSON(json, () => { try { // 检查所有加载的对象 const allObjects = fcanvas.getObjects() // 检查图片对象 const imageObjects = allObjects.filter(obj => obj.type === 'image') // 确保所有图片对象都正确渲染 fcanvas.getObjects().forEach(obj => { if (obj.type === 'image') { // 如果图片有clipPath,确保它正确应用 if (obj.clipPath) { obj.setCoords() } // 确保图片在canvas边界内正确显示 obj.setCoords() } }) fcanvas.renderAll() // 获取商品图宽高,并在导出时根据宽度做压缩控制 const MAX_WIDTH = 2048 // 默认导出倍数(原来为 2,保持清晰度) let exportMultiplier = 2 // 计算按当前导出倍数后的宽度 const expectedWidth = width * exportMultiplier if (expectedWidth > MAX_WIDTH) { // 根据最大宽度反推需要的缩放倍数(<= 1) exportMultiplier = MAX_WIDTH / width } const finalWidth = Math.round(width * exportMultiplier) const finalHeight = Math.round(height * exportMultiplier) const dataUrl = fcanvas.toDataURL({ format: 'jpeg', multiplier: exportMultiplier, enableRetinaScaling: true, }) fcanvas.dispose() resolve({ canvasIndex, dataUrl, width: finalWidth, height: finalHeight, }) } catch (e) { try { fcanvas.dispose() } catch (e2) { } resolve(null) } }, reviver) // 添加fabric错误处理 fcanvas.on('object:added', (e) => { }) } catch (e) { resolve(null) } }) } for (const plan of plans || []) { const canvasItem = canvasList[plan.canvasIndex] if (!canvasItem) continue for (const imgPlan of plan.images || []) { // eslint-disable-next-line no-await-in-loop const res = await renderOne(canvasItem, plan, imgPlan) if (res) { results.push(res) } else { } } } return results } /** * 针对每个款号(style),按画布生成所有图片,并额外生成「所有画布组合在一起」的一张长图。 * * @param {Array} canvasList 画布配置数组 * @param {Array} goodsList 原始商品数据数组 [goods, goods1, ...] * @returns {Promise, * combined?: { dataUrl: string, width: number, height: number } * }>>} */ export async function generateAllStyleImageBundles(canvasList, goodsList) { const bundles = [] // 工具:把若干 dataURL 竖向拼接为一张长图 // const composeCombinedImage = (images) => // new Promise((resolve) => { // const validImages = (images || []).filter( // img => img && typeof img.dataUrl === 'string' && img.dataUrl.startsWith('data:image') // ) // if (!validImages.length) return resolve(null) // Promise.all( // validImages.map(img => // new Promise(res => { // fabric.Image.fromURL( // img.dataUrl, // (oImg) => res(oImg), // { crossOrigin: 'anonymous' } // ) // }) // ) // ).then(fabricImages => { // const widths = fabricImages.map(i => i.width * (i.scaleX || 1)) // const heights = fabricImages.map(i => i.height * (i.scaleY || 1)) // const totalHeight = heights.reduce((a, b) => a + b, 0) // const maxWidth = widths.reduce((a, b) => Math.max(a, b), 0) // const el = document.createElement('canvas') // el.width = maxWidth // el.height = totalHeight // const canvas = new fabric.Canvas(el, { // backgroundColor: '#fff', // width: maxWidth, // height: totalHeight, // renderOnAddRemove: false, // }) // let currentTop = 0 // fabricImages.forEach((img, idx) => { // const w = widths[idx] // const h = heights[idx] // img.set({ // left: (maxWidth - w) / 2, // top: currentTop, // }) // currentTop += h // canvas.add(img) // }) // canvas.renderAll() // const dataUrl = canvas.toDataURL({ // format: 'jpeg', // multiplier:2, // enableRetinaScaling: true, // }) // const result = { dataUrl, width: maxWidth, height: totalHeight } // canvas.dispose() // resolve(result) // }).catch(() => resolve(null)) // }) // 在 generateAllStyleImageBundles 函数中修复 const composeCombinedImage = (images) => new Promise((resolve) => { const validImages = (images || []).filter( img => img && typeof img.dataUrl === 'string' && img.dataUrl.startsWith('data:image') ) if (!validImages.length) return resolve(null) // 确保有canvas元素 const el = document.createElement('canvas') if (!el) return resolve(null) const ctx = el.getContext('2d') if (!ctx) { el.remove() return resolve(null) } Promise.all( validImages.map(img => new Promise(res => { const tempImg = new Image() tempImg.crossOrigin = 'anonymous' tempImg.onload = () => res({ img: tempImg, width: tempImg.width, height: tempImg.height }) tempImg.onerror = () => res(null) tempImg.src = img.dataUrl }) ) ).then(imageInfos => { const validInfos = imageInfos.filter(info => info !== null) if (!validInfos.length) { el.remove() return resolve(null) } const widths = validInfos.map(info => info.width) const heights = validInfos.map(info => info.height) const totalHeight = heights.reduce((a, b) => a + b, 0) const maxWidth = widths.reduce((a, b) => Math.max(a, b), 0) el.width = maxWidth el.height = totalHeight ctx.fillStyle = '#fff' ctx.fillRect(0, 0, maxWidth, totalHeight) let currentTop = 0 validInfos.forEach((info, idx) => { const w = widths[idx] const h = heights[idx] const left = (maxWidth - w) / 2 ctx.drawImage(info.img, left, currentTop, w, h) currentTop += h }) const dataUrl = el.toDataURL('image/jpeg', 0.9) const result = { dataUrl, width: maxWidth, height: totalHeight } el.remove() resolve(result) }).catch((error) => { console.error('组合图片失败:', error) if (el) el.remove() resolve(null) }) }) // 按款号分组处理,避免不同款号的货号混在一起 for (const group of goodsList || []) { if (!group) continue for (const [styleKey, entry] of Object.entries(group)) { if (!entry || !Array.isArray(entry['货号资料'])) continue const styleNo = entry['款号'] || styleKey const styleGoodsList = [{ [styleKey]: entry }] const plans = buildRenderPlans(canvasList, styleGoodsList) const skus = normalizeGoods(styleGoodsList) if (!plans.length || !skus.length) continue // eslint-disable-next-line no-await-in-loop const images = await renderImagesByPlans(plans, canvasList, skus) if (!images.length) continue // 按 canvasIndex 排序,方便后续命名和组合 images.sort((a, b) => a.canvasIndex - b.canvasIndex) // 组合所有画布图片为一张长图(可以根据需要选择只取每个画布的第一张等) const combined = await composeCombinedImage(images) bundles.push({ styleKey, styleNo, images, combined, }) } } return bundles } /** * 轻量入口:直接给模板列表与商品数据,返回每张图的 base64。 * 不做文件落地,也不做长图拼接,便于外部(如 Python 调用)直接获取切片。 * * @param {Array} canvasList 画布配置数组 * @param {Array} goodsList 商品数据数组 * @returns {Promise<{plans:Array, images:Array<{canvasIndex:number,dataUrl:string}>}>} */ export async function generateImagesBase64(canvasList = [], goodsList = []) { const skus = normalizeGoods(goodsList) const plans = buildRenderPlans(canvasList, goodsList) const images = await renderImagesByPlans(plans, canvasList, skus) return { images } }