合并Pdf、excel、图片、word为单个Pdf文件的工具类(技术点的选择与深度解析)

14 阅读17分钟

2.1 流的重置机制:选择 getChannel().position(0)

FileTypeDetector 类中,我们看到了这样的代码:

finally {
    inputStream.getChannel().position(0);
}

为什么需要实现流重置: 在FileTypeDetector中,需要读取流的前面20个字节(用于通过魔数判断流的基本类型),此时流的标记位改变,流不可再用来读取转pdf文件,因为无论哪种文件类型工具(第三方工具)执行转pdf操作,第一步都是判断类型是否正确(我们先通过代码判断文件流的文件类型,再给到对应类型的第三方工具,第三方工具内部还会判断传入的文件类型是否符合),标记位不是初始位,类型工具判断必然错误,为了流的可重复使用,必须进行流的重置

2.1.1 Java流的重置方法对比

Java中重置流位置的方法主要有以下几种:

1. mark()reset() 方法

// 标记当前位置
inputStream.mark(1024);
// 读取数据...
// 重置到标记位置
inputStream.reset();
  • 限制:需要流支持 markSupported() 返回 true
  • 问题FileInputStream 默认不支持 mark(),需要包装为 BufferedInputStream
  • 内存消耗:需要缓存标记位置之后的数据

2. skip() 方法(不推荐)

// 需要记录已读取的字节数
long bytesRead = ...;
inputStream.skip(-bytesRead); // 向后跳转
  • 问题skip() 不支持负数,无法向后跳转
  • 不可靠skip() 可能不会跳过确切的字节数

3. getChannel().position(0) 方法(推荐)

FileInputStream fis = new FileInputStream("file.txt");
FileChannel channel = fis.getChannel();
channel.position(0); // 重置到文件开头
  • 优势:直接操作底层文件通道,性能高效
  • 可靠性:基于文件系统,位置精确
  • 适用性:仅适用于 FileInputStream 及其子类

2.1.2 为什么选择 getChannel().position(0)

我一开始使用的是BufferedInputStream,后面发现docx和xlsx,doc和xls分别属于ZIP格式和OLE格式,通过字节头是无法区分的,需要遍历zip条目和OLE根目录,但BufferedInputStream是基于缓存的字节流,会先将文件内容分段的写入到内部的缓存字节数组中,默认的缓存数组大小是8096,BufferedInputStream的mark和reset操作是基于重置位标志变量和当前标记为变量,这两个标记变量是基于缓存数组的,也就是说,要是缓存数组刷新,在执行reset方法,从文件流的角度出发,也不是原来的位置,我尝试将缓存数组大小设置为16192,zip的条目遍历能够支持,OLE的根目录遍历大小还是不够,而且缓存数组过大容易造成jvm的oom,所以最后还是使用了filechannel的positon(0)

1. 性能优势

  • FileChannel 是NIO(New I/O)的一部分,直接操作操作系统文件描述符
  • 避免了Java堆内存的缓冲操作
  • 重置操作是O(1)时间复杂度,不依赖已读取的数据量

2. 精确性

  • 文件通道的位置是绝对位置,不受缓冲影响
  • 不会出现 skip() 方法可能跳过的字节数不准确的问题

3. 内存效率

  • 不需要像 mark()/reset() 那样在内存中缓存数据
  • 对于大文件,这种方式内存占用最小

4. 代码简洁性

// 使用 mark/reset 需要包装
BufferedInputStream bis = new BufferedInputStream(fis);
bis.mark(Integer.MAX_VALUE);
// ... 读取操作
bis.reset();

// 使用 FileChannel 更简洁
fis.getChannel().position(0);

2.1.3 FileChannel 底层原理

FileChannel 是Java NIO的核心组件,它提供了对文件的高效访问:

public abstract class FileChannel extends AbstractInterruptibleChannel
    implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel

关键特性:

  • 直接内存映射:可以通过 map() 方法将文件映射到内存
  • 零拷贝传输transferTo()transferFrom() 方法可以在内核空间直接传输数据,写数据的操作同样使用的是入参输出流的write()操作
  • 文件锁定:支持文件级别的锁定机制
  • 位置控制position() 方法直接操作文件指针

底层实现:

// FileChannel.position() 的底层调用
native long position0(FileDescriptor fd, long offset);

这是JNI调用,直接操作操作系统的文件描述符,重置文件指针到指定位置。


