2.1 流的重置机制:选择 getChannel().position(0)
在 FileTypeDetector 类中,我们看到了这样的代码:
java
finally {
inputStream.getChannel().position(0);
}
为什么需要实现流重置: 在FileTypeDetector中,需要读取流的前面20个字节(用于通过魔数判断流的基本类型),此时流的标记位改变,流不可再用来读取转pdf文件,因为无论哪种文件类型工具(第三方工具)执行转pdf操作,第一步都是判断类型是否正确(我们先通过代码判断文件流的文件类型,再给到对应类型的第三方工具,第三方工具内部还会判断传入的文件类型是否符合),标记位不是初始位,类型工具判断必然错误,为了流的可重复使用,必须进行流的重置
2.1.1 Java流的重置方法对比
Java中重置流位置的方法主要有以下几种:
1. mark() 和 reset() 方法
java
// 标记当前位置
inputStream.mark(1024);
// 读取数据...
// 重置到标记位置
inputStream.reset();
- 限制 :需要流支持
markSupported()返回true - 问题 :
FileInputStream默认不支持mark(),需要包装为BufferedInputStream - 内存消耗:需要缓存标记位置之后的数据
2. skip() 方法(不推荐)
java
// 需要记录已读取的字节数
long bytesRead = ...;
inputStream.skip(-bytesRead); // 向后跳转
- 问题 :
skip()不支持负数,无法向后跳转 - 不可靠 :
skip()可能不会跳过确切的字节数
3. getChannel().position(0) 方法(推荐)
java
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. 代码简洁性
java
// 使用 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的核心组件,它提供了对文件的高效访问:
java
public abstract class FileChannel extends AbstractInterruptibleChannel
implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel
关键特性:
- 直接内存映射 :可以通过
map()方法将文件映射到内存 - 零拷贝传输 :
transferTo()和transferFrom()方法可以在内核空间直接传输数据,写数据的操作同样使用的是入参输出流的write()操作 - 文件锁定:支持文件级别的锁定机制
- 位置控制 :
position()方法直接操作文件指针
底层实现:
java
// FileChannel.position() 的底层调用
native long position0(FileDescriptor fd, long offset);
这是JNI调用,直接操作操作系统的文件描述符,重置文件指针到指定位置。
2.2 匿名内部类与流包装:防止底层流被关闭
在代码中,我们看到了这样的实现:
java
InputStream noCloseFis = new FilterInputStream(fis) {
@Override
public void close() throws IOException {}
};
zipIs = new ZipInputStream(noCloseFis);
2.2.1 什么是匿名内部类?
匿名内部类是Java中一种特殊的内部类,它没有显式的类名,在定义的同时就创建了实例。
语法结构:
java
new 父类/接口() {
// 类体
}
示例对比:
普通内部类:
java
class NonClosingInputStream extends FilterInputStream {
public NonClosingInputStream(InputStream in) {
super(in);
}
@Override
public void close() throws IOException {}
}
// 使用
NonClosingInputStream wrapper = new NonClosingInputStream(fis);
匿名内部类:
java
FilterInputStream wrapper = new FilterInputStream(fis) {
@Override
public void close() throws IOException {}
};
对于在整个系统中只会使用一次的类来说,建立一个专门的类文件是一件很浪费资源和时间的事情,所以就有了匿名内部类, 不过在jdk7之后,JAVA能够使用lambda语法来简化开发,匿名内部类的地位被削弱,当时在一些场景之下,匿名内部类还是有优势的
2.2.2 为什么使用匿名内部类?
首先是理清底层流和包装流的关系,以fileinputstream和bufferinputstream为例子,bufferinputstream在执行close()方法的时候,内部会首先调用释放自身上下文资源(native资源)的方法,然后再调用in(传入流的方法),所以bufferinputstream执行close方法同样会导致传入流的关闭,但是在filetypedetector判断文件类型之后,流需要重置进行转pdf的操作,所以流不能关闭,但是包装类不关闭的话,即使后面在mergefilestopdfutil中统一关闭了底层流,包装流没有释放,包装流持有的native资源不释放,会导致资源泄露问题。 所以就要使用装饰类:filterinputstream,继承装饰类,通过匿名内部类,来重写close方法,使得包装类释放native资源,不释放底层流;
使用匿名内部类的优点: 1. 代码简洁性
- 如果某个类只在一个地方使用,定义独立的类会增加代码复杂度
- 匿名内部类将定义和使用合二为一,代码更紧凑
2. 作用域限制
- 匿名内部类只在定义它的方法或代码块中可见
- 避免了类的命名空间污染
3. 闭包特性
- 匿名内部类可以访问外部类的final变量
- 可以捕获方法中的局部变量(Java 8+ 可以访问 effectively final 变量)
示例:
java
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):
java
// 装饰器基类
FilterInputStream (装饰器)
↓
FileInputStream (被装饰的对象)
装饰器模式的优势:
- 动态扩展功能:在不修改原有类的基础上,动态添加功能
- 组合优于继承:避免了类爆炸问题
- 职责分离:每个装饰器只负责一个功能
Java IO中的装饰器模式:
java
// Java IO 流体系中的装饰器模式
InputStream (抽象组件)
├── FileInputStream (具体组件)
├── FilterInputStream (装饰器基类)
│ ├── BufferedInputStream (具体装饰器)
│ ├── DataInputStream (具体装饰器)
│ └── PushbackInputStream (具体装饰器)
2.3 文件类型校验:为什么docx、xlsx,doc、xls需要特殊处理?
在 FileTypeDetector 中,我们看到对于某些文件类型,需要额外的校验:
java
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 文件头冲突问题
问题根源:
-
OOXML格式的文件头相同
.docx和.xlsx都是基于OOXML(Office Open XML)格式- 它们实际上都是ZIP压缩包,文件头都是
PK(0x50 0x4B 0x03 0x04) - 仅凭文件头无法区分是Word还是Excel
-
OLE格式的文件头相同
.doc和.xls都使用OLE(Object Linking and Embedding)格式- 文件头都是
D0 CF(OLE复合文档格式) - 同样无法仅凭文件头区分
文件头对比表:
| 文件类型 | 文件头(十六进制) | 格式类型 |
|---|---|---|
| 25 50 44 46 | PDF格式 | |
| DOCX | 50 4B 03 04 | ZIP格式(OOXML) |
| XLSX | 50 4B 03 04 | ZIP格式(OOXML) |
| DOC | D0 CF | OLE格式 |
| XLS | D0 CF | OLE格式 |
| PNG | 89 50 4E 47 | PNG格式 |
| JPG | FF D8 | JPEG格式 |
2.3.2 OOXML格式解析
OOXML文件结构:
.docx 和 .xlsx 文件实际上是一个ZIP压缩包,包含以下结构:
DOCX结构:
javascript
document.docx (ZIP)
├── [Content_Types].xml
├── _rels/
├── docProps/
└── word/
├── document.xml ← 关键特征文件
├── styles.xml
└── ...
XLSX结构:
scss
workbook.xlsx (ZIP)
├── [Content_Types].xml
├── _rels/
├── docProps/
└── xl/
├── workbook.xml ← 关键特征文件
├── worksheets/
└── ...
校验实现:
java
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开发的复合文档格式:
css
OLE Document
├── Header (512 bytes)
├── FAT (File Allocation Table)
├── Directory Entries
│ ├── WordDocument ← DOC文件的特征流
│ ├── Workbook ← XLS文件的特征流
│ └── ...
└── Data Streams
校验实现:
java
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:
java
ByteArrayOutputStream pdfMemoryStream = new ByteArrayOutputStream();
document.save(pdfMemoryStream);
return pdfMemoryStream;
2.4.1 ByteArrayOutputStream 是什么?
ByteArrayOutputStream 是Java IO包中的一个输出流类,它将数据写入内存中的字节数组缓冲区。
类继承关系:
java
java.lang.Object
└── java.io.OutputStream
└── java.io.ByteArrayOutputStream
核心特性:
- 内存操作:所有数据都存储在内存中
- 动态扩容:缓冲区大小自动增长
- 线程安全 :
synchronized关键字保证线程安全 - 零拷贝访问 :可以通过
toByteArray()直接获取字节数组
2.4.2 为什么返回 ByteArrayOutputStream?
1. 灵活性
java
// 调用方可以根据需要选择处理方式
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 方法中,我们看到这样的代码:
java
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文件结构:
java
ZIP文件
├── Local File Header (每个文件)
├── File Data (文件内容)
├── Data Descriptor (可选)
├── Central Directory (文件目录)
└── End of Central Directory Record
ZipInputStream 读取流程:
getNextEntry()- 定位到下一个ZIP条目,读取Local File Header- 读取条目数据(如果需要)
closeEntry()- 关闭当前条目,释放相关资源- 重复步骤1-3,直到所有条目读取完毕
2.5.2 为什么必须调用 closeEntry()?
1. 内存泄漏风险
如果不调用 closeEntry(),会发生什么?
java
// 错误示例:不关闭条目
while ((entry = zipIs.getNextEntry()) != null) {
String name = entry.getName();
// 没有调用 closeEntry()
// 继续读取下一个条目...
}
问题分析:
getNextEntry()会读取条目的元数据(文件名、大小、压缩方式等)到内存- 这些元数据会一直保存在
ZipInputStream的内部缓冲区中 - 如果不调用
closeEntry(),这些缓冲区不会被清理 - 在处理包含大量文件的ZIP时,会导致内存泄漏
2. 流位置错误
java
// ZipInputStream 内部维护当前条目的读取位置
// closeEntry() 会:
// 1. 跳过当前条目的剩余数据
// 2. 定位到下一个条目的开始位置
// 3. 清理当前条目的状态
如果不调用 closeEntry():
- 流的位置可能停留在当前条目的数据中间
- 下次调用
getNextEntry()时可能读取到错误的数据 - 导致ZIP文件解析失败
2.5.3 closeEntry() 的底层实现
ZipInputStream 内部状态:
java
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; // 释放条目引用
}
}
关键操作:
- 读取剩余数据:确保当前条目的所有数据都被读取
- 重置状态 :将
entryEOF设置为true - 释放引用 :将
entry设置为null,允许GC回收
2.6 临时文件管理:安全创建与清理策略
在 MergeFilesToPDFUtil 中,我们看到临时文件的管理:
java
String tempPath = url + "/" + num; // 临时目录路径
// ... 使用临时文件 ...
finally {
FileUtils.forceDelete(new File(tempPath)); // 清理临时文件
}
2.6.1 为什么需要临时文件?
使用场景:
- Office文档转换:Word/Excel需要先转换为PDF,再合并
- 中间结果存储:转换过程需要临时存储中间文件
- 流处理限制:某些库(如Aspose)需要文件路径而不是流
临时文件的生命周期:
创建临时目录 → 生成临时文件 → 使用临时文件 → 清理临时文件
2.6.2 临时文件命名策略
当前实现:
java
String num = System.currentTimeMillis() + "";
String tempPath = url + "/" + num;
分析:
- 使用时间戳作为目录名,确保唯一性避免并发冲突或者是多用户操作同一个文件的时候,误删其他进程使用中的临时文件
- 简单直接,但存在并发问题
2.6.3 更安全的临时文件管理
FileUtils.forceDelete() 的优势:
- 自动处理目录和文件
- 递归删除所有子目录和文件
- 异常处理更完善
2.6.4 临时文件清理的最佳实践
使用 try-finally 确保清理
java
File tempDir = null;
try {
tempDir = Files.createTempDirectory("pdf_").toFile();
// 使用临时目录
} finally {
if (tempDir != null && tempDir.exists()) {
FileUtils.forceDelete(tempDir);
}
}
2.7 finally块与资源释放:确保资源不泄漏
在代码中,使用了大量的 finally 块用于资源释放:
java
try {
// 业务逻辑
} finally {
if(document != null){
document.close();
}
closeIo(pdfdocuments);
FileUtils.forceDelete(new File(tempPath));
}
2.7.1 为什么需要 finally 块?
问题场景:
java
// 错误示例:没有finally块
PDDocument document = new PDDocument();
// ... 业务逻辑,可能抛出异常
document.close(); // 如果上面抛出异常,这行不会执行!
异常情况:
- 如果业务逻辑中抛出异常,
close()不会被执行 - 资源(文件句柄、内存等)不会被释放
- 导致资源泄漏
2.7.2 finally 块的执行保证
finally 块的特性:
- 总是执行:无论是否发生异常,finally块都会执行
- 执行顺序:在try块或catch块之后执行
- 异常不影响:即使finally块中抛出异常,也会执行
执行流程:
java
try {
// 1. 执行try块
// 2. 如果发生异常,跳转到catch
} catch (Exception e) {
// 3. 执行catch块
} finally {
// 4. 总是执行finally块
}
2.7.3 资源释放的最佳实践
方案1:try-finally(传统方式)
java
PDDocument document = null;
try {
document = new PDDocument();
// 业务逻辑
} finally {
if (document != null) {
document.close();
}
}
方案2:try-with-resources(推荐,Java 7+)
java
try (PDDocument document = new PDDocument()) {
// 业务逻辑
// 自动关闭,无需finally块
} // 这里自动调用 document.close()
try-with-resources 的要求:
- 资源必须实现
AutoCloseable接口 PDDocument实现了AutoCloseable,可以使用
方案3:多个资源的try-with-resources
java
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表示 |
|---|---|---|
| 25 50 44 46 | ||
| PNG | 89 50 4E 47 0D 0A 1A 0A | .PNG.... |
| JPEG | FF D8 FF | ÿØÿ |
| GIF | 47 49 46 38 | GIF8 |
| ZIP | 50 4B 03 04 | PK.. |
| OLE | D0 CF 11 E0 | ÐÏ.à |
3.1.2 为什么使用Magic Number而不是扩展名?
扩展名的局限性:
- 可被修改:用户可以随意修改文件扩展名
- 不唯一:不同格式可能使用相同扩展名
- 不可靠:某些系统可能没有扩展名
Magic Number的优势:
- 难以伪造:修改文件头会导致文件损坏
- 唯一性强:每种格式都有独特的签名
- 跨平台:不依赖操作系统
3.1.3 实现原理
java
// 读取文件头
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合并实现
java
// 创建目标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实现
java
// 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机制
java
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实现
java
Document doc = new Document(inputStream);
doc.updatePageLayout(); // 更新页面布局,确保格式正确
doc.save(pdfOutputStream, SaveFormat.PDF);
updatePageLayout() 的重要性:
- Word文档在转换前可能没有完全渲染页面布局
- 调用此方法会触发页面布局计算
- 确保转换后的PDF格式与Word文档一致
3.3.4 空白页处理
java
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文件大小,提升用户体验