generateImagesRender.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
  1. import fabric from '../PictureEditor/js/fabric-adapter'
  2. import { buildRenderPlans, normalizeGoods } from './generateImagesPlan'
  3. /**
  4. * 根据渲染计划和拍平后的货号数据,真正生成图片(dataURL),按 canvasIndex 返回。
  5. *
  6. * 注意:
  7. * - 不做任何上传,只返回 dataURL,方便在 EXE 或其他环境里落地到本地文件。
  8. * - 需要在外部保证 canvasList / plans / skus 的来源一致。
  9. *
  10. * @param {Array} plans 由 buildRenderPlans 生成的渲染计划
  11. * @param {Array} canvasList 画布配置数组(包含 canvas_json / width / height / bg_color 等)
  12. * @param {Array} skus normalizeGoods(goodsList) 的结果
  13. * @returns {Promise<Array<{canvasIndex:number,dataUrl:string}>>}
  14. */
  15. // export async function renderImagesByPlans(plans, canvasList, skus) {
  16. // const results = []
  17. // // 工具:从 canvas_json 创建一个离屏 fabric.Canvas,并应用一次性的渲染逻辑
  18. // const renderOne = (canvasItem, planForCanvas, imagePlan) =>
  19. // new Promise((resolve) => {
  20. // if (!canvasItem) {
  21. // return resolve(null)
  22. // }
  23. // const { canvasIndex } = planForCanvas
  24. // const { skuIndexes } = imagePlan
  25. // // 模特/场景占位:直接返回类型,不渲染
  26. // if (canvasItem.canvas_json === 'model') {
  27. // return resolve({
  28. // canvasIndex,
  29. // dataUrl: 'model',
  30. // })
  31. // }
  32. // if (canvasItem.canvas_json === 'scene') {
  33. // return resolve({
  34. // canvasIndex,
  35. // dataUrl: 'scene',
  36. // })
  37. // }
  38. // // 解析 JSON 为可修改的对象
  39. // let json
  40. // try {
  41. // json = typeof canvasItem.canvas_json === 'string'
  42. // ? JSON.parse(canvasItem.canvas_json)
  43. // : JSON.parse(JSON.stringify(canvasItem.canvas_json))
  44. // } catch (e) {
  45. // return resolve(null)
  46. // }
  47. // const width = Number(canvasItem.width) || 395
  48. // const height = Number(canvasItem.height) || 600
  49. // const bgColor = canvasItem.bg_color || '#fff'
  50. // try {
  51. // const mode = canvasItem.multi_goods_mode || ''
  52. // const perCanvasSlots = planForCanvas.perCanvasSlots || 1
  53. // const usedSkus = (skuIndexes || []).map((idx) => (idx != null ? skus[idx] : null))
  54. // // 先在原始 JSON 上做数据替换,避免 setSrc 的异步问题;
  55. // // loadFromJSON 会在所有图片加载完成后才触发回调。
  56. // const objs = (json && Array.isArray(json.objects)) ? json.objects : []
  57. // // 1) 处理图片占位(data-type = img)
  58. // const imgPlaceholders = objs.filter((o) => o && o['data-type'] === 'img')
  59. // if (mode === 'multiple') {
  60. // imgPlaceholders.forEach((obj, idx) => {
  61. // const slotIndex = idx % perCanvasSlots
  62. // const sku = usedSkus[slotIndex]
  63. // const angleKey = obj['data-key']
  64. // if (!sku) {
  65. // obj.visible = false
  66. // return
  67. // }
  68. // const url = (sku.pics && sku.pics[angleKey]) || ''
  69. // if (!url) {
  70. // obj.visible = false
  71. // return
  72. // }
  73. // obj.visible = true
  74. // obj['data-value'] = url
  75. // obj.src = url
  76. // })
  77. // } else {
  78. // // 默认 / single:一个货号多角度,一张图只用一个货号
  79. // const sku = usedSkus[0]
  80. // imgPlaceholders.forEach((obj) => {
  81. // if (!sku) {
  82. // obj.visible = false
  83. // return
  84. // }
  85. // const angleKey = obj['data-key']
  86. // const url = (sku.pics && sku.pics[angleKey]) || ''
  87. // if (!url) {
  88. // obj.visible = false
  89. // return
  90. // }
  91. // obj.visible = true
  92. // obj['data-value'] = url
  93. // obj.src = url
  94. // })
  95. // }
  96. // // 2) 处理文字占位(data-type = text)
  97. // const textPlaceholders = objs.filter((o) => o && o['data-type'] === 'text')
  98. // if (textPlaceholders.length) {
  99. // // 通用的 key -> 文本 映射函数,
  100. // const mapKeyToText = (sku, key, defaultVal) => {
  101. // if (!sku) return defaultVal
  102. // let textVal = defaultVal || ''
  103. // if (key === '颜色') {
  104. // textVal = sku.color || textVal
  105. // } else if (key === '货号') {
  106. // textVal = sku.sku || textVal
  107. // }
  108. // // 兜底:去 raw 里找同名字段(支持 卖点 / 使用场景 / 其他自定义字段)
  109. // if ((!textVal || textVal === defaultVal) && sku.raw && sku.raw[key] != null) {
  110. // textVal = sku.raw[key]
  111. // }
  112. // // 再兜底:如果 sku 上有同名字段
  113. // if ((!textVal || textVal === defaultVal) && sku[key] != null) {
  114. // textVal = sku[key]
  115. // }
  116. // return textVal
  117. // }
  118. // if (mode === 'multiple') {
  119. // // 多个货号同角度:
  120. // // - 按 data-key + 出现顺序把文字分配给不同货号
  121. // // - 如果该 slot 没有对应货号(usedSkus[slotIndex] 为 null),则隐藏该文字层
  122. // const keyCounter = {}
  123. // textPlaceholders.forEach((obj) => {
  124. // const key = obj['data-key']
  125. // if (!key) return
  126. // const idxForKey = keyCounter[key] || 0
  127. // keyCounter[key] = idxForKey + 1
  128. // const slotIndex = idxForKey % perCanvasSlots
  129. // const sku = usedSkus[slotIndex]
  130. // if (!sku) {
  131. // obj.visible = false
  132. // return
  133. // }
  134. // const origin = obj['data-value'] || ''
  135. // const textVal = mapKeyToText(sku, key, origin)
  136. // obj.visible = true
  137. // obj.text = textVal
  138. // obj['data-value'] = textVal
  139. // })
  140. // } else {
  141. // // 默认 / single:全部文字都使用同一个货号(默认模式只生成 1 张,用第一个货号)
  142. // const sku = usedSkus[0]
  143. // if (sku) {
  144. // textPlaceholders.forEach((obj) => {
  145. // const key = obj['data-key']
  146. // if (!key) return
  147. // const origin = obj['data-value'] || ''
  148. // const textVal = mapKeyToText(sku, key, origin)
  149. // obj.visible = true
  150. // obj.text = textVal
  151. // obj['data-value'] = textVal
  152. // })
  153. // }
  154. // }
  155. // }
  156. // // 创建离屏 canvas
  157. // const el = document.createElement('canvas')
  158. // el.width = width
  159. // el.height = height
  160. // const fcanvas = new fabric.Canvas(el, {
  161. // backgroundColor: bgColor,
  162. // width,
  163. // height,
  164. // renderOnAddRemove: false,
  165. // })
  166. // // 把已经替换好动态数据的 JSON 加载进 fabric;
  167. // // loadFromJSON 会等所有图片资源加载完之后才调用回调函数
  168. // fcanvas.loadFromJSON(json, () => {
  169. // try {
  170. // fcanvas.renderAll()
  171. // // 获取商品图宽高,并在导出时根据宽度做压缩控制
  172. // const MAX_WIDTH = 2048
  173. // // 默认导出倍数(原来为 2,保持清晰度)
  174. // let exportMultiplier = 2
  175. // // 计算按当前导出倍数后的宽度
  176. // const expectedWidth = width * exportMultiplier
  177. // if (expectedWidth > MAX_WIDTH) {
  178. // // 根据最大宽度反推需要的缩放倍数(<= 1)
  179. // exportMultiplier = MAX_WIDTH / width
  180. // }
  181. // const finalWidth = Math.round(width * exportMultiplier)
  182. // const finalHeight = Math.round(height * exportMultiplier)
  183. // const dataUrl = fcanvas.toDataURL({
  184. // format: 'jpeg',
  185. // multiplier: exportMultiplier,
  186. // enableRetinaScaling: true,
  187. // })
  188. // fcanvas.dispose()
  189. // resolve({
  190. // canvasIndex,
  191. // dataUrl,
  192. // width: finalWidth,
  193. // height: finalHeight,
  194. // })
  195. // } catch (e) {
  196. // try {
  197. // fcanvas.dispose()
  198. // } catch (e2) {}
  199. // resolve(null)
  200. // }
  201. // })
  202. // } catch (e) {
  203. // resolve(null)
  204. // }
  205. // })
  206. // for (const plan of plans || []) {
  207. // const canvasItem = canvasList[plan.canvasIndex]
  208. // if (!canvasItem) continue
  209. // for (const imgPlan of plan.images || []) {
  210. // // eslint-disable-next-line no-await-in-loop
  211. // const res = await renderOne(canvasItem, plan, imgPlan)
  212. // if (res) results.push(res)
  213. // }
  214. // }
  215. // return results
  216. // }
  217. export async function renderImagesByPlans(plans, canvasList, skus) {
  218. const results = []
  219. // 工具函数:只检查并压缩图片,返回压缩后的图片URL
  220. const compressImageIfNeeded = (url, maxWidth = 2048) => {
  221. return new Promise((resolve) => {
  222. if (!url) {
  223. resolve(null)
  224. return
  225. }
  226. const img = new Image()
  227. img.crossOrigin = 'anonymous'
  228. img.onload = () => {
  229. // 检查图片是否需要压缩
  230. if (img.width <= maxWidth) {
  231. resolve(url)
  232. return
  233. }
  234. // 计算压缩后的尺寸(等比例压缩)
  235. const scale = maxWidth / img.width
  236. const newWidth = maxWidth
  237. const newHeight = Math.round(img.height * scale)
  238. // 创建canvas进行压缩
  239. const canvas = document.createElement('canvas')
  240. canvas.width = newWidth
  241. canvas.height = newHeight
  242. const ctx = canvas.getContext('2d')
  243. if (!ctx) {
  244. console.error('无法获取canvas 2d上下文')
  245. resolve(url)
  246. return
  247. }
  248. // 重要:设置canvas背景为透明
  249. // 方法1:清除画布,设置为透明
  250. // ctx.clearRect(0, 0, newWidth, newHeight)
  251. // 方法2:或者使用透明矩形填充
  252. ctx.fillStyle = 'rgba(0, 0, 0, 0)'
  253. ctx.fillRect(0, 0, newWidth, newHeight)
  254. // 设置高质量压缩参数
  255. ctx.imageSmoothingEnabled = true
  256. ctx.imageSmoothingQuality = 'high'
  257. // 绘制压缩后的图片
  258. ctx.drawImage(img, 0, 0, newWidth, newHeight)
  259. // 返回压缩后的dataURL,使用PNG格式保持透明通道(如果原图有透明背景)
  260. // 但如果是JPG商品图,通常没有透明通道,也可以用PNG
  261. const dataUrl = canvas.toDataURL('image/png') // 使用PNG确保无黑色背景
  262. canvas.remove()
  263. resolve(dataUrl)
  264. }
  265. img.onerror = (error) => {
  266. resolve(url) // 返回原始URL作为fallback
  267. }
  268. img.src = url
  269. })
  270. }
  271. // 工具:从 canvas_json 创建一个离屏 fabric.Canvas,并应用一次性的渲染逻辑
  272. const renderOne = async (canvasItem, planForCanvas, imagePlan) => {
  273. return new Promise(async (resolve) => {
  274. if (!canvasItem) {
  275. return resolve(null)
  276. }
  277. const { canvasIndex } = planForCanvas
  278. const { skuIndexes } = imagePlan
  279. // 模特/场景占位:直接返回类型,不渲染
  280. if (canvasItem.canvas_json === 'model') {
  281. return resolve({
  282. canvasIndex,
  283. dataUrl: 'model',
  284. })
  285. }
  286. if (canvasItem.canvas_json === 'scene') {
  287. return resolve({
  288. canvasIndex,
  289. dataUrl: 'scene',
  290. })
  291. }
  292. // 解析 JSON 为可修改的对象
  293. let json
  294. try {
  295. json = typeof canvasItem.canvas_json === 'string'
  296. ? JSON.parse(canvasItem.canvas_json)
  297. : JSON.parse(JSON.stringify(canvasItem.canvas_json))
  298. } catch (e) {
  299. return resolve(null)
  300. }
  301. const width = Number(canvasItem.width) || 395
  302. const height = Number(canvasItem.height) || 600
  303. const bgColor = canvasItem.bg_color || '#fff'
  304. try {
  305. const mode = canvasItem.multi_goods_mode || ''
  306. const perCanvasSlots = planForCanvas.perCanvasSlots || 1
  307. const usedSkus = (skuIndexes || []).map((idx) => (idx != null ? skus[idx] : null))
  308. // 获取画布对象
  309. const objs = (json && Array.isArray(json.objects)) ? json.objects : []
  310. // 1) 处理图片占位(data-type = img)
  311. const imgPlaceholders = objs.filter((o) => o && o['data-type'] === 'img')
  312. // 为每个图片占位生成压缩后的URL
  313. const imageUrlMap = new Map()
  314. // 收集所有需要压缩的图片URL
  315. const urlsToCompress = []
  316. if (mode === 'multiple') {
  317. imgPlaceholders.forEach((obj, idx) => {
  318. const slotIndex = idx % perCanvasSlots
  319. const sku = usedSkus[slotIndex]
  320. const angleKey = obj['data-key']
  321. if (!sku) {
  322. return
  323. }
  324. const url = (sku.pics && sku.pics[angleKey]) || ''
  325. if (!url) {
  326. return
  327. }
  328. urlsToCompress.push({ url, objId: `${idx}_${angleKey}`, originalObj: obj, angleKey, skuIndex: slotIndex })
  329. })
  330. } else {
  331. // 默认 / single:一个货号多角度,一张图只用一个货号
  332. const sku = usedSkus[0]
  333. if (!sku) {
  334. }
  335. imgPlaceholders.forEach((obj, idx) => {
  336. if (!sku) return
  337. const angleKey = obj['data-key']
  338. const url = (sku.pics && sku.pics[angleKey]) || ''
  339. if (!url) {
  340. return
  341. }
  342. urlsToCompress.push({ url, objId: `${idx}_${angleKey}`, originalObj: obj, angleKey })
  343. })
  344. }
  345. // 并发压缩所有图片
  346. const compressResults = await Promise.all(
  347. urlsToCompress.map(async ({ url, objId, originalObj, angleKey }) => {
  348. try {
  349. const compressedUrl = await compressImageIfNeeded(url , originalObj.width)
  350. return { objId, compressedUrl, originalObj, angleKey }
  351. } catch (e) {
  352. return { objId, compressedUrl: null, originalObj, angleKey }
  353. }
  354. })
  355. )
  356. // 更新imageUrlMap
  357. compressResults.forEach(({ objId, compressedUrl }) => {
  358. if (compressedUrl) {
  359. imageUrlMap.set(objId, compressedUrl)
  360. }
  361. })
  362. // 更新JSON中的图片URL
  363. if (mode === 'multiple') {
  364. imgPlaceholders.forEach((obj, idx) => {
  365. const slotIndex = idx % perCanvasSlots
  366. const sku = usedSkus[slotIndex]
  367. const angleKey = obj['data-key']
  368. if (!sku) {
  369. obj.visible = false
  370. return
  371. }
  372. const objId = `${idx}_${angleKey}`
  373. const compressedUrl = imageUrlMap.get(objId)
  374. if (compressedUrl) {
  375. obj.visible = true
  376. obj['data-value'] = compressedUrl
  377. obj.src = compressedUrl
  378. } else {
  379. const url = (sku.pics && sku.pics[angleKey]) || ''
  380. if (!url) {
  381. obj.visible = false
  382. return
  383. }
  384. obj.visible = true
  385. obj['data-value'] = url
  386. obj.src = url
  387. }
  388. })
  389. } else {
  390. // 默认 / single:一个货号多角度,一张图只用一个货号
  391. const sku = usedSkus[0]
  392. imgPlaceholders.forEach((obj, idx) => {
  393. if (!sku) {
  394. obj.visible = false
  395. return
  396. }
  397. const angleKey = obj['data-key']
  398. const objId = `${idx}_${angleKey}`
  399. const compressedUrl = imageUrlMap.get(objId)
  400. if (compressedUrl) {
  401. obj.visible = true
  402. obj['data-value'] = compressedUrl
  403. obj.src = compressedUrl
  404. } else {
  405. const url = (sku.pics && sku.pics[angleKey]) || ''
  406. if (!url) {
  407. obj.visible = false
  408. return
  409. }
  410. obj.visible = true
  411. obj['data-value'] = url
  412. obj.src = url
  413. }
  414. })
  415. }
  416. // 2) 处理文字占位(data-type = text)
  417. const textPlaceholders = objs.filter((o) => o && o['data-type'] === 'text')
  418. if (textPlaceholders.length) {
  419. // 通用的 key -> 文本 映射函数,
  420. const mapKeyToText = (sku, key, defaultVal) => {
  421. if (!sku) return defaultVal
  422. let textVal = defaultVal || ''
  423. if (key === '颜色') {
  424. textVal = sku.color || textVal
  425. } else if (key === '货号') {
  426. textVal = sku.sku || textVal
  427. }
  428. // 兜底:去 raw 里找同名字段(支持 卖点 / 使用场景 / 其他自定义字段)
  429. if ((!textVal || textVal === defaultVal) && sku.raw && sku.raw[key] != null) {
  430. textVal = sku.raw[key]
  431. }
  432. // 再兜底:如果 sku 上有同名字段
  433. if ((!textVal || textVal === defaultVal) && sku[key] != null) {
  434. textVal = sku[key]
  435. }
  436. return textVal
  437. }
  438. if (mode === 'multiple') {
  439. // 多个货号同角度:
  440. // - 按 data-key + 出现顺序把文字分配给不同货号
  441. // - 如果该 slot 没有对应货号(usedSkus[slotIndex] 为 null),则隐藏该文字层
  442. const keyCounter = {}
  443. textPlaceholders.forEach((obj) => {
  444. const key = obj['data-key']
  445. if (!key) return
  446. const idxForKey = keyCounter[key] || 0
  447. keyCounter[key] = idxForKey + 1
  448. const slotIndex = idxForKey % perCanvasSlots
  449. const sku = usedSkus[slotIndex]
  450. if (!sku) {
  451. obj.visible = false
  452. return
  453. }
  454. const origin = obj['data-value'] || ''
  455. const textVal = mapKeyToText(sku, key, origin)
  456. obj.visible = true
  457. obj.text = textVal
  458. obj['data-value'] = textVal
  459. })
  460. } else {
  461. // 默认 / single:全部文字都使用同一个货号(默认模式只生成 1 张,用第一个货号)
  462. const sku = usedSkus[0]
  463. if (sku) {
  464. textPlaceholders.forEach((obj) => {
  465. const key = obj['data-key']
  466. if (!key) return
  467. const origin = obj['data-value'] || ''
  468. const textVal = mapKeyToText(sku, key, origin)
  469. obj.visible = true
  470. obj.text = textVal
  471. obj['data-value'] = textVal
  472. })
  473. }
  474. }
  475. }
  476. // 创建离屏 canvas
  477. const el = document.createElement('canvas')
  478. el.width = width
  479. el.height = height
  480. const fcanvas = new fabric.Canvas(el, {
  481. backgroundColor: bgColor,
  482. width,
  483. height,
  484. renderOnAddRemove: false,
  485. })
  486. // 添加fabric事件监听器来调试图片加载
  487. let loadedImageCount = 0
  488. const totalImagePlaceholders = imgPlaceholders.filter(obj => obj.visible !== false).length
  489. // 重要:使用reviver函数确保dataURL图片正确加载
  490. const reviver = (key, value, object) => {
  491. // 处理图片加载
  492. if (key === 'src' && value && typeof value === 'string') {
  493. // 对于dataURL,确保fabric正确处理
  494. return value
  495. }
  496. // 记录图片加载状态
  497. if (key === 'type' && value === 'image' && object && object.src) {
  498. }
  499. return value
  500. }
  501. // 把已经替换好动态数据的 JSON 加载进 fabric;
  502. // loadFromJSON 会等所有图片资源加载完之后才调用回调函数
  503. fcanvas.loadFromJSON(json, () => {
  504. try {
  505. // 检查所有加载的对象
  506. const allObjects = fcanvas.getObjects()
  507. // 检查图片对象
  508. const imageObjects = allObjects.filter(obj => obj.type === 'image')
  509. // 确保所有图片对象都正确渲染
  510. fcanvas.getObjects().forEach(obj => {
  511. if (obj.type === 'image') {
  512. // 如果图片有clipPath,确保它正确应用
  513. if (obj.clipPath) {
  514. obj.setCoords()
  515. }
  516. // 确保图片在canvas边界内正确显示
  517. obj.setCoords()
  518. }
  519. })
  520. fcanvas.renderAll()
  521. // 获取商品图宽高,并在导出时根据宽度做压缩控制
  522. const MAX_WIDTH = 2048
  523. // 默认导出倍数(原来为 2,保持清晰度)
  524. let exportMultiplier = 2
  525. // 计算按当前导出倍数后的宽度
  526. const expectedWidth = width * exportMultiplier
  527. if (expectedWidth > MAX_WIDTH) {
  528. // 根据最大宽度反推需要的缩放倍数(<= 1)
  529. exportMultiplier = MAX_WIDTH / width
  530. }
  531. const finalWidth = Math.round(width * exportMultiplier)
  532. const finalHeight = Math.round(height * exportMultiplier)
  533. const dataUrl = fcanvas.toDataURL({
  534. format: 'jpeg',
  535. multiplier: exportMultiplier,
  536. enableRetinaScaling: true,
  537. })
  538. fcanvas.dispose()
  539. resolve({
  540. canvasIndex,
  541. dataUrl,
  542. width: finalWidth,
  543. height: finalHeight,
  544. })
  545. } catch (e) {
  546. try {
  547. fcanvas.dispose()
  548. } catch (e2) {
  549. }
  550. resolve(null)
  551. }
  552. }, reviver)
  553. // 添加fabric错误处理
  554. fcanvas.on('object:added', (e) => {
  555. })
  556. } catch (e) {
  557. resolve(null)
  558. }
  559. })
  560. }
  561. for (const plan of plans || []) {
  562. const canvasItem = canvasList[plan.canvasIndex]
  563. if (!canvasItem) continue
  564. for (const imgPlan of plan.images || []) {
  565. // eslint-disable-next-line no-await-in-loop
  566. const res = await renderOne(canvasItem, plan, imgPlan)
  567. if (res) {
  568. results.push(res)
  569. } else {
  570. }
  571. }
  572. }
  573. return results
  574. }
  575. /**
  576. * 针对每个款号(style),按画布生成所有图片,并额外生成「所有画布组合在一起」的一张长图。
  577. *
  578. * @param {Array} canvasList 画布配置数组
  579. * @param {Array} goodsList 原始商品数据数组 [goods, goods1, ...]
  580. * @returns {Promise<Array<{
  581. * styleKey: string,
  582. * styleNo: string,
  583. * images: Array<{canvasIndex:number,dataUrl:string}>,
  584. * combined?: { dataUrl: string, width: number, height: number }
  585. * }>>}
  586. */
  587. export async function generateAllStyleImageBundles(canvasList, goodsList) {
  588. const bundles = []
  589. // 工具:把若干 dataURL 竖向拼接为一张长图
  590. // const composeCombinedImage = (images) =>
  591. // new Promise((resolve) => {
  592. // const validImages = (images || []).filter(
  593. // img => img && typeof img.dataUrl === 'string' && img.dataUrl.startsWith('data:image')
  594. // )
  595. // if (!validImages.length) return resolve(null)
  596. // Promise.all(
  597. // validImages.map(img =>
  598. // new Promise(res => {
  599. // fabric.Image.fromURL(
  600. // img.dataUrl,
  601. // (oImg) => res(oImg),
  602. // { crossOrigin: 'anonymous' }
  603. // )
  604. // })
  605. // )
  606. // ).then(fabricImages => {
  607. // const widths = fabricImages.map(i => i.width * (i.scaleX || 1))
  608. // const heights = fabricImages.map(i => i.height * (i.scaleY || 1))
  609. // const totalHeight = heights.reduce((a, b) => a + b, 0)
  610. // const maxWidth = widths.reduce((a, b) => Math.max(a, b), 0)
  611. // const el = document.createElement('canvas')
  612. // el.width = maxWidth
  613. // el.height = totalHeight
  614. // const canvas = new fabric.Canvas(el, {
  615. // backgroundColor: '#fff',
  616. // width: maxWidth,
  617. // height: totalHeight,
  618. // renderOnAddRemove: false,
  619. // })
  620. // let currentTop = 0
  621. // fabricImages.forEach((img, idx) => {
  622. // const w = widths[idx]
  623. // const h = heights[idx]
  624. // img.set({
  625. // left: (maxWidth - w) / 2,
  626. // top: currentTop,
  627. // })
  628. // currentTop += h
  629. // canvas.add(img)
  630. // })
  631. // canvas.renderAll()
  632. // const dataUrl = canvas.toDataURL({
  633. // format: 'jpeg',
  634. // multiplier:2,
  635. // enableRetinaScaling: true,
  636. // })
  637. // const result = { dataUrl, width: maxWidth, height: totalHeight }
  638. // canvas.dispose()
  639. // resolve(result)
  640. // }).catch(() => resolve(null))
  641. // })
  642. // 在 generateAllStyleImageBundles 函数中修复
  643. const composeCombinedImage = (images) =>
  644. new Promise((resolve) => {
  645. const validImages = (images || []).filter(
  646. img => img && typeof img.dataUrl === 'string' && img.dataUrl.startsWith('data:image')
  647. )
  648. if (!validImages.length) return resolve(null)
  649. // 确保有canvas元素
  650. const el = document.createElement('canvas')
  651. if (!el) return resolve(null)
  652. const ctx = el.getContext('2d')
  653. if (!ctx) {
  654. el.remove()
  655. return resolve(null)
  656. }
  657. Promise.all(
  658. validImages.map(img =>
  659. new Promise(res => {
  660. const tempImg = new Image()
  661. tempImg.crossOrigin = 'anonymous'
  662. tempImg.onload = () => res({ img: tempImg, width: tempImg.width, height: tempImg.height })
  663. tempImg.onerror = () => res(null)
  664. tempImg.src = img.dataUrl
  665. })
  666. )
  667. ).then(imageInfos => {
  668. const validInfos = imageInfos.filter(info => info !== null)
  669. if (!validInfos.length) {
  670. el.remove()
  671. return resolve(null)
  672. }
  673. const widths = validInfos.map(info => info.width)
  674. const heights = validInfos.map(info => info.height)
  675. const totalHeight = heights.reduce((a, b) => a + b, 0)
  676. const maxWidth = widths.reduce((a, b) => Math.max(a, b), 0)
  677. el.width = maxWidth
  678. el.height = totalHeight
  679. ctx.fillStyle = '#fff'
  680. ctx.fillRect(0, 0, maxWidth, totalHeight)
  681. let currentTop = 0
  682. validInfos.forEach((info, idx) => {
  683. const w = widths[idx]
  684. const h = heights[idx]
  685. const left = (maxWidth - w) / 2
  686. ctx.drawImage(info.img, left, currentTop, w, h)
  687. currentTop += h
  688. })
  689. const dataUrl = el.toDataURL('image/jpeg', 0.9)
  690. const result = { dataUrl, width: maxWidth, height: totalHeight }
  691. el.remove()
  692. resolve(result)
  693. }).catch((error) => {
  694. console.error('组合图片失败:', error)
  695. if (el) el.remove()
  696. resolve(null)
  697. })
  698. })
  699. // 按款号分组处理,避免不同款号的货号混在一起
  700. for (const group of goodsList || []) {
  701. if (!group) continue
  702. for (const [styleKey, entry] of Object.entries(group)) {
  703. if (!entry || !Array.isArray(entry['货号资料'])) continue
  704. const styleNo = entry['款号'] || styleKey
  705. const styleGoodsList = [{ [styleKey]: entry }]
  706. const plans = buildRenderPlans(canvasList, styleGoodsList)
  707. const skus = normalizeGoods(styleGoodsList)
  708. if (!plans.length || !skus.length) continue
  709. // eslint-disable-next-line no-await-in-loop
  710. const images = await renderImagesByPlans(plans, canvasList, skus)
  711. if (!images.length) continue
  712. // 按 canvasIndex 排序,方便后续命名和组合
  713. images.sort((a, b) => a.canvasIndex - b.canvasIndex)
  714. // 组合所有画布图片为一张长图(可以根据需要选择只取每个画布的第一张等)
  715. const combined = await composeCombinedImage(images)
  716. bundles.push({
  717. styleKey,
  718. styleNo,
  719. images,
  720. combined,
  721. })
  722. }
  723. }
  724. return bundles
  725. }
  726. /**
  727. * 轻量入口:直接给模板列表与商品数据,返回每张图的 base64。
  728. * 不做文件落地,也不做长图拼接,便于外部(如 Python 调用)直接获取切片。
  729. *
  730. * @param {Array} canvasList 画布配置数组
  731. * @param {Array} goodsList 商品数据数组
  732. * @returns {Promise<{plans:Array, images:Array<{canvasIndex:number,dataUrl:string}>}>}
  733. */
  734. export async function generateImagesBase64(canvasList = [], goodsList = []) {
  735. const skus = normalizeGoods(goodsList)
  736. const plans = buildRenderPlans(canvasList, goodsList)
  737. const images = await renderImagesByPlans(plans, canvasList, skus)
  738. return { images }
  739. }