2.2 匿名内部类与流包装:防止底层流被关闭

在代码中,我们看到了这样的实现:

InputStream noCloseFis = new FilterInputStream(fis) {
    @Override
    public void close() throws IOException {}
};
zipIs = new ZipInputStream(noCloseFis);

2.2.1 什么是匿名内部类?

匿名内部类是Java中一种特殊的内部类,它没有显式的类名,在定义的同时就创建了实例。

语法结构:

new 父类/接口() {
    // 类体
}

示例对比:

普通内部类:

class NonClosingInputStream extends FilterInputStream {
    public NonClosingInputStream(InputStream in) {
        super(in);
    }
    @Override
    public void close() throws IOException {}
}

// 使用
NonClosingInputStream wrapper = new NonClosingInputStream(fis);

匿名内部类:

FilterInputStream wrapper = new FilterInputStream(fis) {
    @Override
    public void close() throws IOException {}
};

对于在整个系统中只会使用一次的类来说,建立一个专门的类文件是一件很浪费资源和时间的事情,所以就有了匿名内部类, 不过在jdk7之后,JAVA能够使用lambda语法来简化开发,匿名内部类的地位被削弱,当时在一些场景之下,匿名内部类还是有优势的

2.2.2 为什么使用匿名内部类?

首先是理清底层流和包装流的关系,以fileinputstreambufferinputstream为例子,bufferinputstream在执行close()方法的时候,内部会首先调用释放自身上下文资源(native资源)的方法,然后再调用in(传入流的方法),所以bufferinputstream执行close方法同样会导致传入流的关闭,但是在filetypedetector判断文件类型之后,流需要重置进行转pdf的操作,所以流不能关闭,但是包装类不关闭的话,即使后面在mergefilestopdfutil中统一关闭了底层流,包装流没有释放,包装流持有的native资源不释放,会导致资源泄露问题。 所以就要使用装饰类:filterinputstream,继承装饰类,通过匿名内部类,来重写close方法,使得包装类释放native资源,不释放底层流;

使用匿名内部类的优点: 1. 代码简洁性

  • 如果某个类只在一个地方使用,定义独立的类会增加代码复杂度
  • 匿名内部类将定义和使用合二为一,代码更紧凑

2. 作用域限制

  • 匿名内部类只在定义它的方法或代码块中可见
  • 避免了类的命名空间污染

3. 闭包特性

  • 匿名内部类可以访问外部类的final变量
  • 可以捕获方法中的局部变量(Java 8+ 可以访问 effectively final 变量)

示例:

public void processFile(FileInputStream fis) {
    final String fileName = "test.txt"; // final 变量
    
    FilterInputStream wrapper = new FilterInputStream(fis) {
        @Override
        public void close() throws IOException {
            System.out.println("Closing wrapper for: " + fileName);
            // 可以访问外部变量
        }
    };
}

2.2.3 装饰器模式的应用

这里的实现实际上应用了装饰器模式(Decorator Pattern)

// 装饰器基类
FilterInputStream (装饰器)
    ↓
FileInputStream (被装饰的对象)

装饰器模式的优势:

  • 动态扩展功能:在不修改原有类的基础上,动态添加功能
  • 组合优于继承:避免了类爆炸问题
  • 职责分离:每个装饰器只负责一个功能

Java IO中的装饰器模式:

// Java IO 流体系中的装饰器模式
InputStream (抽象组件)
    ├── FileInputStream (具体组件)
    ├── FilterInputStream (装饰器基类)
    │   ├── BufferedInputStream (具体装饰器)
    │   ├── DataInputStream (具体装饰器)
    │   └── PushbackInputStream (具体装饰器)

2.3 文件类型校验:为什么docx、xlsx,doc、xls需要特殊处理?

FileTypeDetector 中,我们看到对于某些文件类型,需要额外的校验:

if(typeName.equals("xlsx")) {
    check = isZipFeatureExists(inputStream, XLSX_FEATURE);
} else if(typeName.equals("docx")) {
    check = isZipFeatureExists(inputStream, DOCX_FEATURE);
} else if(typeName.equals("xls")) {
    check = isOleFeatureExists(inputStream, XLS_FEATURE);
} else if(typeName.equals("doc")) {
    check = isOleFeatureExists(inputStream, DOC_FEATURE);
}

2.3.1 文件头冲突问题

