Officia.Pdf — PDF 读写、合并拆分、加密、数字签名与转 Word
能力摘要(AI 友好):
OfficiaPdf读取/抽取文本与图片、合并拆分、页码/水印/旋转、加密解密(RC4 与 AES-256,decrypt可导出未加密副本),并提供链式edit()/edit(pdf, password)一次完成多步编辑;sign()加数字签名,让阅读器打开即显示文档是否被篡改;toWord()把电子版 PDF 转成 Word(默认FLOW模式:真w:tbl表格 + 标题大纲、可自由编辑,版式按原文实测值还原;toWordEditable()与默认等价,toWordPreserveLayout()出绝对定位文本框、版式优先但不可编辑);toImages()转成每页一张的图片供浏览器直接预览。门面静态方法,纯 JDK 实现,依 PDF 32000 规范。
引入
<dependency>
<groupId>plus.ruoyi</groupId>
<artifactId>officia-all</artifactId>
<version>1.0.0</version>
</dependency>这是唯一需要引的依赖:Maven Central 上只发布
officia-all这一个构件, 它是含全部能力模块与授权客户端的单一 jar,且无任何第三方传递依赖。
合并 / 拆分
import plus.ruoyi.officia.pdf.OfficiaPdf;
import java.util.List;
byte[] merged = OfficiaPdf.merge(List.of(pdfA, pdfB)); // 合并
OfficiaPdf.mergeToFile(List.of(pdfA, pdfB), new File("out.pdf")); // 合并直接写盘
List<byte[]> pages = OfficiaPdf.split(pdf); // 每页拆成一份抽取 / 删除页(页索引可变参,从 0 开始)
byte[] sub = OfficiaPdf.extractPages(pdf, 0, 2, 4); // 抽取第 1、3、5 页
byte[] rest = OfficiaPdf.removePages(pdf, 1); // 删除第 2 页
extractPages/removePages接受int...页索引(0-based),可传任意多页,非连续也行。
读取 / 抽取
int n = OfficiaPdf.pageCount(pdf);
int n2 = OfficiaPdf.pageCount(pdf, "openPwd"); // 加密 PDF 传口令
String text = OfficiaPdf.extractText(pdf); // 也有 (pdf, password) / (InputStream) / (File) 重载
List<String> perPage = OfficiaPdf.extractTextByPage(pdf);
List<byte[]> images = OfficiaPdf.extractImages(pdf);
List<float[]> sizes = OfficiaPdf.pageSizes(pdf); // 每页 [宽, 高](pt)
String ver = OfficiaPdf.version(pdf); // 如 "1.7"
boolean locked = OfficiaPdf.isEncrypted(pdf);抽图片:想知道有没有漏抽用 extractImagesDetailed
extractImages 只给能解出来的图。当前支持 DCTDecode(JPEG) 与 FlateDecode, 遇到 JBIG2Decode / CCITTFaxDecode / JPXDecode(老书数字化常见)会跳过—— 直接用 extractImages 的话,"这份 PDF 只有 3 张图"和"有 10 张但 7 张没解出来"看起来一模一样。
PdfReader.ImageExtraction r = OfficiaPdf.extractImagesDetailed(pdf);
r.images(); // 成功抽出的图
r.skippedCount(); // 被跳过几张
r.hasSkipped(); // 结果是否不完整
r.describeSkipped(); // 人可读说明,可直接写日志或提示用户元数据
PdfMetadata meta = OfficiaPdf.metadata(pdf); // 也有 (pdf, password) 重载
// 写:读写共用 PdfMetadata,键名由编译器保证(1.1.2 起不再收 Map)
byte[] tagged = OfficiaPdf.setMetadata(pdf,
new PdfMetadata().title("合同").author("Officia"));
// 读出来改几项再写回
byte[] patched = OfficiaPdf.setMetadata(pdf,
new PdfMetadata(meta.all()).subject("补充主题"));
// 自定义 Info 键(PDF32000 §14.3.3 允许)
byte[] custom = OfficiaPdf.setMetadata(pdf, new PdfMetadata().set("Company", "若依科技"));单步编辑
byte[] rot = OfficiaPdf.rotate(pdf, 90); // 旋转 90°
byte[] wm = OfficiaPdf.watermark(pdf, "机密"); // 默认字体水印
byte[] wm2 = OfficiaPdf.watermark(pdf, "机密", simsunTtf); // 指定中文字体(byte[])
byte[] pn = OfficiaPdf.addPageNumbers(pdf, "第 {page} 页 / 共 {total} 页");水印
上面的 watermark(pdf, text) 样式是固定的(页心、45°、半透明灰)。需要自定义样式、 整页平铺或用图片当水印时,用 WatermarkOptions:
import plus.ruoyi.officia.render.image.WatermarkOptions;
// 防泄密溯源水印:姓名 + 时间戳,整页平铺
byte[] out = OfficiaPdf.watermark(pdf,
WatermarkOptions.text("张三 {datetime}")
.tile(true)
.fontSizePt(13)
.opacity(0.13f)
.rotationDegrees(-30f)
.colorRgb(0x9AA0A6)
.tileGapRatio(1.5f),
simsunTtf);
// 印章 / LOGO:PNG 的透明通道会保留,不带白底
byte[] stamped = OfficiaPdf.watermark(pdf,
WatermarkOptions.image(sealPng).imageWidthPt(120f));| 选项 | 含义 | 默认 |
|---|---|---|
text(String) / image(byte[]) | 文字水印 / 图片水印(二选一) | — |
tile(boolean) | 整页平铺 | false |
tileGapRatio(float) | 平铺间距系数,越大越稀疏(1.0~8.0) | 1.6 |
fontSizePt(float) | 字号 | 自动 |
imageWidthPt(float) | 图片显示宽(高按原图比例) | 自动 |
colorRgb(int) | 颜色 0xRRGGBB | 0x808080 |
opacity(float) | 不透明度 | 0.15 |
rotationDegrees(float) | 旋转角 | -38 |
behindText(boolean) | 画在正文之下 | false |
annotation(boolean) | 走水印注解而非内容流 | false |
文字支持动态变量,逐页替换:{page} {pages} {date} {time} {datetime}。
溯源水印不要开 annotation
内容流里的水印与正文是同一层字节,去掉等同于重排页面;注解是独立对象,阅读器能隐藏、 编辑器能删除。annotation(true) 适合「草稿」「待审」这类提示性水印——它的好处是打印时 按固定尺寸位置输出、不随纸张缩放。
中文水印必须传字体
文字含中文却没传 fontTtf 时会直接报错,而不是静默把水印画成一串问号—— 水印多是安全或合规措施,失效而不报警比报错更危险。
加密(含 AES-256)
byte[] enc = OfficiaPdf.encryptAes256(pdf, "openPwd", "ownerPwd"); // AESV3,推荐
byte[] enc2 = OfficiaPdf.encrypt(pdf, "openPwd", "ownerPwd"); // 标准安全处理器(默认 128 位 RC4)
byte[] enc3 = OfficiaPdf.encrypt(pdf, "openPwd", "ownerPwd", 40); // 指定位数:40 / 128 / 256- 用户口令(userPassword):打开文档需要的口令。空串表示无需口令即可打开,但内容仍加密。
- 拥有者口令(ownerPassword):权限口令,可空(空则等同用户口令)。
- 两个口令都能用于打开文档——读取或解密时任传其一即可,RC4 与 AES-256 行为一致。
bits只接受40/128/256,其它值抛OfficiaException(不会静默降级)。
解密与编辑加密文档
加密 PDF 只能读取,要做任何编辑(水印/页码/合并/删页)必须先解密:
byte[] plain = OfficiaPdf.decrypt(enc, "openPwd"); // 导出未加密副本;未加密文档为恒等操作
byte[] out = OfficiaPdf.edit(enc, "openPwd") // 带口令直接进入链式编辑
.keepPages(0, 1)
.watermark("REVIEWED")
.encryptAes256("newPwd", "newOwner") // 换口令重新加密
.toBytes();数字签名(防篡改)
给 PDF 加标准数字签名(PDF 32000 §12.8,adbe.pkcs7.detached)。签完的文档在 Adobe Acrobat 等阅读器里打开即显示防篡改状态,客户不需要额外装任何工具:
| 文档状态 | 阅读器显示 |
|---|---|
| 未被改动 | ✅ 「自应用本签名以来,"文档"未被修改」 |
| 改动过哪怕一个字节 | ❌ 「自应用"签名"以来,"文档"已被更改或损坏」 |
签署人、签署时间、签署原因显示在阅读器的签名面板——审批留痕不必另造私有格式。
import plus.ruoyi.officia.pdf.OfficiaPdf;
import plus.ruoyi.officia.pdf.sign.KeyMaterial;
import plus.ruoyi.officia.pdf.sign.SignOptions;
// ① 签名身份:已有企业证书用 .p12
KeyMaterial id = KeyMaterial.fromPkcs12(p12Bytes, "口令");
// 或现场生成自签名身份(内部审计场景)
KeyMaterial id2 = KeyMaterial.selfSigned("张三", "某某公司", 3650);
// ② 签名
byte[] signed = OfficiaPdf.sign(pdf, id);
byte[] signed2 = OfficiaPdf.sign(pdf, id, SignOptions.defaults()
.name("张三")
.reason("部门经理审批通过")
.location("北京·财务部")
.contactInfo("zhangsan@example.com") // 写入 /ContactInfo
.fieldName("Signature1")); // 会签时用它区分签名域KeyMaterial.fromPkcs12 另有 (byte[], 口令, 别名)、(File, 口令)、(InputStream, 口令) 重载; 不指定别名时取容器内第一个带私钥的条目。证书主体名支持中文。
证书链会整条嵌入
CA 签发的证书通常是「你的证书 → 中间 CA → 根 CA」一条链,officia 取 .p12 里的完整链 一并写进 PKCS#7。只送签署人证书的话,验证方本地若没有那张中间 CA 就接不到受信任的根, 正版证书也会被判"身份未知"。所以向 CA 索取证书时,要的是含完整链的 .p12。
.p12 口令必须是 ASCII
JDK 的 PKCS#12 实现不接受非 ASCII 口令,用中文口令会抛 UnrecoverableKeyException: Password is not ASCII。这是 JDK 的限制而非 officia 的, 但国内用户习惯用中文密码,向 CA 申请证书或自行导出 .p12 时请注意。
统一社会信用代码放在证书主体的 serialNumber 字段(OID 2.5.4.5)——这是国内 CA 的惯例, 验签方与阅读器据此核对签发主体的工商登记身份。自建 CA 时用带该参数的重载写入(见下方「私有 CA」)。 欧盟 eIDAS 用的是 organizationIdentifier(2.5.4.97),二者语义相近但国内认前者。
从自签名换成 CA 证书,代码一行都不用改——把 KeyMaterial.selfSigned(...) 换成 KeyMaterial.fromPkcs12(证书, 口令) 即可。二者在 PKCS#7 层面完全一样, 区别只在证书自己的签发者是谁。
不用 CA 也能防篡改
数字签名解决两个互相独立的问题:
| 问题 | 靠什么保证 | 需要 CA 吗 |
|---|---|---|
| 文档有没有被改过 | 哈希 + 私钥签名(纯数学) | ❌ 完全不需要 |
| 签字的人是不是他自称的人 | 证书信任链 | ✅ 需要信任锚 |
CA 只解决第二个。所以自签名证书的防篡改能力不打折,差别仅在阅读器会提示 "签署人身份未知"(黄色警示),而下面那行"文档未被修改"照样是绿的。 把根证书导入阅读器的受信任身份列表即可消除该提示。
法律效力边界
我国《电子签名法》中的"可靠电子签名"要求由第三方认证机构签发的证书。 自签名方案在内部审计与溯源追责上完全有效;若涉及对外合同、可能上法庭举证, 请使用有资质 CA 签发的证书。
三种证书怎么选
签名证书要解决的从来只有一个问题:验证方凭什么相信"张三"就是张三。 按信任范围从窄到宽有三档,成本与体验也依次不同:
| 方案 | 验证方看到 | 适用 | 成本 |
|---|---|---|---|
自签名(KeyMaterial.selfSigned) | ⚠️ 身份未知 + ✅ 未被修改 | 试用、内部溯源 | 0 |
| 私有 CA(自建根证书 + 内网分发) | ✅ 绿勾(限装了根证书的机器) | 企业内网审批 | 一次性搭建 |
| 公共 CA 证书(向持牌机构购买) | ✅ 绿勾(任何人打开都是) | 对外合同、法律举证 | 年费 |
三档的防篡改能力完全相同——差别只在"身份可信到什么范围"。
证书必须由签署主体自己申请
CA 签发前要做实名认证(企业验营业执照与对公账户,个人验身份证与人脸), 证书上的 CN=某某公司 就是这么来的。谁签字就得谁去申请, 就像公章必须公司自己去刻——软件供应商代办不了这一步。
购买时的两个常见错误
- 买错类型:要的是「文档签名证书」或「电子签章证书」, 不是 SSL/HTTPS 证书。二者用途完全不同,不说清楚容易被卖错。
- 忽略 AATL:普通 CA 证书 Adobe 仍会显示黄标(它不认识这家 CA); 只有 AATL(Adobe Approved Trust List)成员签发的证书, Adobe 打开才直接绿勾、验证方零操作。选型时直接问 CA 销售 "贵司是否在 Adobe 的 AATL 名单内",或到 Adobe 官网核对该名单。
国内的持牌机构以工信部《电子认证服务许可证》公示名单为准(资质有新增也有吊销, 请以官网当期名单为准,不要沿用二手清单)。
内网场景优先考虑私有 CA
企业内部审批(如"三级会签")通常不需要买公共 CA 证书:自建一个私有 CA, 把根证书通过域策略推送到内部机器,之后内部所有人打开都是绿勾,且没有年费。 代价是这份信任不出内网——文件发给外部单位,对方仍会看到"身份未知"。
建议路径:自签名跑通流程 → 内网上线用私有 CA → 确有对外举证需求时才买公共 CA 证书。
🔴 签名必须是最后一步
签完之后对字节的任何改动都会使签名失效——加水印、加页码、加密、合并、再编辑都不行。 推荐顺序:转换 → 加工 → 加密 → 签名。
byte[] out = OfficiaPdf.edit(pdf)
.watermark("内部资料", simsunTtf)
.encryptAes256("open", "owner")
.toBytes();
byte[] signed = OfficiaPdf.sign(out, id); // 签名独立调用,放最后正因如此,sign 刻意不做成链式方法:链式每步都会重写全文,签名置于中段必然失效。
多人依次签字
给已签名的 PDF 再签,会自动转走增量更新(PDF 32000 §7.5.6):原文件字节一个都不动, 新签名只追加到尾部——前面每个人的签名因此保持有效。
byte[] s1 = OfficiaPdf.sign(pdf, zhangSan, SignOptions.defaults()
.name("张三").reason("部门经理审批").fieldName("Signature1"));
byte[] s2 = OfficiaPdf.sign(s1, liSi, SignOptions.defaults()
.name("李四").reason("财务复核").fieldName("Signature2"));
byte[] s3 = OfficiaPdf.sign(s2, wangWu, SignOptions.defaults()
.name("王五").reason("总经理批准").fieldName("Signature3"));Adobe 签名面板会列出三条「修订版 1 / 2 / 3」,各自显示签署人、时间、原因与"文档未被修改"。
每人的 fieldName 必须不同
SignOptions 的 fieldName 默认都是 Signature1。多人签字时若不区分,表单域重名, 阅读器可能只认出其中一个签名。建议按签署顺序编号或用业务角色名。
每次加签会追加一个完整的增量段(约 17 KB),文件随签字人数线性增长。这是增量更新的 固有代价,也正是"前签不被破坏"的前提。
私有 CA:内网统一签发
自建根 CA,把根证书装进内网各机器,之后内网任何人打开都是绿勾、且没有年费—— 这是消除"身份未知"提示里成本最低的一条路。
import plus.ruoyi.officia.pdf.sign.CertAuthority;
CertAuthority ca = CertAuthority.createRoot("某某公司内部CA", "某某公司", 3650);
byte[] rootCer = ca.exportRootCertificate(); // 分发到内网各机器
byte[] caP12 = ca.exportPkcs12("StrongPwd2026");// 离线保管,日后 CertAuthority.load 恢复
KeyMaterial zhang = ca.issue("张三", "研发部", 365); // 自带完整证书链
byte[] signed = OfficiaPdf.sign(pdf, zhang, SignOptions.defaults().name("张三"));
// 带统一社会信用代码的重载(写入 subject serialNumber,国内 CA 惯例)
CertAuthority ca2 = CertAuthority.createRoot("某某公司内部CA", "某某公司",
"91440300MA5XXXXXXX", 3650);
KeyMaterial li = ca2.issue("李四", "财务部", "91440300MA5XXXXXXX", 365);根证书的安装:Windows 域用组策略推送到「受信任的根证书颁发机构」; Adobe 另需导入其「受信任身份」列表(编辑 → 首选项 → 签名 → 身份与可信证书)。
CA 私钥是内网信任的总钥匙
泄露则任何人都能签发被内网信任的证书、冒充任意签署人。应离线保存或存入硬件密钥模块, 签发在受控机器上进行,绝不放进应用服务器或版本库。
这份信任不出内网:文件发给外部单位,对方机器没装根证书仍显示"身份未知"。 私有 CA 只做建根与签发终端证书,不含证书吊销(CRL/OCSP)、密钥托管、自动续期、 多级中间 CA;根证书 pathLen=0,结构上锁死为两层。
可信时间戳(RFC 3161)
不盖时间戳时,签名里的时间取自签名机本地时钟、可被签署人篡改。盖了之后由第三方 TSA 背书。
SignOptions opts = SignOptions.defaults()
.name("张三")
.timestampProvider(req -> {
// officia 已构造好 TimeStampReq,你只管发出去
// POST,Content-Type: application/timestamp-query
return yourHttpClient.post("https://tsa.example.com/tsr", req);
});officia 不做这次网络调用
它是离线库,只构造请求、解析并嵌入响应,HTTP 交给你——业务系统本就有成熟的网络栈、 代理配置与重试策略。另一个原因是顺序:时间戳盖的是签名值,签名值必须签完才存在, 所以不可能"先拿好 token 再签名"。回调抛异常时签名会明确失败而非静默跳过。
验证签名
OfficiaPdf.verify 是签名的对侧:让业务系统程序化判定一份文档是否仍然可信, 不必让人打开 Acrobat 肉眼看。
SignatureVerification v = OfficiaPdf.verify(pdf);
if (!v.isSigned()) {
reject("这份文件没有签名");
} else if (v.isValid()) {
for (SignatureInfo s : v.getSignatures()) {
log.info("{} 于 {} 签署,理由:{}",
s.getSignerName(), s.getSigningTime(), s.getReason());
}
} else {
log.warn("签名校验未通过:{}", v.getSummary());
}判文档可信一律看 v.isValid(),不要自己遍历各签名取与
整体结论额外查了一条:最末一个签名的覆盖区是否延伸到文件末尾。 签完之后被追加的内容不受任何签名保护,却会照常显示给读者—— 此时每个签名单独验都是"有效",漏掉这一条会得到危险的假绿。 v.getUnsignedTailBytes() 给出被追加的字节数。
验证 ≠ 信任。默认只做密码学验证;要一并核验签署人身份,传入受信任的根证书:
X509Certificate root = (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(rootCerBytes));
SignatureVerification v = OfficiaPdf.verify(pdf, List.of(root));
boolean ok = v.isValid() && v.isTrusted(); // 内容没被改 + 签署人可信SignatureInfo 的四项独立结论,各查一段、互不替代:
| 方法 | 回答什么 | false 意味着 |
|---|---|---|
isDigestMatch() | 被签内容还是原样吗 | 签名后有人改了正文 |
isSignatureMatch() | 摘要声明真是持私钥者作出的吗 | 签名值被替换或容器被改造 |
isCertificateIntact() | 证书本身没被动过吗 | 证书被冒名改写 |
isCoveringWholeDocument() | 覆盖区到文件尾了吗 | 多签时前序签名如此属正常 |
isCertificateIntact 为什么不能省
签名值签的是 signedAttrs,证书只是承载公钥的容器。篡改者若只改证书里的主体名 (把"张三"改成"李四"),公钥、signedAttrs、签名值三者都没动,前两项检查照样全绿—— 只有验证证书自身被其签发者签的那道签名才能发现。isValid() 已包含这一项。
其余可读字段:getFieldName()、getSignerName()、getSigningTime()、getTimestampTime()、 getReason()、getLocation()、getContactInfo()、getSignerCertificate()、 getCertificateChain()、getSubject()、getIssuer()、getProblem()。
签名时间取自哪里
getSigningTime() 优先取 PKCS#7 signedAttrs 里的时间(在签名覆盖范围内,改不了), 缺失时才退回签名字典的 /M(不在保护内)。两者都来自签名机本地时钟; 要不可否认的时间看 getTimestampTime()(第三方 TSA 出具)。
验签不受授权门控:它是纯读取操作,且要验的往往是别人发来的文档。
当前限制
| 场景 | 状态 |
|---|---|
| 可见签章(页面上的红章 / 手写签名图) | ❌ 当前为不可见签名,只在签名面板可见 |
| 验签时的证书吊销检查(CRL / OCSP) | ❌ verify 不查吊销 |
| 只嵌叶子证书时的证书完整性判定 | ⚠️ 容器里没有签发者证书就验不了那一项,此时 isCertificateIntact() 返回 true("没验成"不等于"验失败"),改由信任锚兜底 |
| 原文档的书签 / XMP 元数据 | ⚠️ 仅首次签名有此影响(走全量重写,与 watermark 等一致);后续加签走增量更新,原文档分毫不动 |
| 证书吊销(CRL / OCSP) | ❌ 私有 CA 不提供;签发出的证书在有效期内无法作废 |
私钥是整套机制的命门
私钥泄露等同于任何人都能伪造"未被篡改"的签名。签名操作必须在服务端执行, 私钥不下发客户端;.p12 口令进配置中心或密钥管理,不写进代码、不入版本库。
链式编辑 edit()
对同一份 PDF 做多步操作时,用链式编辑器一次完成(每步返回 this,toBytes() / writeTo() / toFile() 终结):
byte[] out = OfficiaPdf.edit(pdf)
.watermark("机密", simsunTtf) // 水印
.pageNumbers("第 {page} 页 / 共 {total} 页", simsunTtf) // 页码(注意方法名是 pageNumbers)
.rotate(90) // 旋转
.append(anotherPdf) // 追加另一份 PDF
.keepPages(0, 1, 2) // 只保留前 3 页
.encryptAes256("open", "owner") // 加密放最后
.toBytes();链式方法:watermark(String 或 WatermarkOptions,均可再带 fontTtf)/ pageNumbers / rotate / keepPages / removePages / append / metadata / encrypt / encryptAes256;终结:toBytes() / writeTo(OutputStream) / toFile(File)。
PDF → 图片
把 PDF 转成每页一张的图片,用于文档在线预览 —— 图片在任何浏览器与移动端 webview 里都能直接显示,不依赖 PDF 阅读器。
List<byte[]> pages = OfficiaPdf.toImages(pdf); // 顺序即页码
// 加水印
List<byte[]> pages = OfficiaPdf.toImages(pdf,
ImageRenderOptions.defaults()
.watermark(WatermarkOptions.text("内部资料").tile(true)));自动选路,调用方不用关心文档是什么类型:
| 文档类型 | 走哪条路 | 特点 |
|---|---|---|
| 扫描件(整页是图) | 抽图快路 | 又快又无损 —— 无需重编码时原样输出 |
| 电子版 / 图文混排 | 通用渲染 | 解析内容流后重画 |
扫描件默认跟随源图,不做无谓转换
- 分辨率保持原始像素(通常 200–300 DPI)。只有显式调过
dpi(...)才重采样。 - 格式跟随源图(源是 JPEG 就出 JPEG)。只有显式调过
format(...)才换 —— 实测一份 29 页扫描书硬转 PNG 是 226 MB,跟随源格式只有 38 MB。
走通用渲染时则按 dpi(默认 96)光栅化,格式默认 PNG。
用标准 14 号字体的 PDF 不会叠字
Helvetica / Times-Roman / Courier / Symbol / ZapfDingbats 按规范允许不写 字宽表,要求阅读器自带这些度量。officia 用度量兼容的替换族量宽并绘制 (Helvetica → Arial、Times-Roman → Times New Roman、Courier → Courier New —— 这几款当初就是照着彼此的度量设计的),版面对得上。
宽度仍属近似,且取决于本机可用字体。(1.0.0 之前有此缺陷:一行字全压在同一位置。)
表单类 PDF(对账单 / 申报表 / 回执单)也读得对
这类文档的字段值画在表单域外观流里、字体登记在它自己的资源字典中。officia 展开时会把这些字体一并纳入解码与字形查找,字段值不会退化成乱码。
(1.0.0 之前有此缺陷:双字节字符编码会被逐字节误读,公司名之类的字段抽出来是乱码。)
老 PDF 也读得了
流过滤器覆盖 FlateDecode / LZWDecode / ASCII85Decode / ASCIIHexDecode / RunLengthDecode。LZW 对 1990 年代前后的 PDF 很关键 —— FlateDecode 到 PDF 1.2 才引入,更早的生成器只有 LZW 可用。实测一份 1988 年的 ITU-T 规范(PDF 1.2, 正文内容流全是 LZW)可正常抽文字、转 Word 与转图片。
字形:内嵌 TrueType 走精确,其余走近似
这是转图片保真度的第一决定因素。21 份真实 PDF、238 个去重字体实测:
| 源 PDF 的字体 | 字形 | 占比 |
|---|---|---|
内嵌 TrueType(FontFile2) | 精确 —— 直接光栅化 PDF 里那份字体程序 | 约 50% |
| 内嵌 CFF/Type1 | 近似 | 约 19% |
| 完全没内嵌(只声明字体名) | 近似(这是唯一可能,Adobe 同样如此) | 约 31% |
近似 = 用 officia 自己的字体栈画字,笔画细节与原文件不同,但版面位置精确 (按 PDF 声明宽度横向缩放对齐)。两档在同一份文档里可以混着出现,逐片段自动选。
extractText 是乱码,转出来的图却是对的 —— 这不矛盾
两者走的是不同的信息链。精确字形走 码 → GID → 内嵌轮廓,不经过 ToUnicode; 而 ToUnicode 是可选的、只影响能不能复制文本,生成器常写错。 别拿文本抽取的结果推断渲染结果。
其余已知失真
- 绘制次序按类别而非内容流原序(底纹→图案→图像→线→文字)。绝大多数文档文字在 最上层,这个近似成立;刻意把图盖在字上的会失真。
- 走近似字形的那部分,若源文件
ToUnicode表本身错,乱码会被忠实反映。 - 裁剪路径(
W/W*)已支持:填充色块按裁剪区精确裁切,图像与线条整块剔除; 非矩形裁剪路径按包围盒近似(宁可多画,不裁掉该显示的)。文本不按裁剪区剔除。 - 不支持内联图像(
BI/ID/EI只跳过、不解码,该处不画图)与渐变(sh)。 这是取证后的取舍:12 份真实 PDF、9149 个内容流的算子普查里,这两个各出现 0 次 (同批样本里裁剪算子出现 76 万次)。若你的文档确实用到,欢迎反馈样本。 - 不支持透明组与混合模式。
若源文件本是 Word/Excel/PPT,直接 OfficiaWords.toImages(docx) —— 不经 PDF 这一道, 保真更好。
性能参考(实测,96 DPI,含解析,评估态限 30 页):源文档 46 页出图 4.8 s, 580 页 6.9 s,1553 页 12.9 s,5039 页 59 s。
耗时主要由「实际渲染的页有多密」决定,与文档总页数只弱相关 —— 简单页约 160 ms/页,塞满表格的密集页可达 2 s/页。精确字形比近似字形慢约 15%。 扫描件快路是另一个量级 —— 29 页 0.2 s。
以你自己的样本实测为准,别照搬上表。
该用哪条路
| 你手上的 PDF | 用什么 |
|---|---|
| 扫描件(整页是图) | OfficiaPdf.toImages —— 走快路 |
| 电子版(有文字层) | OfficiaPdf.toImages —— 走通用渲染,注意上面的失真说明 |
| 源文件是 Office | OfficiaWords.toImages 等,别经 PDF |
| 扫描件且要文字 | ScannedPdfConverter(OCR,见 Officia.OCR) |
PDF → Word
把电子版 PDF 转成可编辑的 .docx。
// 默认:版式贴合 + 完全可编辑(真表格、标题大纲、普通段落)
byte[] docx = OfficiaPdf.toWord(pdf);
byte[] docx = OfficiaPdf.toWord(pdf, "口令"); // 加密 PDF
byte[] docx = OfficiaPdf.toWord(new File("in.pdf"));
byte[] docx = OfficiaPdf.toWord(inputStream);
OfficiaPdf.toWord(new File("in.pdf"), new File("out.docx")); // 直接写盘
// 与默认等价,语义更显式
byte[] docx = OfficiaPdf.toWordEditable(pdf);
OfficiaPdf.toWordEditable(new File("in.pdf"), new File("out.docx"));
// 要绝对坐标、不打算编辑时用
byte[] docx = OfficiaPdf.toWordPreserveLayout(pdf);选项
import plus.ruoyi.officia.pdf.word.WordConvertOptions;
byte[] docx = OfficiaPdf.toWord(pdf, WordConvertOptions.defaults()
.setPassword("open")
.setExtractImages(true) // 嵌入图片(默认 true)
.setExtractVectors(true) // 表格线 / 底纹(默认 true)
.setRasterizeVectorPatterns(true)); // 公章 / 艺术字光栅化(默认 true)还原范围
文字、加粗、字体名、逐行缩进(含中文首行缩进 2 字符)、图片(含透明通道)、 表格线与单元格底纹、表格单元格切分、公章与艺术字(光栅化为位图)。
两点必须先说清楚
① 扫描件走另一条路径。 图片型 PDF 没有文字层,toWord 会抛出异常并说明需要 OCR, 不会返回一个空白文档。这不是缺陷——扫描件有专门的入口 ScannedPdfConverter:
import plus.ruoyi.officia.ocr.recog.BuiltinModels.Language;
import plus.ruoyi.officia.ocr.scan.ScannedPdfConverter;
byte[] docx = new ScannedPdfConverter()
.language(Language.CHINESE) // 必填
.toWord(pdfBytes);
// 老书常见版面:扫描时页面横放、两页并排
byte[] docx = new ScannedPdfConverter()
.language(Language.CHINESE)
.rotate(ScannedPdfConverter.Rotation.CLOCKWISE_90)
.splitFacingPages(true) // 按最大空白带定位中缝,不是对半切
.minConfidence(0.01) // 滤掉插图/印章被误当文本行的噪声
.toWord(pdfBytes);语种必须显式指定
.language(...) 不填会直接抛异常。OCR 不做语种自动判别,默认值是英文—— 中文扫描件走英文模型时,识别器在固定字符集上永远给出某个结果, 于是吐出满篇拉丁字母,不报错、置信度还不低。与其让你在客户投诉时才发现, 不如在这里响亮地失败。
中文已支持(自研 CRNN-lite,3885 类,随包内置 int8 权重)。
质量按印刷年代分层
实测(2026-08-24,七本真实扫描书):
| 样本 | 结果 |
|---|---|
| 2004 年胶印书 | 正文页平均置信度 0.961,基本逐字准确;错误集中在生僻字与封面美术字 |
| 1990 年代铅印书 | 召回 69.6%,能读懂大意,不宜当作可直接交付的转录 |
差距来自字形域差(老铅字的墨色扩散、纸张透印在训练分布外),不是缺陷。 交付前请用你自己的样本实测,别按单一数字预期。
🔴 版面参数配错不会报错
这是最容易踩的坑:rotate / splitFacingPages 配错时 OCR 照常返回, 只是内容全是怪字——识别器没有「认不出」这一档。实测同一本对开扫描书, 同一份权重,只差版面参数:
| 参数 | 产物 |
|---|---|
| 不给 | 5.9 KB,全是乱码 |
rotate + splitFacingPages | 285.8 KB,9330 段、17.6 万字可读 |
每本书都要先探版面:转两三页看看输出是不是人话,再决定整本怎么转。
能力边界
| 场景 | 状态 |
|---|---|
| 版面还原 | 只做「行 → 段落」的线性还原,不还原表格、多栏、图文混排 |
| 竖排文字 | ❌ 不支持。切分层按横排设计(水平投影切行),竖排页面整页只切出 1 行 |
| 两页并排 | ⚠️ 必须显式开 splitFacingPages(true),否则静默产出垃圾 |
| 页面扫描黑边 | ✅ 自动剥离;但装订阴影呈渐变且不贯穿时仍可能残留 |
try {
byte[] docx = OfficiaPdf.toWord(pdf);
} catch (OfficiaException e) {
// 消息已包含失败原因与下一步建议,可直接展示给终端用户
}② 默认就兼顾版式与可编辑。
toWord(FLOW,默认) | toWordPreserveLayout(TEXTBOX) | |
|---|---|---|
| 正文 | 普通段落,改字会重新断行 | 每块一个绝对定位文本框 |
| 表格 | 真 w:tbl,可插入行列、拖列宽 | 线画成形状,不能插入行列 |
| 标题 | 带大纲级别,导航窗格可用、能一键插入目录 | 无 |
| 版式贴合 | 回环 95 页 | 回环 94 页 |
实测 94 页招标文:默认模式还原出 13 张真表格(跨页续表已缝合,最长一张 63 行, 含横向与纵向合并)与三级标题大纲,体积 92 KB(绝对定位模式 234 KB)。 文字与原 PDF 的差额只有被有意剥离的页码与重复表头副本。
版式贴合靠的是把几何参数逐项按原文实测值还原:版心宽度(按最宽正文行反推)、 逐段行高(到下一段的基线距离)、段间留白、表格行高、单元格内边距、页边距。 1.1.4 之前这些没做全,同一份文档会被排成 122 页——这也是早期文档里 「版式与可编辑无法兼得」那句话的由来,该结论已被实测推翻。
跨页表格会自动缝成一张(判据是列边界一致且上表贴页底、下表贴页顶),重复表头只留一份并标 w:tblHeader 交给 Word 在分页处自动重复。
红头公文的红色分隔线、正文里的独立横线会还原成段落下边框(保留线宽与颜色),PDF 用扁矩形画的线同样识别。加密文档方面,/V 4 /R 4 配 /EncryptMetadata false(阅读器双击就能打开的那类)已支持。
双栏 PDF 会转成真正的 Word 分栏(w:cols):阅读顺序按栏走(左栏整栏排完才轮到右栏),横跨两栏的图题、大表单独切成一栏的连续分节。字体没有粗体变体时,PDF 常靠「同一处画两遍」来模拟加粗——这种重复会被识别、合并成一个并正确标成粗体(不认它的话,「第三条」会抽成「第第三三条条」)。西文的词间空隙在 PDF 里常常不是空格字符而是 TJ 数组的位移量,也已按几何间隔还原——不还原的话抽出来的正文会是 Figure1:Overallpre-training… 这样全部粘连。
版式还原程度(可核对):把产物再排成 PDF 与原文逐行比对,八份真实样本的横向偏移中位数全部为 0.0pt;纵向偏移中位数在半行以内(企业档案管理规定 1.9pt、114 页的 RFC 2616 0.2pt、56 页的询比采购文件 8.0pt)。页数方面档案、红头公文、政府工作报告与原文完全一致,篇幅长的会多出几页(询比 56→57、招标 94→99)。断行位置同样按原文还原——判断「上一行有没有排到版心右边界」,没排满的是硬换行、各自成段,排满的才并成段落交给 Word 重排,所以正文改字仍会重新流动。
页眉会写回 Word 的 header part,页眉下面那条分隔线还原成段落下边框——页眉只有一条线、一个字都没有的公文同样处理。实测一份 114 页的英文规范,重建后每页文字的纵向偏移从 37.5pt 收到 5.3pt。
页眉页脚里的页码会还原成 Word 的 PAGE 域,其余文字照搬——「第 X 页 共 Y 页」「- 3 -」「Fielding, et al ⇥ Standards Track ⇥ [Page 20]」都能原样出来(含制表位的三段式也保得住),而且重新分页后页码自动是对的。
论文插图会整块还原:这类图往往不是一张图,而是几十个矢量块加独立文字标签拼出来的(实测某篇论文的一张图由 101 条线 + 63 个矩形 + 96 条路径 + 105 个文字片段构成),会被识别成一个整体连同标签渲染成图片插入,横跨两栏的还会自动单独切成一栏的节。实测该论文 16 页转出后从 22 页降到 18 页。
已知缺陷:插图区域可能出现大片纯黑——由线段围成的填充路径目前被一律退化成它的包围盒,横贯全图的一条深色折线因此被涂成一大块;这同时影响 toImages 与转 Word 的插图。
已知局限:学术论文常用的三线表转不出真表格(表格识别靠横竖线构成的网格,三线表只有横线);插图识别要求图区里有曲线或斜线,纯直线画的流程图不算(这是为了保住表格——表格的线彼此同样紧挨,误判会让真表格变成一张图片);页眉页脚各只支持一种(取出现最多的模式),奇偶页不同眉的文档只还原其中一种;页码有偏移的文档(封面不计数)不还原页码,维持剥离;文字环绕会退化成顺序段落;原文把一个单元格的文字分两页画时,合并后仍是两行;浮动图片(公章等)按阅读顺序插入,不保留原坐标;栏数按整篇取主导值而非逐页分节;由几十个矢量块拼成的复杂合成图判不出"通栏",会挤进单栏、高度翻倍(实测一份 16 页的双栏论文转出后 22 页,差额全在这类页面上,另一份 15 页的论文转出后 16 页)。
方法一览
| 分类 | 方法 |
|---|---|
| 合并拆分 | merge / mergeToFile / split / extractPages(int...) / removePages(int...) |
| 读取抽取 | pageCount / pageSizes / version / extractText(多重载) / extractTextByPage / extractImages / extractImagesDetailed / isEncrypted |
| 元数据 | metadata / setMetadata |
| 单步编辑 | rotate / watermark / addPageNumbers |
| 链式编辑 | edit(pdf) / edit(pdf, password) → watermark / pageNumbers / rotate / keepPages / removePages / append / metadata / encrypt / encryptAes256 → toBytes |
| 加密解密 | encrypt(可选位数 40/128/256) / encryptAes256 / decrypt(pdf, password) |
| 数字签名 | sign(pdf, KeyMaterial) / sign(pdf, KeyMaterial, SignOptions);身份三条来源:KeyMaterial.fromPkcs12(byte[] / File / InputStream,可指定别名)、KeyMaterial.selfSigned、CertAuthority.issue |
| 私有 CA | CertAuthority.createRoot(cn, o[, 统一社会信用代码], days) / issue(cn, o[, 统一社会信用代码], days) / exportRootCertificate / exportPkcs12 / load |
| 时间戳 | SignOptions.timestampProvider(TimestampProvider)——officia 构造请求与嵌入响应,HTTP 由调用方完成 |
| 验证签名 | verify(pdf) / verify(pdf, List<X509Certificate> 信任锚) → SignatureVerification(isValid / isTrusted / getSummary / getSignatures) |
| 转 Word | toWord(byte[] / +options / +password / File / InputStream / File→File);toWordEditable(同上重载,与默认等价);toWordPreserveLayout(byte[] / +password / File) |
| 转图片 | toImages(pdf) / toImages(pdf, ImageRenderOptions) → List<byte[]>,扫描件与电子版均可(自动选路) |
常见问题
- 中文水印/页码乱码:传入覆盖所需汉字的 TTF 字节(
watermark(pdf, text, fontTtf))。 - 加密 PDF 读取:
extractText/pageCount/metadata都有(…, password)重载。 - 页索引越界:
extractPages/removePages的索引从 0 开始,越界抛OfficiaException。 toWord报"没有文字层":这是扫描件/图片型 PDF 的预期行为,不是缺陷;改用ScannedPdfConverter(见上)。- 转出的 Word 表格不能插入行列:只会出现在
toWordPreserveLayout(TEXTBOX)下——它把表格线画成了形状,属既定取舍。默认的toWord(FLOW)出的是真w:tbl,可插行列。