Ver Fonte

feat(marketing): 添加字体加载和图片压缩功能

- 集成字体配置文件,实现动态字体加载和检查功能
- 添加图片压缩逻辑,优化图片渲染性能
- 实现文本占位符的字体属性默认值设置
- 添加商品文案数据去重逻辑,避免重复渲染
- 优化画布渲染流程,提升图片加载成功率
panqiuyao há 1 dia atrás
pai
commit
e79defb117

+ 13 - 1
frontend/src/views/Tpl/Edit/index.vue

@@ -68,7 +68,19 @@ const defaultGoodsText = computed(() => {
   console.log(templateDataCache.value)
   if (templateDataCache.value?.template_excel_headers && Array.isArray(templateDataCache.value.template_excel_headers)) {
     // 缓存中已经是完整的商品文案对象结构,直接使用
-    return [...defaultGoodsTextFallback,...templateDataCache.value.template_excel_headers]
+    // 合并时去重,基于key字段
+    const allItems = [...defaultGoodsTextFallback,...templateDataCache.value.template_excel_headers]
+    const uniqueItems = []
+    const seenKeys = new Set()
+    
+    for (const item of allItems) {
+      if (!seenKeys.has(item.key)) {
+        seenKeys.add(item.key)
+        uniqueItems.push(item)
+      }
+    }
+    
+    return uniqueItems
   }
   // 如果没有缓存数据,使用默认值
   return defaultGoodsTextFallback

+ 140 - 50
frontend/src/views/components/marketingEdit/generateImagesRender.js

@@ -1,5 +1,6 @@
 import fabric from '../PictureEditor/js/fabric-adapter'
 import { buildRenderPlans, normalizeGoods } from './generateImagesPlan'
+import fontConfig from '../PictureEditor/mixin/edit/module/font.json'
 
 /**
  * 根据渲染计划和拍平后的货号数据,真正生成图片(dataURL),按 canvasIndex 返回。
@@ -250,7 +251,7 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
   // 工具函数:只检查并压缩图片,返回压缩后的图片URL
   const compressImageIfNeeded = (url, maxWidth = 2048) => {
     return new Promise((resolve) => {
-      
+
       if (!url) {
         resolve(null)
         return
@@ -258,9 +259,9 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
 
       const img = new Image()
       img.crossOrigin = 'anonymous'
-      
+
       img.onload = () => {
-        
+
         // 检查图片是否需要压缩
         if (img.width <= maxWidth) {
           resolve(url)
@@ -275,43 +276,43 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
         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
     })
   }
@@ -319,7 +320,7 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
   // 工具:从 canvas_json 创建一个离屏 fabric.Canvas,并应用一次性的渲染逻辑
   const renderOne = async (canvasItem, planForCanvas, imagePlan) => {
 
-    
+
     return new Promise(async (resolve) => {
       if (!canvasItem) {
         return resolve(null)
@@ -327,7 +328,7 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
 
       const { canvasIndex } = planForCanvas
       const { skuIndexes } = imagePlan
-      
+
 
       // 模特/场景占位:直接返回类型,不渲染
       if (canvasItem.canvas_json === 'model') {
@@ -357,13 +358,13 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
       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))
-        
+
 
 
         // 获取画布对象
@@ -371,30 +372,30 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
 
         // 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 {
@@ -402,21 +403,21 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
           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 }) => {
@@ -443,15 +444,15 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
             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
@@ -475,11 +476,11 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
               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
@@ -501,7 +502,74 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
         const textPlaceholders = objs.filter((o) => o && o['data-key'] && (o['data-type'] === 'text' || o['type'] === "textbox"))
         console.log('=====textPlaceholders========', textPlaceholders)
 
+        // 字体加载工具函数
+        const loadFont = (_fontName, _fontUrl) => {
+          return new Promise((resolve, reject) => {
+            if (checkFont(_fontName)) {
+              console.log('已有字体:', _fontName)
+              return resolve(true)
+            }
+
+            let prefont = new FontFace(
+              _fontName,
+              'url(' + _fontUrl + ')'
+            );
+
+            prefont.load().then(function (loaded_face) {
+              document.fonts.add(loaded_face);
+              console.log('字体加载成功', loaded_face, document.fonts)
+              return resolve(true)
+            }).catch(function (error) {
+              console.log('字体加载失败', error)
+              return reject(error)
+            })
+          })
+        }
+
+        const checkFont = (name) => {
+          let values = document.fonts.values();
+          let isHave = false;
+          let item = values.next();
+          while(!item.done && !isHave) {
+            let fontFace = item.value;
+            if(fontFace.family == name) {
+              isHave = true;
+            }
+            item = values.next();
+          }
+          return isHave;
+        }
+
+        // 根据fontFamily查找字体URL
+        const findFontUrl = (fontFamily) => {
+          const font = fontConfig.find(f => f.fontFamily === fontFamily || f.name === fontFamily)
+          return font ? font.src : null
+        }
+
         if (textPlaceholders.length) {
+          // 收集所有需要加载的字体
+          const fontsToLoad = new Map()
+
+          // 首先收集字体信息
+          textPlaceholders.forEach((obj) => {
+            const fontFamily = obj.fontFamily
+            if (fontFamily && fontFamily !== 'Arial') {
+              const fontUrl = findFontUrl(fontFamily)
+              if (fontUrl) {
+                fontsToLoad.set(fontFamily, fontUrl)
+              }
+            }
+          })
+
+          // 加载所有需要的字体
+          const fontLoadPromises = Array.from(fontsToLoad.entries()).map(([fontName, fontUrl]) => {
+            console.log('需要加载字体:', fontName, fontUrl)
+            return loadFont(fontName, fontUrl)
+          })
+
+          // 等待所有字体加载完成
+          await Promise.all(fontLoadPromises)
+
           // 通用的 key -> 文本 映射函数,
           const mapKeyToText = (sku, key, defaultVal) => {
             if (!sku) return defaultVal
@@ -553,6 +621,17 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
               obj.visible = true
               obj.text = textVal || ''
               obj['data-value'] = textVal || ''
+
+              // 确保字体属性被正确应用
+              // 保留原始的字体设置,如果没有则设置默认值
+              if (!obj.fontFamily) obj.fontFamily = 'Arial'
+              if (!obj.fontSize || obj.fontSize <= 0) obj.fontSize = 16
+              if (!obj.fill) obj.fill = '#000000'
+
+              // 保留其他字体属性
+              if (obj.fontWeight === undefined) obj.fontWeight = 'normal'
+              if (obj.fontStyle === undefined) obj.fontStyle = 'normal'
+              if (obj.textAlign === undefined) obj.textAlign = 'left'
             })
           } else {
             // 默认 / single:全部文字都使用同一个货号(默认模式只生成 1 张,用第一个货号)
@@ -567,6 +646,17 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
                 obj.visible = true
                 obj.text = textVal || ''
                 obj['data-value'] = textVal || ''
+
+                // 确保字体属性被正确应用
+                // 保留原始的字体设置,如果没有则设置默认值
+                if (!obj.fontFamily) obj.fontFamily = 'Arial'
+                if (!obj.fontSize || obj.fontSize <= 0) obj.fontSize = 16
+                if (!obj.fill) obj.fill = '#000000'
+
+                // 保留其他字体属性
+                if (obj.fontWeight === undefined) obj.fontWeight = 'normal'
+                if (obj.fontStyle === undefined) obj.fontStyle = 'normal'
+                if (obj.textAlign === undefined) obj.textAlign = 'left'
               })
             }
           }
@@ -587,7 +677,7 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
         // 添加fabric事件监听器来调试图片加载
         let loadedImageCount = 0
         const totalImagePlaceholders = imgPlaceholders.filter(obj => obj.visible !== false).length
-        
+
 
         // 重要:使用reviver函数确保dataURL图片正确加载
         const reviver = (key, value, object) => {
@@ -596,28 +686,28 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
             // 对于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') {
@@ -629,7 +719,7 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
                 obj.setCoords()
               }
             })
-            
+
             fcanvas.renderAll()
 
             // 获取商品图宽高,并在导出时根据宽度做压缩控制
@@ -639,7 +729,7 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
 
             // 计算按当前导出倍数后的宽度
             const expectedWidth = width * exportMultiplier
-            
+
             if (expectedWidth > MAX_WIDTH) {
               // 根据最大宽度反推需要的缩放倍数(<= 1)
               exportMultiplier = MAX_WIDTH / width
@@ -648,13 +738,13 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
             const finalWidth = Math.round(width * exportMultiplier)
             const finalHeight = Math.round(height * exportMultiplier)
 
-            
+
             const dataUrl = fcanvas.toDataURL({
               format: 'jpeg',
               multiplier: exportMultiplier,
               enableRetinaScaling: true,
             })
-            
+
 
             fcanvas.dispose()
 
@@ -672,11 +762,11 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
             resolve(null)
           }
         }, reviver)
-        
+
         // 添加fabric错误处理
         fcanvas.on('object:added', (e) => {
         })
-        
+
       } catch (e) {
         resolve(null)
       }
@@ -687,7 +777,7 @@ export async function renderImagesByPlans(plans, canvasList, skus) {
     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)
@@ -787,7 +877,7 @@ const composeCombinedImage = (images) =>
     // 确保有canvas元素
     const el = document.createElement('canvas')
     if (!el) return resolve(null)
-    
+
     const ctx = el.getContext('2d')
     if (!ctx) {
       el.remove()