问题根源:

  1. OOXML格式的文件头相同

    • .docx.xlsx 都是基于OOXML(Office Open XML)格式
    • 它们实际上都是ZIP压缩包,文件头都是 PK(0x50 0x4B 0x03 0x04)
    • 仅凭文件头无法区分是Word还是Excel
  2. OLE格式的文件头相同

    • .doc.xls 都使用OLE(Object Linking and Embedding)格式
    • 文件头都是 D0 CF(OLE复合文档格式)
    • 同样无法仅凭文件头区分

文件头对比表:

文件类型文件头(十六进制)格式类型
PDF25 50 44 46PDF格式
DOCX50 4B 03 04ZIP格式(OOXML)
XLSX50 4B 03 04ZIP格式(OOXML)
DOCD0 CFOLE格式
XLSD0 CFOLE格式
PNG89 50 4E 47PNG格式
JPGFF D8JPEG格式

2.3.2 OOXML格式解析

OOXML文件结构:

.docx.xlsx 文件实际上是一个ZIP压缩包,包含以下结构:

DOCX结构:

document.docx (ZIP)
├── [Content_Types].xml
├── _rels/
├── docProps/
└── word/
    ├── document.xml      ← 关键特征文件
    ├── styles.xml
    └── ...

XLSX结构:

workbook.xlsx (ZIP)
├── [Content_Types].xml
├── _rels/
├── docProps/
└── xl/
    ├── workbook.xml     ← 关键特征文件
    ├── worksheets/
    └── ...

校验实现:

private static boolean isZipFeatureExists(FileInputStream fis, String feature) 
    throws IOException {
    fis.getChannel().position(0);
    ZipInputStream zipIs = new ZipInputStream(noCloseFis);
    
    ZipEntry entry;
    while ((entry = zipIs.getNextEntry()) != null) {
        String entryPath = entry.getName().replace("\\", "/");
        if (entryPath.equalsIgnoreCase(feature)) {
            return true; // 找到特征文件
        }
        zipIs.closeEntry();
    }
    return false;
}

为什么查找特征文件?

  • word/document.xml 只存在于DOCX文件中
  • xl/workbook.xml 只存在于XLSX文件中
  • 通过检查ZIP包内是否存在这些特征文件,可以准确区分文件类型

2.3.3 OLE格式解析

OLE复合文档结构:

OLE(Object Linking and Embedding)是Microsoft开发的复合文档格式:

OLE Document
├── Header (512 bytes)
├── FAT (File Allocation Table)
├── Directory Entries
│   ├── WordDocument    ← DOC文件的特征流
│   ├── Workbook        ← XLS文件的特征流
│   └── ...
└── Data Streams

校验实现:

private static boolean isOleFeatureExists(FileInputStream is, String featureStream) 
    throws IOException {
    is.getChannel().position(0);
    POIFSFileSystem poifs = new POIFSFileSystem(nonClosingStream);
    DirectoryEntry root = poifs.getRoot();
    
    for (Entry entry : root) {
        if (entry.getName().equals(featureStream)) {
            return true; // 找到特征流
        }
    }
    return false;
}

为什么查找特征流?

  • WordDocument 流只存在于DOC文件中
  • Workbook 流只存在于XLS文件中
  • 通过检查OLE容器中是否存在这些特征流,可以准确区分文件类型

2.4 ByteArrayOutputStream:内存流的核心作用

MergeFilesToPDFUtil 中,方法返回 ByteArrayOutputStream

ByteArrayOutputStream pdfMemoryStream = new ByteArrayOutputStream();
document.save(pdfMemoryStream);
return pdfMemoryStream;

2.4.1 ByteArrayOutputStream 是什么?

ByteArrayOutputStream 是Java IO包中的一个输出流类,它将数据写入内存中的字节数组缓冲区。

类继承关系:

java.lang.Object
    └── java.io.OutputStream
        └── java.io.ByteArrayOutputStream

核心特性:

  • 内存操作:所有数据都存储在内存中
  • 动态扩容:缓冲区大小自动增长
  • 线程安全synchronized 关键字保证线程安全
  • 零拷贝访问:可以通过 toByteArray() 直接获取字节数组

2.4.2 为什么返回 ByteArrayOutputStream?

1. 灵活性

// 调用方可以根据需要选择处理方式
ByteArrayOutputStream stream = MergeFilesToPDFUtil.generatePdf(files, null);

// 方式1:转换为字节数组
byte[] pdfBytes = stream.toByteArray();

