Przeglądaj źródła

feat(excel): 添加图片格式检测和续页功能支持

- 实现图片格式自动检测,支持JPEG、PNG、GIF格式识别
- 添加续页处理功能,支持自动复制工作表并调整绑定路径
- 优化图片插入逻辑,修复图片类型不匹配导致的错误
- 添加对Illustration字段多种数据类型的兼容处理
- 集成janino依赖支持logback彩色日志输出
- 配置异步日志输出提升性能
- 修复布尔值判断和字符串转JSON数组的安全性问题
xuzhancheng 1 tydzień temu
rodzic
commit
be4f46f158

+ 5 - 0
pom.xml

@@ -60,6 +60,11 @@
         <artifactId>lombok</artifactId>
         <version>1.18.36</version>
         </dependency>
+        <!-- 支持 logback 彩色日志(%clr / %highlight 等) -->
+        <dependency>
+            <groupId>org.codehaus.janino</groupId>
+            <artifactId>janino</artifactId>
+        </dependency>
     </dependencies>
 
     <build>

+ 36 - 4
src/main/java/com/grapecity/controller/ExcelController.java

@@ -64,7 +64,7 @@ public class ExcelController {
             byte[] processedBytes = processWorkbook(templateBytes, data, fileBytes, imgCols, editCols);
 
             List<String> newColCodes = new ArrayList<>();
-            if (Boolean.TRUE.equals(continuePage) && copyConfigJson != null) {
+            if (continuePage && copyConfigJson != null) {
                 processedBytes = handleContinuationPages(processedBytes, copyConfigJson, existingColCodes, newColCodes);
             }
 
@@ -219,9 +219,12 @@ public class ExcelController {
                             continue;
                         }
                         ByteArrayInputStream imgStream = new ByteArrayInputStream(imgBytes);
+                        // 检测图片实际格式,避免类型不匹配导致 "Image type UNKNOWN" 错误
+                        ImageType detectedType = detectImageType(imgBytes);
+                        if (detectedType == null) detectedType = ImageType.PNG;
                         worksheet.getShapes().addPictureInPixel(
                                 JSON.toJSONString(imgItem),
-                                imgStream, ImageType.JPG,
+                                imgStream, detectedType,
                                 imgItem.getDoubleValue("x"),
                                 imgItem.getDoubleValue("y"),
                                 imgItem.getDoubleValue("width"),
@@ -263,9 +266,12 @@ public class ExcelController {
                         continue;
                     }
                     ByteArrayInputStream imgStream = new ByteArrayInputStream(imgBytes);
+                    // 检测图片实际格式,避免类型不匹配导致 "Image type UNKNOWN" 错误
+                    ImageType detectedType = detectImageType(imgBytes);
+                    if (detectedType == null) detectedType = ImageType.PNG;
                     worksheet.getShapes().addPictureInPixel(
-                            url,
-                            imgStream, ImageType.JPG,
+                            jsonObject.toJSONString(),
+                            imgStream, detectedType,
                             jsonObject.getDoubleValue("x"),
                             jsonObject.getDoubleValue("y"),
                             jsonObject.getDoubleValue("width"),
@@ -628,4 +634,30 @@ public class ExcelController {
         }
         return null;
     }
+
+    /**
+     * 根据图片字节流魔数检测图片格式
+     * 支持 JPEG (0xFFD8)、PNG (0x8950)、GIF (0x4749)
+     *
+     * @param imageBytes 图片字节数组
+     * @return 对应的 ImageType,识别失败返回 null
+     */
+    private ImageType detectImageType(byte[] imageBytes) {
+        if (imageBytes == null || imageBytes.length < 4) {
+            return null;
+        }
+        int firstByte = imageBytes[0] & 0xFF;
+        int secondByte = imageBytes[1] & 0xFF;
+
+        if (firstByte == 0xFF && secondByte == 0xD8) {
+            return ImageType.JPEG;
+        }
+        if (firstByte == 0x89 && secondByte == 0x50) {
+            return ImageType.PNG;
+        }
+        if (firstByte == 0x47 && secondByte == 0x49) {
+            return ImageType.GIF;
+        }
+        return null;
+    }
 }

+ 247 - 41
src/main/java/com/grapecity/controller/GrapeCityController.java

@@ -19,6 +19,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.util.*;
 import java.util.List;
+
 /**
  * GrapeCity 控制器
  * 提供 Excel 模板处理和 PDF 生成功能
@@ -158,33 +159,24 @@ public class GrapeCityController {
     private void collectFloatingImages(IWorksheet worksheet, Set<String> imagePaths, Map<String, Object> data) {
         // 这里可以添加收集浮动图片的逻辑,如果需要的话
         // 目前 pdf 方法中有处理 Illustration 字段,但 filePath 可能不需要
-        if (data.get("Illustration") != null) {
-            Object illustrationObj = data.get("Illustration");
-            JSONArray jsonArray;
+        Object illustrationObj = data.get("Illustration");
+        if (illustrationObj == null) return;
 
-            // 处理不同类型的 Illustration 数据
-            if (illustrationObj instanceof JSONArray arr) {
-                jsonArray = arr;
-            } else if (illustrationObj instanceof String str && str.trim().startsWith("[")) {
-                jsonArray = JSON.parseArray(str);
-            } else {
-                logger.warn("Illustration 字段格式不正确:{}", illustrationObj.getClass().getName());
-                return;
-            }
+        JSONArray jsonArray = toJSONArray(illustrationObj);
+        if (jsonArray == null || jsonArray.isEmpty()) return;
 
-            if (jsonArray != null) {
-                List<JSONObject> list = new ArrayList<>();
-                for (int k = 0; k < jsonArray.size(); k++) {
-                    JSONObject jsonObject = jsonArray.getJSONObject(k);
-                    if (jsonObject.getString("sheet").equals(worksheet.getName())) {
-                        imagePaths.add(jsonObject.getString("url"));
-                    } else {
-                        list.add(jsonObject);
-                    }
-                }
-                data.put("Illustration", JSON.toJSONString(list));
+        List<JSONObject> list = new ArrayList<>();
+        for (int k = 0; k < jsonArray.size(); k++) {
+            JSONObject jsonObject = jsonArray.getJSONObject(k);
+            if (jsonObject.getString("sheet").equals(worksheet.getName())) {
+                imagePaths.add(jsonObject.getString("url"));
+            } else {
+                list.add(jsonObject);
             }
         }
+        data.put("Illustration", JSON.toJSONString(list));
+
+
     }
 
     /**
@@ -224,7 +216,6 @@ public class GrapeCityController {
             workbook = new Workbook(workbookOptions);
             inputStream = new ByteArrayInputStream(templateBytes);
             workbook.open(inputStream, OpenFileFormat.Sjs);
-
             for (int i = 0; i < workbook.getWorksheets().getCount(); i++) {
                 IWorksheet worksheet = workbook.getWorksheets().get(i);
                 worksheet.getPageSetup().setPrintHeadings(false);
@@ -248,6 +239,19 @@ public class GrapeCityController {
                 // 浮动图片
                 processFloatingImages(worksheet, data, fileBytes);
             }
+            try {
+                boolean continuePage = request.get("continuePage") != null && Boolean.parseBoolean(request.get("continuePage").toString());
+                String copyConfigJson = request.get("copyConfig") != null
+                        ? request.get("copyConfig").toString()
+                        : null;
+                List<String> newColCodes = new ArrayList<>();
+                List<String> existingColCodes = extractStringList(request.get("existingColCodes"));
+                if (continuePage && copyConfigJson != null) {
+                    workbook = handleContinuationPages(workbook, copyConfigJson, existingColCodes, newColCodes);
+                }
+            } catch (Exception e) {
+                logger.error("处理分页异常:{}", e.getMessage());
+            }
 
             PrintManager printManager = new PrintManager();
             //Workbook.FontsFolderPath = this.fontsFolderPath;
@@ -266,6 +270,7 @@ public class GrapeCityController {
             };
             PdfSaveOptions pdfOptions = new PdfSaveOptions();
             pdfOptions.setIncludeAutoMergedCells(true);
+            pdfOptions.setPrintBackgroundPicture(true);
 //            pdfOptions.getShrinkToFitSettings().setCanShrinkToFitWrappedText(true);
             List<PageInfo> pages = printManager.paginate(workbook);
             printManager.savePageInfosToPDF(byteArrayOutputStream, pages, pdfOptions);
@@ -293,7 +298,7 @@ public class GrapeCityController {
      * 处理单元格背景图片
      */
     private void processCellBackgroundImages(IWorksheet worksheet, Map<String, byte[]> fileBytes) {
-        if (fileBytes == null || fileBytes.isEmpty()){
+        if (fileBytes == null || fileBytes.isEmpty()) {
             return;
         }
         for (int x = 0; x < worksheet.getRowCount(); x++) {
@@ -343,7 +348,7 @@ public class GrapeCityController {
      * 处理浮动图片
      */
     private void processFloatingImages(IWorksheet worksheet, Map<String, Object> data, Map<String, byte[]> fileBytes) {
-        if (fileBytes == null || fileBytes.isEmpty()){
+        if (fileBytes == null || fileBytes.isEmpty()) {
             return;
         }
         Object illustrationObj = data.get("Illustration");
@@ -351,6 +356,8 @@ public class GrapeCityController {
             JSONArray jsonArray;
             if (illustrationObj instanceof JSONArray arr) {
                 jsonArray = arr;
+            } else if (illustrationObj instanceof List arr) {
+                jsonArray = JSON.parseArray(JSON.toJSONString(arr));
             } else if (illustrationObj instanceof String str && str.trim().startsWith("[")) {
                 jsonArray = JSON.parseArray(str);
             } else {
@@ -364,21 +371,39 @@ public class GrapeCityController {
                     if (jsonObject.getString("sheet").equals(worksheet.getName())) {
                         String url = jsonObject.getString("url");
                         byte[] bytes = fileBytes.get(url);
-                        if (bytes != null) {
-                            try (InputStream byteArrayInputStream = new ByteArrayInputStream(bytes)) {
-                                worksheet.getShapes().addPictureInPixel(
-                                        byteArrayInputStream,
-                                        ImageType.JPG,
-                                        jsonObject.getDouble("x"),
-                                        jsonObject.getDouble("y"),
-                                        jsonObject.getDouble("width"),
-                                        jsonObject.getDouble("height")
-                                );
-                            } catch (IOException e) {
-                                logger.warn("添加浮动图片失败:{}", url, e);
-                            }
-                        } else {
-                            logger.warn("未找到浮动图片数据:{}", url);
+
+                        if (bytes == null || bytes.length == 0) {
+                            logger.warn("浮动图片数据为空: {}", url);
+                            list.add(jsonObject);
+                            continue;
+                        }
+
+                        if (!isValidImage(bytes)) {
+                            logger.error("浮动图片损坏或格式不支持: {}", url);
+                            list.add(jsonObject);
+                            continue;
+                        }
+
+                        ImageType imageType = detectImageType(bytes);
+                        if (imageType == null) {
+                            logger.error("无法识别浮动图片格式: {}", url);
+                            list.add(jsonObject);
+                            continue;
+                        }
+
+                        InputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
+                        try {
+                            worksheet.getShapes().addPictureInPixel(
+                                    url,
+                                    byteArrayInputStream,
+                                    imageType,
+                                    jsonObject.getDouble("x"),
+                                    jsonObject.getDouble("y"),
+                                    jsonObject.getDouble("width"),
+                                    jsonObject.getDouble("height")
+                            );
+                        } catch (IOException e) {
+                            logger.error("图片流异常: {}", url, e);
                         }
                     } else {
                         list.add(jsonObject);
@@ -389,6 +414,123 @@ public class GrapeCityController {
         }
     }
 
+    /**
+     * 处理续页:检查 copyConfig 配置,处理隐藏空页和自动生成续页
+     * copyConfigJson 和 existingColCodes 由 pressure2 传入,newColCodes 通过参数返回给调用方
+     */
+    private Workbook handleContinuationPages(Workbook workbook, String copyConfigJson,
+                                             List<String> existingColCodes, List<String> newColCodes) {
+        try {
+            JSONObject copyConfig = JSON.parseObject(copyConfigJson);
+
+            Boolean isContinuePage = copyConfig.getBoolean("isContinuePage");
+            if (!Boolean.TRUE.equals(isContinuePage)) return workbook;
+
+            String sheetName = copyConfig.getString("sheetName");
+            if (sheetName == null) return workbook;
+
+            JSONObject copyRange = copyConfig.getJSONObject("copyRange");
+            if (copyRange == null) return workbook;
+
+            String[] topLeft = copyRange.getString("topLeft").split(",");
+            String[] topRight = copyRange.getString("topRight").split(",");
+            String[] bottomLeft = copyRange.getString("bottomLeft").split(",");
+
+            int col = Integer.parseInt(topLeft[0]);
+            int row = Integer.parseInt(topLeft[1]);
+            int colCount = Integer.parseInt(topRight[0]) - col;
+            int rowCount = Integer.parseInt(bottomLeft[1]) - row;
+
+            WorkbookOptions workbookOptions = new WorkbookOptions();
+            workbookOptions.setPixelBasedColumnWidth(true);
+            IWorksheet targetSheet = null;
+            for (int i = 0; i < workbook.getWorksheets().getCount(); i++) {
+                if (workbook.getWorksheets().get(i).getName().equals(sheetName)) {
+                    targetSheet = workbook.getWorksheets().get(i);
+                    break;
+                }
+            }
+            if (targetSheet == null) return workbook;
+
+            Boolean hidden = copyConfig.getBoolean("hidden");
+            if (Boolean.TRUE.equals(hidden)) {
+                boolean isEmpty = true;
+                for (int i = 0; i <= colCount; i++) {
+                    Object cellValue = targetSheet.getRange(row, col + i).getValue();
+                    if (cellValue != null && !cellValue.toString().isEmpty()) {
+                        isEmpty = false;
+                        break;
+                    }
+                }
+                if (isEmpty) {
+                    targetSheet.setVisible(Visibility.Hidden);
+                }
+            }
+
+            List<String> effectiveExistingColCodes = existingColCodes != null ? existingColCodes : new ArrayList<>();
+
+            int sheetNum = 2;
+
+            boolean isNotEmpty = false;
+            for (int i = 0; i <= colCount; i++) {
+                Object cellValue = targetSheet.getRange(row + rowCount, col + i).getValue();
+                if (cellValue != null && !cellValue.toString().isEmpty()) {
+                    isNotEmpty = true;
+                    break;
+                }
+            }
+
+            if (isNotEmpty) {
+                generateContinuedSheet(workbook, targetSheet, sheetName, sheetNum,
+                        col, row, colCount, rowCount, effectiveExistingColCodes, newColCodes);
+                sheetNum++;
+            }
+
+            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+            workbook.save(outputStream, SaveFileFormat.Sjs);
+            return workbook;
+        } catch (Exception e) {
+            logger.warn("续页处理失败,回退返回原始 SJS", e);
+            return workbook;
+        }
+    }
+
+    /**
+     * 生成续页工作表:复制 sheet 并调整绑定路径
+     * 新字段编码通过 newColCodes 参数返回给调用方
+     */
+    private void generateContinuedSheet(Workbook workbook, IWorksheet sourceSheet,
+                                        String sheetName, int sheetNum,
+                                        int col, int row, int colCount, int rowCount,
+                                        List<String> existingColCodes, List<String> newColCodes) {
+        String sheetJson = sourceSheet.toJson();
+        IWorksheet newSheet = workbook.getWorksheets().add();
+        newSheet.fromJson(sheetJson);
+        String newSheetName = sheetName + " (" + sheetNum + ")";
+        newSheet.setName(newSheetName);
+
+        for (int i = 0; i <= colCount; i++) {
+            for (int j = 0; j <= rowCount; j++) {
+                String bindingPath = sourceSheet.getRange(row + j, col + i).getBindingPath();
+                if (bindingPath != null && bindingPath.contains("_")) {
+                    String prefix = bindingPath.substring(0, bindingPath.lastIndexOf("_") + 1);
+                    String numStr = bindingPath.substring(bindingPath.lastIndexOf("_") + 1);
+                    try {
+                        int num = Integer.parseInt(numStr);
+                        int newNum = num + (rowCount + 1) * (sheetNum - 1);
+                        String newPath = prefix + newNum;
+                        newSheet.getRange(row + j, col + i).setBindingPath(newPath);
+                        if (!existingColCodes.contains(newPath) && !newColCodes.contains(newPath)) {
+                            newColCodes.add(newPath);
+                        }
+                    } catch (NumberFormatException ignored) {
+                    }
+                }
+            }
+        }
+    }
+
+
     /**
      * 合并图片
      * 将多张图片合并成一个图片,高度压缩成一致
@@ -662,4 +804,68 @@ public class GrapeCityController {
             tableRange.setWrapText(true);
         }
     }
+
+    @SuppressWarnings("unchecked")
+    private List<String> extractStringList(Object obj) {
+        if (obj == null) return new ArrayList<>();
+        if (obj instanceof List) return (List<String>) obj;
+        return new ArrayList<>();
+    }
+
+    /**
+     * 将对象安全转换为 JSONArray
+     * 兼容 fastjson2 JSONArray 和 Jackson 反序列化的 ArrayList
+     */
+    private JSONArray toJSONArray(Object obj) {
+        if (obj instanceof JSONArray arr) return arr;
+        if (obj instanceof List<?> list) {
+            JSONArray arr = new JSONArray();
+            arr.addAll(list);
+            return arr;
+        }
+        if (obj instanceof String str && str.trim().startsWith("[")) {
+            try {
+                return JSON.parseArray(str);
+            } catch (Exception ignored) {
+            }
+        }
+        return null;
+    }
+
+    private boolean isValidImage(byte[] imageBytes) {
+        if (imageBytes == null || imageBytes.length < 4) {
+            return false;
+        }
+
+        try {
+            BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes));
+            return image != null;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    private ImageType detectImageType(byte[] imageBytes) {
+        if (imageBytes == null || imageBytes.length < 4) {
+            return null;
+        }
+
+        int firstByte = imageBytes[0] & 0xFF;
+        int secondByte = imageBytes[1] & 0xFF;
+
+        if (firstByte == 0xFF && secondByte == 0xD8) {
+            return ImageType.JPEG;
+        }
+
+        if (firstByte == 0x89 && secondByte == 0x50) {
+            return ImageType.PNG;
+        }
+
+        if (firstByte == 0x47 && secondByte == 0x49) {
+            return ImageType.GIF;
+        }
+
+        return null;
+    }
+
 }

+ 55 - 0
src/main/resources/logback-spring.xml

@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+    <!-- 日志文件存放路径 -->
+    <property name="LOG_HOME" value="./logs"/>
+    <!-- 文件日志格式(不含颜色,方便检索) -->
+    <property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"/>
+
+    <!-- ==================== 控制台输出(带颜色) ==================== -->
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <withJansi>true</withJansi>
+        <encoder>
+            <charset>UTF-8</charset>
+            <pattern>
+                %d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %cyan([%thread]) %magenta(%logger{36}) %msg %n
+            </pattern>
+        </encoder>
+    </appender>
+
+    <!-- ==================== 文件输出(按日期滚动) ==================== -->
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <!-- 当天日志文件名 -->
+        <file>${LOG_HOME}/grape-city.log</file>
+        <encoder>
+            <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 每天滚动,历史文件按日期命名 -->
+            <fileNamePattern>${LOG_HOME}/grape-city-%d{yyyy-MM-dd}.log</fileNamePattern>
+            <!-- 保留 30 天历史日志 -->
+            <maxHistory>30</maxHistory>
+        </rollingPolicy>
+    </appender>
+
+    <!-- ==================== 异步输出(提升性能) ==================== -->
+    <appender name="ASYNC_CONSOLE" class="ch.qos.logback.classic.AsyncAppender">
+        <appender-ref ref="CONSOLE"/>
+        <queueSize>256</queueSize>
+        <discardingThreshold>0</discardingThreshold>
+    </appender>
+    <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
+        <appender-ref ref="FILE"/>
+        <queueSize>512</queueSize>
+        <discardingThreshold>0</discardingThreshold>
+    </appender>
+
+    <!-- 项目日志级别 -->
+    <logger name="com.grapecity" level="DEBUG"/>
+
+    <!-- 根日志:INFO 级别 -->
+    <root level="INFO">
+        <appender-ref ref="ASYNC_CONSOLE"/>
+        <appender-ref ref="ASYNC_FILE"/>
+    </root>
+</configuration>