问题背景

使用thumbnail对图片进行压缩,偶然会发现对png图片出现黑底的情况如下:

压缩前

android png图片带底色 png图片黑底_spring

压缩后

android png图片带底色 png图片黑底_java_02

问题解决

对网上搜到的解决方法主要有两种:

1.指定png输出

JAVA - Get black background when uploading PNG image - Stack Overflow

但是这样有个问题就是要指定大小,可以参考

做等比例,不过代码稍微复杂点

2.重绘图,用白底重绘

用白的背景重画,就会有类似如下问题:左边为原图,右边为重绘后的图

android png图片带底色 png图片黑底_sed_03

解决方案

看thumbnail的tofile方法可以看到

android png图片带底色 png图片黑底_sed_04

看fileImageLink,可以看到getExtension()

android png图片带底色 png图片黑底_mybatis_05

那么是怎么确定输出文件的后缀呢?获取.后面的内容 

android png图片带底色 png图片黑底_java_06

 问题到这基本就清晰了,黑底的原因是png压缩过程中alpha通道值没了,用黑色补充,为什么alpha通道会丢失呢?因为按jpg处理了,所以我们只需要处理png时,识别到真实png,保证其后缀名为.png即可。

步骤如下:

  1. 读取文件头

判断文件类型为jpg还是png,对后缀名不对的,强行补充.png等

  1. 一行压缩

 Thumbnails.of(oriFile).scale(compressFactor).toFile(finalFileName);

-----

2023.6.3补充

实际上thumbnailator在设计时就没考虑png位深的问题,见

How to convert a image to a 8-bit png ? · Issue #83 · coobird/thumbnailator · GitHub

所以最后方案改为jpg使用thumbnailator压缩,png使用比较冷门的压缩软件:OpenViewerFX,这个软件实际不是非本人写的,只是把java的图片功能给抠出来了
 

<dependency>
<groupId>org.jpedal</groupId>
<artifactId>OpenViewerFX</artifactId>
<version>6.6.14</version>
</dependency>

更神奇的是就这个版本行,其它版本都恰好把要用的几个类给踢掉了,2015年的包,从压缩效果看,几乎和在线的tinypng差不多。试了透明底/彩色/普通的png,都能完美过关。

检索到的入口

完整代码:

package com.htsc.project.common;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.text.DecimalFormat;
import java.util.Map;

import javax.annotation.PostConstruct;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import lombok.extern.slf4j.Slf4j;
import net.coobird.thumbnailator.Thumbnails;

/**
 * @author 020152
 * @date 2023/5/9
 */
@Service
@Slf4j
public class ImageCompress {
     private static final Integer COMPRESSED = 1;
     /**
     * 支持的文件的头,key:文件头,value:支持的文件后缀,以逗号分割
     * {"FFD8FF":"jpg,jpeg","89504E47":"png"}
     */
    @Value(value = "#{${image.compress.fileHeader:{\"FFD8FF\":\"jpg,jpeg\",\"89504E47\":\"png\"}}}")
     private Map<String, String> compressFileHeaders;

     /**
     * 是否压缩图片,0不压缩,1压缩
     */
    @Value(value = "${image.compressed:0}")
     private Integer compressed;

     /**
     * 对png图片开始压缩的大小,对较小的png不压缩,防止无用功
     */
    @Value(value = "${image.compress.minSize:100000}")
     private Long minCompressedSize;

     /**
     * 太大的png压缩必要不大,可能是无用功
     */
    @Value(value = "${image.compress.maxSize:10000000}")
     private Long maxCompressedSize;

     /**
     * 压缩率,默认0.9
     */
    @Value(value = "${image.compressFactor:0.9}")
     private Double compressFactor;

     @Autowired
     private Config config;

     @PostConstruct
     public void init() {
         log.info("compressHeaders:{}, compressed:{},compressFactor:{}", compressFileHeaders, compressed, compressFactor);
         log.info(Constant.LOG_DEVMODESTR + config.getDevMode());
     }