// 方式2:写入文件
try (FileOutputStream fos = new FileOutputStream("output.pdf")) {
    stream.writeTo(fos);
}

// 方式3:写入HTTP响应
response.getOutputStream().write(stream.toByteArray());

// 方式4:转换为输入流供其他组件使用
ByteArrayInputStream bis = new ByteArrayInputStream(stream.toByteArray());

2. 内存效率

  • PDF文档通常不会特别大(几MB到几十MB)
  • 内存操作比磁盘IO快得多
  • 避免了临时文件的创建和清理

3. 原子性

  • 整个PDF生成过程在内存中完成
  • 要么成功返回完整PDF,要么抛出异常
  • 不会出现部分写入文件的情况

2.4.4 与其他输出流的对比

输出流类型存储位置适用场景性能
FileOutputStream磁盘文件大文件、持久化存储较慢(磁盘IO)
ByteArrayOutputStream内存小到中等文件、临时数据快(内存操作)
PipedOutputStream管道线程间通信中等
SocketOutputStream网络网络传输取决于网络

2.5 ZIP条目资源管理:为什么必须调用 closeEntry()

isZipFeatureExists 方法中,我们看到这样的代码:

while ((entry = zipIs.getNextEntry()) != null) {
    String entryPath = entry.getName().replace("\\", "/");
    zipIs.closeEntry();  // 关闭当前条目,释放资源
    if (entryPath.equalsIgnoreCase(feature)) {
        return true;
    }
}

2.5.1 ZipInputStream 的工作机制

ZipInputStream 是Java提供的ZIP文件读取流,它按顺序读取ZIP文件中的每个条目(entry)。

ZIP文件结构:

ZIP文件
├── Local File Header (每个文件)
├── File Data (文件内容)
├── Data Descriptor (可选)
├── Central Directory (文件目录)
└── End of Central Directory Record

ZipInputStream 读取流程:

  1. getNextEntry() - 定位到下一个ZIP条目,读取Local File Header
  2. 读取条目数据(如果需要)
  3. closeEntry() - 关闭当前条目,释放相关资源
  4. 重复步骤1-3,直到所有条目读取完毕

2.5.2 为什么必须调用 closeEntry()

1. 内存泄漏风险

如果不调用 closeEntry(),会发生什么?

// 错误示例:不关闭条目
while ((entry = zipIs.getNextEntry()) != null) {
    String name = entry.getName();
    // 没有调用 closeEntry()
    // 继续读取下一个条目...
}

问题分析:

  • getNextEntry() 会读取条目的元数据(文件名、大小、压缩方式等)到内存
  • 这些元数据会一直保存在 ZipInputStream 的内部缓冲区中
  • 如果不调用 closeEntry(),这些缓冲区不会被清理
  • 在处理包含大量文件的ZIP时,会导致内存泄漏

2. 流位置错误

// ZipInputStream 内部维护当前条目的读取位置
// closeEntry() 会:
// 1. 跳过当前条目的剩余数据
// 2. 定位到下一个条目的开始位置
// 3. 清理当前条目的状态

如果不调用 closeEntry()

  • 流的位置可能停留在当前条目的数据中间
  • 下次调用 getNextEntry() 时可能读取到错误的数据
  • 导致ZIP文件解析失败

2.5.3 closeEntry() 的底层实现

ZipInputStream 内部状态:

public class ZipInputStream extends InflaterInputStream {
    private ZipEntry entry;           // 当前条目
    private byte[] b;                 // 缓冲区
    private boolean closed = false;
    private boolean entryEOF = false;  // 当前条目是否读取完毕
    
    public void closeEntry() throws IOException {
        ensureOpen();
        while (read(tmpbuf, 0, tmpbuf.length) != -1) {
            // 读取并丢弃剩余数据
        }
        entryEOF = true;
        entry = null;  // 释放条目引用
    }
}

关键操作:

  1. 读取剩余数据:确保当前条目的所有数据都被读取
  2. 重置状态:将 entryEOF 设置为 true
  3. 释放引用:将 entry 设置为 null,允许GC回收

2.6 临时文件管理:安全创建与清理策略

MergeFilesToPDFUtil 中,我们看到临时文件的管理:

String tempPath = url + "/" + num;  // 临时目录路径
// ... 使用临时文件 ...
finally {
    FileUtils.forceDelete(new File(tempPath));  // 清理临时文件
}

2.6.1 为什么需要临时文件?

