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

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 为什么使用匿名内部类?

首先是理清底层流和包装流的关系,以fileinputstreambufferinputstream为例子,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 文件头冲突问题

问题根源:

  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复合文档格式)
    • 同样无法仅凭文件头区分

文件头对比表:

文件类型 文件头(十六进制) 格式类型
PDF 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 读取流程:

  1. getNextEntry() - 定位到下一个ZIP条目,读取Local File Header
  2. 读取条目数据(如果需要)
  3. closeEntry() - 关闭当前条目,释放相关资源
  4. 重复步骤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;  // 释放条目引用
    }
}

关键操作:

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

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

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

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

2.6.1 为什么需要临时文件?

使用场景:

  1. Office文档转换:Word/Excel需要先转换为PDF,再合并
  2. 中间结果存储:转换过程需要临时存储中间文件
  3. 流处理限制:某些库(如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 块的特性:

  1. 总是执行:无论是否发生异常,finally块都会执行
  2. 执行顺序:在try块或catch块之后执行
  3. 异常不影响:即使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表示
PDF 25 50 44 46 %PDF
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而不是扩展名?

扩展名的局限性:

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

Magic Number的优势:

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

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文件大小,提升用户体验

相关推荐
又过一个秋1 小时前
CyberRT Transport传输层设计
后端
Java水解1 小时前
20个高级Java开发面试题及答案!
spring boot·后端·面试
Moe4881 小时前
合并Pdf、excel、图片、word为单个Pdf文件的工具类(拿来即用版)
java·后端
bcbnb1 小时前
手机崩溃日志导出的工程化方法,构建多工具协同的跨平台日志获取与分析体系(iOS/Android 全场景 2025 进阶版)
后端
Java水解2 小时前
为何最终我放弃了 Go 的 sync.Pool
后端·go
oliveira-time2 小时前
原型模式中的深浅拷贝
java·开发语言·原型模式
二川bro2 小时前
第41节:第三阶段总结:打造一个AR家具摆放应用
后端·restful
进阶的猿猴2 小时前
easyExcel实现单元格合并
java·excel
aiopencode2 小时前
苹果应用商店上架全流程 从证书体系到 IPA 上传的跨平台方法
后端