     /**
     * 压缩图片
     *
     * @param oriFile  原始文件
     * @param fileName 文件名
     * @param random   uuid随机
     * @return 压缩后的文件
     */
    public File compress(File oriFile, String fileName, String random) {
         if (!COMPRESSED.equals(compressed)) {
             return oriFile;
         }
         try (InputStream inputStream = Files.newInputStream(oriFile.toPath())) {
             byte[] bytes = new byte[10];
             inputStream.read(bytes, 0, bytes.length);
             // 校验文件头信息,防止txt、html等文件
             String fileHeader = bytesToHex(bytes);
             String matchExt = compressFileHeaders.keySet().stream().filter(fileHeader::startsWith).findAny().orElse(null);
            if (matchExt == null) {
                 // 非图片文件,直接返回
                 return oriFile;
             }
            String suffix, prefix;
            if (fileName.lastIndexOf(".") == -1) {
                suffix = "." + compressFileHeaders.get(matchExt);
                if (suffix.contains(",")) {
                    suffix = ".jpg";
                 }
                prefix = fileName;
             } else {
                suffix = fileName.substring(fileName.lastIndexOf("."));
                 prefix = fileName.substring(0, fileName.lastIndexOf("."));
             }
            String finalFileName = prefix + suffix;
             // 后缀名和真实文件类型不匹配,如本身是png文件但是后缀为jpg,或反之
             if (!compressFileHeaders.get(matchExt).contains(suffix.substring(1))) {
                 // 实际是jpg文件,但是用了png后缀,在toFile使用jpg压缩,保证压缩率,但是不修改目标文件后缀名
                 if (!suffix.contains("jpg")) {
                    finalFileName = prefix + ".jpg";
                 }
                 // 实际是png文件,但是用jpg后缀,在toFile使用jpg压缩可能出现黑底
                 if ("png".equals(compressFileHeaders.get(matchExt))) {
                    finalFileName = prefix + ".png";
                 }
            }
            finalFileName = random + "_" + finalFileName;
             File result = compressFile(matchExt, oriFile, finalFileName);
            long oriLength = oriFile.length();
            long length = result.length();
             Double cut = (oriLength - length) / (double) oriLength;
             DecimalFormat df = new DecimalFormat("#.##%");
             String cutStr = df.format(cut);
             log.info("fileName:{}, finalFileName:{} , 压缩前:{}, 压缩后:{}, 节省:{}", fileName, finalFileName, oriLength, length, cutStr);
             // 压缩失败=0;压缩负优化;png原图大小不在压缩范围
             if (length == 0 || length > oriLength || result.equals(oriFile)) {
                 return oriFile;
             }
            oriFile.delete();
            return result;
         } catch (IOException e) {
             throw new RuntimeException(e);
         }
    }

     /**
     * 字节数组转Hex
     *
     * @param bytes 字节数组
     * @return Hex
     */
    public String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        if (bytes != null) {
             for (byte aByte : bytes) {
                String hex = byteToHex(aByte);
                 sb.append(hex);
             }
        }
         return sb.toString();
     }

     /**
     * Byte字节转Hex
     *
     * @param b 字节
     * @return Hex
     */
    public String byteToHex(byte b) {
        String hexString = Integer.toHexString(b & 0xFF);
         //由于十六进制是由0~9、A~F来表示1~16,所以如果Byte转换成Hex后如果是<16,就会是一个字符(比如A=10),通常是使用两个字符来表示16进制位的,
        //假如一个字符的话,遇到字符串11,这到底是1个字节,还是1和1两个字节,容易混淆,如果是补0,那么1和1补充后就是0101,11就表示纯粹的11
         if (hexString.length() < 2) {
            hexString = 0 + hexString;
         }
         return hexString.toUpperCase();
     }

     private File compressFile(String matchExt, File oriFile, String finalFileName) throws IOException {
         if (compressFileHeaders.get(matchExt).contains("jpg")) {
             return compressJpg(oriFile, finalFileName);
         } else {
             return compressPng(oriFile, finalFileName);
         }
    }

     /**
     * 实际可以和compressJpg合并为一个方法,考虑后期png可能单独处理,暂时保留
     */
    private File compressPng(File oriFile, String finalFileName) throws IOException {
         if (oriFile.length() < minCompressedSize || oriFile.length() > maxCompressedSize) {
             return oriFile;
         }
        Thumbnails.of(oriFile).scale(compressFactor).toFile(finalFileName);
        return new File(finalFileName);
     }

     private File compressJpg(File oriFile, String finalFileName) throws IOException {
        Thumbnails.of(oriFile).scale(compressFactor).toFile(finalFileName);
        return new File(finalFileName);
     }
}