使用场景:

  1. Office文档转换:Word/Excel需要先转换为PDF,再合并
  2. 中间结果存储:转换过程需要临时存储中间文件
  3. 流处理限制:某些库(如Aspose)需要文件路径而不是流

临时文件的生命周期:

创建临时目录 → 生成临时文件 → 使用临时文件 → 清理临时文件

2.6.2 临时文件命名策略

当前实现:

String num = System.currentTimeMillis() + "";
String tempPath = url + "/" + num;

分析:

  • 使用时间戳作为目录名,确保唯一性避免并发冲突或者是多用户操作同一个文件的时候,误删其他进程使用中的临时文件
  • 简单直接,但存在并发问题

2.6.3 更安全的临时文件管理

FileUtils.forceDelete() 的优势:

  • 自动处理目录和文件
  • 递归删除所有子目录和文件
  • 异常处理更完善

2.6.4 临时文件清理的最佳实践

使用 try-finally 确保清理

File tempDir = null;
try {
    tempDir = Files.createTempDirectory("pdf_").toFile();
    // 使用临时目录
} finally {
    if (tempDir != null && tempDir.exists()) {
        FileUtils.forceDelete(tempDir);
    }
}

2.7 finally块与资源释放:确保资源不泄漏

在代码中,使用了大量的 finally 块用于资源释放:

try {
    // 业务逻辑
} finally {
    if(document != null){
        document.close();
    }
    closeIo(pdfdocuments);
    FileUtils.forceDelete(new File(tempPath));
}

2.7.1 为什么需要 finally 块?

问题场景:

// 错误示例:没有finally块
PDDocument document = new PDDocument();
// ... 业务逻辑,可能抛出异常
document.close();  // 如果上面抛出异常,这行不会执行!

异常情况:

  • 如果业务逻辑中抛出异常,close() 不会被执行
  • 资源(文件句柄、内存等)不会被释放
  • 导致资源泄漏

2.7.2 finally 块的执行保证

finally 块的特性:

  1. 总是执行:无论是否发生异常,finally块都会执行
  2. 执行顺序:在try块或catch块之后执行
  3. 异常不影响:即使finally块中抛出异常,也会执行

执行流程:

try {
    // 1. 执行try块
    // 2. 如果发生异常,跳转到catch
} catch (Exception e) {
    // 3. 执行catch块
} finally {
    // 4. 总是执行finally块
}

2.7.3 资源释放的最佳实践

方案1:try-finally(传统方式)

PDDocument document = null;
try {
    document = new PDDocument();
    // 业务逻辑
} finally {
    if (document != null) {
        document.close();
    }
}

方案2:try-with-resources(推荐,Java 7+)

try (PDDocument document = new PDDocument()) {
    // 业务逻辑
    // 自动关闭,无需finally块
}  // 这里自动调用 document.close()

try-with-resources 的要求:

  • 资源必须实现 AutoCloseable 接口
  • PDDocument 实现了 AutoCloseable,可以使用

方案3:多个资源的try-with-resources

try (PDDocument doc1 = new PDDocument();
     PDDocument doc2 = new PDDocument();
     FileInputStream fis = new FileInputStream("file.pdf")) {
    // 使用资源
}  // 按相反顺序自动关闭:fis -> doc2 -> doc1

底层技术深度解析

3.1 Magic Number(文件头字节)技术

3.1.1 什么是Magic Number?

Magic Number是文件格式的"签名",通常位于文件的开头几个字节,用于标识文件类型。

常见文件的Magic Number:

文件类型Magic Number(十六进制)ASCII表示
PDF25 50 44 46%PDF
PNG89 50 4E 47 0D 0A 1A 0A.PNG....
JPEGFF D8 FFÿØÿ
GIF47 49 46 38GIF8
ZIP50 4B 03 04PK..
OLED0 CF 11 E0ÐÏ.à

3.1.2 为什么使用Magic Number而不是扩展名?

扩展名的局限性:

  1. 可被修改:用户可以随意修改文件扩展名
  2. 不唯一:不同格式可能使用相同扩展名
  3. 不可靠:某些系统可能没有扩展名

Magic Number的优势:

  1. 难以伪造:修改文件头会导致文件损坏
  2. 唯一性强:每种格式都有独特的签名
  3. 跨平台:不依赖操作系统

3.1.3 实现原理

// 读取文件头
byte[] fileHeader = new byte[READ_BYTES_LENGTH];
int readLen = inputStream.read(fileHeader);

// 匹配Magic Number
for (FileType fileType : FILE_TYPES) {
    byte[] magicNumber = fileType.magicNumber;
    if (readLen >= magicNumber.length) {
        byte[] actualHeader = Arrays.copyOfRange(fileHeader, 0, magicNumber.length);
        if (Arrays.equals(actualHeader, magicNumber)) {
            return fileType.getTypeName();
        }
    }
}

性能优化:

  • 只读取前20字节,避免读取整个文件
  • 使用 Arrays.equals() 进行高效的字节数组比较
  • 按常见程度排序,优先匹配常见格式

3.2 PDF文档处理:PDFBox库的使用

3.2.1 PDFBox简介

Apache PDFBox是一个开源的Java PDF处理库,提供了创建、操作和提取PDF文档内容的功能。

核心类:

  • PDDocument:PDF文档对象
  • PDPage:PDF页面
  • PDPageContentStream:页面内容流
  • PDImageXObject:PDF图像对象

3.2.2 PDF合并实现

// 创建目标PDF文档
PDDocument document = new PDDocument();

// 遍历源PDF,复制页面
PDDocument sourcePdf = PDDocument.load(fis);
for (PDPage page : sourcePdf.getPages()) {
    document.addPage(page);  // 复制页面到目标文档
}
sourcePdf.close();

底层原理:

  • PDF文档由对象树组成
  • 页面是文档对象树中的一个节点
  • addPage() 实际上是复制页面对象并添加到目标文档的对象树中

3.2.3 图片转PDF实现

// 1. 读取图片字节
byte[] imageBytes = streamToByteArray(is);

// 2. 创建PDF图像对象
PDImageXObject image = PDImageXObject.createFromByteArray(
    document, imageBytes, "image");

// 3. 创建与图片尺寸一致的页面
PDPage page = new PDPage(new PDRectangle(image.getWidth(), image.getHeight()));
document.addPage(page);

// 4. 绘制图片到页面
try (PDPageContentStream contentStream = new PDPageContentStream(document, page)) {
    contentStream.drawImage(image, 0, 0);
}

关键点:

  • PDRectangle 使用PDF点(point)作为单位,1点 = 1/72英寸
  • 图片尺寸直接映射到页面尺寸,避免缩放
  • PDPageContentStream 使用try-with-resources确保资源释放

3.3 Office文档转换:Aspose库的使用

3.3.1 Aspose库简介

Aspose是一个商业化的文档处理库,提供了强大的Office文档转换功能。

核心特性:

  • 支持多种Office格式(Word、Excel、PowerPoint等)
  • 高质量的格式转换
  • 支持复杂的文档结构

3.3.2 License机制

private static void getLicense() {
    try (InputStream is = Word2PdfUtils.class.getClassLoader()
            .getResourceAsStream("License.xml")) {
        License license = new License();
        license.setLicense(is);
    }
}

License的作用:

  • 去除水印:未授权版本会在转换后的文档中添加水印
  • 解除功能限制:某些高级功能需要License
  • 合法使用:确保商业使用的合法性

3.3.3 Word转PDF实现

Document doc = new Document(inputStream);
doc.updatePageLayout();  // 更新页面布局,确保格式正确
doc.save(pdfOutputStream, SaveFormat.PDF);

updatePageLayout() 的重要性:

  • Word文档在转换前可能没有完全渲染页面布局
  • 调用此方法会触发页面布局计算
  • 确保转换后的PDF格式与Word文档一致

3.3.4 空白页处理

private static byte[] removeEmptyPagesFromPdf(byte[] pdfBytes) {
    PdfReader reader = new PdfReader(pdfBytes);
    List<Integer> pagesToKeep = new ArrayList<>();
    
    // 检查每个页面是否为空
    for (int i = 1; i <= reader.getNumberOfPages(); i++) {
        if (!isPageEmpty(reader, i)) {
            pagesToKeep.add(i);
        }
    }
    
    // 创建新PDF,只包含非空白页
    PdfReader newReader = new PdfReader(pdfBytes);
    newReader.selectPages(pagesToKeep);
    PdfStamper stamper = new PdfStamper(newReader, outputStream);
    stamper.close();
    
    return outputStream.toByteArray();
}

为什么需要删除空白页?

  • Word文档可能包含格式化的空白页
  • 转换后这些空白页会保留在PDF中
  • 删除空白页可以减小PDF文件大小,提升用户体验