Skip to content

Commit 95572eb

Browse files
committed
feat(extract): sync hardening round (errors + report + guards + docs) from 3.0.x
1 parent 6c76837 commit 95572eb

10 files changed

Lines changed: 991 additions & 264 deletions

File tree

README.md

Lines changed: 161 additions & 116 deletions
Large diffs are not rendered by default.

README.zh-CN.md

Lines changed: 181 additions & 146 deletions
Large diffs are not rendered by default.

docs/USAGE.md

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
# easypdf 使用指南(USAGE)
2+
3+
> 面向使用场景的端到端指南;README 是能力总览,本文是上手路径。所有代码片段的签名逐一取自 3.0.x 源码(包 `io.github.easy4j.pdf.xhtml.convert`),中英双语注释。
4+
5+
## 目录
6+
7+
- [1. Markdown → PDF](#1-markdown--pdf)
8+
- [2. Tagged 无损往返:生成即保真](#2-tagged-无损往返生成即保真)
9+
- [3. 结构化提取:pdfToStructured / pdfToStructuredMarkdown](#3-结构化提取pdftostructured--pdftostructuredmarkdown)
10+
- [4. Agent 链路:summary → pageRange → chunked](#4-agent-链路summary--pagerange--chunked)
11+
- [5. 大文件:extractPerPage 页级流式与取消](#5-大文件extractperPage-页级流式与取消)
12+
- [6. Tier3 扩展点:接入 REST 布局服务](#6-tier3-扩展点接入-rest-布局服务)
13+
- [7. 错误分级:ExtractionException.Code 与 ExtractReport](#7-错误分级extractionexceptioncode-与-extractreport)
14+
- [附录 A:PdfExtractionProperties 全字段默认值](#附录-apdfextractionproperties-全字段默认值)
15+
16+
---
17+
18+
## 1. Markdown → PDF
19+
20+
最短路径:一行代码把 GFM Markdown(标题/段落/表格/代码块/列表)渲染为 PDF。
21+
22+
```java
23+
import io.github.easy4j.pdf.xhtml.convert.EasyPdf;
24+
// import io.github.easy4j.pdf.xhtml.convert.EasyPdf;
25+
26+
import java.io.File;
27+
// import java.io.File;
28+
29+
// 写文件 / write to file
30+
EasyPdf.markdownToPdf("# 季度报告\n\n| 指标 | 值 |\n|---|---|\n| GMV | 1.2亿 |", new File("q3.pdf"));
31+
32+
// 或写输出流(Web 下载场景)/ or stream it (e.g. HTTP download)
33+
OutputStream out = response.getOutputStream();
34+
EasyPdf.markdownToPdf(markdownBody, out);
35+
```
36+
37+
中文等 CJK 字体若渲染为空白,注册字体后再转换:
38+
39+
```java
40+
io.github.easy4j.pdf.core.convert.HtmlPdfConverter.registerFont("/usr/share/fonts/simsun.ttf");
41+
// register a TTF before conversion if CJK glyphs render blank
42+
```
43+
44+
对应能力矩阵中的"普通 PDF":外来工具可读、复制可行,但没有语义结构树——需要语义还原时用下一节的 Tagged 版本。
45+
46+
## 2. Tagged 无损往返:生成即保真
47+
48+
`markdownToPdfTagged` 在生成时写入 PDF 结构树(PDF/UA 风格的 Tagged PDF);之后任何一份这样的文件用 `pdfToStructuredMarkdown` 读回,标题层级/列表/表格按结构角色(H1–H6/L/Table)**语义级还原,保真度 ≈100%**
49+
50+
```java
51+
// 第一步:生成 Tagged PDF(带结构树)
52+
// Step 1: generate a tagged PDF (structure tree embedded)
53+
EasyPdf.markdownToPdfTagged(md, new File("archived.pdf"));
54+
55+
// …… months later / 数月后……
56+
57+
// 第二步:读回时利用结构树,正文/表格/列表逐项无损
58+
// Step 2: read back via the structure tree — headings/lists/tables come back intact
59+
String restored = EasyPdf.pdfToStructuredMarkdown(new File("archived.pdf"));
60+
```
61+
62+
适用边界:
63+
64+
- 仅对 **easypdf 自己生成的** Tagged PDF 成立;外来 PDF 一般没有可用结构树,走第 3 节的规则引擎。
65+
- 图片以 data URI 保内容;Tagged 角色识别时会做角色归一化(Word 导出的 `heading 1``h1``标题 1` 等别名会映射为标准 `H1`)。
66+
- 快速自检:往返结果可直接与原文做归一化比对(去空白后逐项相等)。
67+
68+
## 3. 结构化提取:pdfToStructured / pdfToStructuredMarkdown
69+
70+
对任意电子版 PDF(含第三方产出),规则引擎(Tier1 格线表格+图片、Tier2 字号聚类标题/分栏/流式表格/列表/页眉页脚剔除)给出尽力而为的结构树,典型办公文档约 80% 还原。想要字符串还是对象树,二选一:
71+
72+
```java
73+
// 对象树:智能体/程序化导航首选
74+
// Object tree: preferred for programmatic navigation
75+
DocumentStructure doc = EasyPdf.pdfToStructured(new File("third-party.pdf"));
76+
77+
doc.title; // 文档标题 / document title
78+
for (DocumentSection sec : doc.sections) {
79+
sec.level; // 标题级别 1..6(0 为隐式继承段)/ heading level
80+
sec.page; // 所在页码(流式回调里写入)/ page number
81+
sec.content; // 正文文本 / body text
82+
sec.children; // 子章节(树形)/ child sections
83+
sec.tables; // 当节表格 headers=List<List<String>>, rows=List<List<String>>
84+
sec.images; // 当节图片 src(内嵌图为 data URI)/ images (data URI)
85+
}
86+
87+
// 等价的 Markdown 字符串 / same thing as a markdown string
88+
String md = EasyPdf.pdfToStructuredMarkdown(new File("third-party.pdf"));
89+
90+
// 只要纯文本(无结构,最快)/ flat text only (fastest, no structure)
91+
String text = io.github.easy4j.pdf.core.convert.HtmlPdfConverter.pdfToText(file);
92+
93+
// 底层入口:需要传 PdfExtractionProperties 时直接用提取器
94+
// low-level entry when custom properties are needed
95+
DocumentStructure doc2 = PdfStructureExtractor.extract(file, props); // 另有单参重载 extract(File)
96+
```
97+
98+
已知边界(诚实声明):无文本层的扫描件不在此范围内(属 OCR);`pdfToMarkdown(File)` 是旧行为的"扁平文本整理版",结构敏感场景请用 `pdfToStructuredMarkdown`
99+
100+
## 4. Agent 链路:summary → pageRange → chunked
101+
102+
面向 LLM 的标准用法是三段式:先看目录骨架决定读什么,再取页区间精读,或整篇切片进向量库。全程避免把整篇文档塞进上下文。
103+
104+
```java
105+
File report = new File("annual-report.pdf");
106+
107+
// ① summary:页数/字符数/表格数/图片数 + level≤2 章节骨架(便宜、快)
108+
// Step 1: cheap overview — counts + section skeleton up to level 2
109+
DocumentSummary s = EasyPdf.summary(report);
110+
System.out.println(s.title + ", pages=" + s.totalPages);
111+
for (DocumentSummarySection sec : s.sections) {
112+
// title / level / pageNo / charCount / tableCount / imageCount
113+
System.out.println("p" + sec.pageNo + " L" + sec.level + " " + sec.title
114+
+ " (" + sec.charCount + " chars)");
115+
}
116+
117+
// ② pageRange:只取任务相关页(页码 1 起算、闭区间)
118+
// Step 2: fetch only relevant pages (1-based, inclusive)
119+
String financials = EasyPdf.pageRange(report, 12, 18);
120+
121+
// ③ chunked:RAG/Embedding 切片(默认单片 800 字符、相邻重叠 100 字符)
122+
// Step 3: RAG-ready chunks (default maxChars=800, overlapChars=100)
123+
ChunkOptions opts = new ChunkOptions();
124+
opts.idPrefix = "annual-report.pdf"; // chunk.id 前缀 + source 字段
125+
List<DocumentChunk> chunks = EasyPdf.chunked(report, opts);
126+
for (DocumentChunk c : chunks) {
127+
// id / source / title / pageStart / pageEnd / level / text / charCount
128+
}
129+
```
130+
131+
字节流入(上传接口)同样覆盖:
132+
133+
```java
134+
DocumentSummary s = EasyPdf.summary(inputStream, "annual-report.pdf"); // filename 用于摘要溯源
135+
String md = EasyPdf.pdfToMarkdown(inputStream);
136+
String structured = EasyPdf.pdfToStructuredMarkdown(inputStream);
137+
```
138+
139+
调参提示:`chunked` 直接作用于完整结构树;对超大文档想省内存,见下节先按页聚合再切片。
140+
141+
## 5. 大文件:extractPerPage 页级流式与取消
142+
143+
全量 `extract` 会把整篇结构驻留内存;几千页的 PDF 应改用页级流式:每页解析完立即回调,内存同时只有一页,且消费方可随时取消。
144+
145+
```java
146+
import io.github.easy4j.pdf.xhtml.convert.PdfStructureExtractor.PageConsumer;
147+
// PageConsumer: boolean page(int pageNo, DocumentStructure pagePartial)
148+
149+
List<DocumentStructure> kept = new ArrayList<>();
150+
PdfStructureExtractor.extractPerPage(bigPdf, props, new PageConsumer() {
151+
@Override
152+
public boolean page(int pageNo, DocumentStructure partial) {
153+
if (!matches(partial)) {
154+
return true; // 返回 true 继续 / return true to continue
155+
}
156+
kept.add(partial);
157+
return pageNo < 2000; // 返回 false 取消:后续页不再解析、不再回调
158+
// return false to cancel streaming early
159+
}
160+
});
161+
```
162+
163+
行为细节:
164+
165+
- 回调里的 `partial` 只含当页产物(title 继承文档标题);页码从 1 起。
166+
- 每页独立分析(无跨页统计),跨页断词合并、全局字号聚类等全局优化不生效——追求最高质量用全量 `extract`,大文件用本方法,这是显式权衡。
167+
- 聚合各页时可参考库内的包级聚合逻辑(把后续页的隐式继承段并入上一节)自行实现。
168+
- REST 引擎无法按页切分:整篇结果以一次 `page(0, wholeDoc)` 回调交付。
169+
170+
## 6. Tier3 扩展点:接入 REST 布局服务
171+
172+
规则引擎之外,easypdf 预留了 ML 布局模型扩展点:把 PDF 字节 POST 给外部布局理解服务(docling / MinerU 类部署即可),服务返回约定的 JSON。接入只需配置,不改代码。
173+
174+
服务端契约(你自己的服务要实现的全部内容):
175+
176+
```text
177+
POST {restEndpoint}
178+
Content-Type: application/pdf # body = 原始 PDF 字节 / raw PDF bytes
179+
Response 200:
180+
{
181+
"title": "string",
182+
"sections": [ {"title": "string", "level": 1, "content": "string"} ],
183+
"tables": [ {"headers": [["h1","h2"]], "rows": [["a","b"]]} ]
184+
}
185+
```
186+
187+
客户端配置:
188+
189+
```java
190+
PdfExtractionProperties p = PdfExtractionProperties.defaults();
191+
p.engine = PdfExtractionProperties.Engine.REST; // 或 AUTO(推荐生产用)
192+
p.restEndpoint = "http://layout-svc:8080/analyze";
193+
p.restTimeoutMillis = 15000; // 连接/读取超时
194+
p.restRetries = 2; // 429/5xx/IOException 指数退避重试
195+
196+
DocumentStructure doc = PdfStructureExtractor.extract(pdf, p);
197+
```
198+
199+
三种引擎模式的行为差异:
200+
201+
| engine | 服务可达 | 服务不可达 |
202+
|:---|:---|:---|
203+
| `AUTO`(默认) | 用 REST 结果 | 记 WARN 后静默回退 RULE |
204+
| `RULE` | 不发请求 | —(始终本地规则) |
205+
| `REST` | 用 REST 结果 | 抛出失败(适合强依赖高质量的场景) |
206+
207+
注意:`engine=REST``restEndpoint` 必须非空(空则构造分析器抛 `IllegalArgumentException`)。质量预期 90–95%,由外部服务决定;本仓库只定义契约与回退策略。
208+
209+
## 7. 错误分级:ExtractionException.Code 与 ExtractReport
210+
211+
解析失败不再是一律 IOException:`ExtractionException extends java.io.IOException` 且携带分类码,既有 `catch (IOException)` 代码无需改动即可平滑升级。
212+
213+
```java
214+
try {
215+
DocumentStructure doc = PdfStructureExtractor.extract(untrustedUpload, props);
216+
} catch (ExtractionException e) {
217+
switch (e.getCode()) {
218+
case NOT_FOUND: // 文件不存在(沿用 "PDF not found" 语义)
219+
respond(404); break;
220+
case ENCRYPTED: // 口令保护/加密 PDF,需用户解密后重试
221+
respond(415, "password protected PDF"); break;
222+
case CORRUPT: // 字节损坏或不构成合法 PDF
223+
respond(422, "corrupt pdf"); break;
224+
case LIMIT_EXCEEDED: // 超出 maxFileBytes / maxPages 护栏
225+
respond(413, "file beyond size/page limits"); break;
226+
}
227+
} catch (IOException e) {
228+
// 其余 I/O 故障(磁盘、网络流等)
229+
respond(500);
230+
}
231+
```
232+
233+
判定规则(实现于 `extract(File, props)` 解析前后):
234+
235+
| Code | 触发条件 |
236+
|:---|:---|
237+
| `NOT_FOUND` | 路径不是存在的普通文件 |
238+
| `LIMIT_EXCEEDED` | `length() > maxFileBytes`(默认 100 MB)或 `getNumberOfPages() > maxPages`(默认 5000) |
239+
| `ENCRYPTED` | 打开文件即失败且异常消息含 password/encrypt(如口令保护 PDF) |
240+
| `CORRUPT` | 打开失败的其余情况(非法字节流等) |
241+
242+
批处理/异步管线推荐永不抛异常的变体,附观测指标:
243+
244+
```java
245+
ExtractReport r = PdfStructureExtractor.extractWithReport(untrustedUpload, props);
246+
if (!r.success) {
247+
log.warn("extract failed: {}", r.error.getMessage()); // r.error = ExtractionException
248+
} else {
249+
log.info("pages={} chars={} tables={} images={} ms={}",
250+
r.pages, r.chars, r.tables, r.images, r.durationMillis);
251+
}
252+
r.warnings.forEach(log::warn); // 如 "no text extracted"(无文本层扫描件预警)
253+
```
254+
255+
---
256+
257+
## 附录 A:PdfExtractionProperties 全字段默认值
258+
259+
静态工厂 `PdfExtractionProperties.defaults()` 返回以下默认状态;全字段 public,按需覆盖。
260+
261+
| 字段 | 类型 | 默认值 | 说明 |
262+
|:---|:---|:---|:---|
263+
| `engine` | `Engine` | `AUTO` | 提取引擎:AUTO/RULE/REST |
264+
| `restEndpoint` | `String` | `null` | Tier3 布局服务地址 |
265+
| `restTimeoutMillis` | `int` | `10000` | REST 超时(毫秒) |
266+
| `restRetries` | `int` | `0` | REST 重试次数(429/5xx/IOException;指数退避 base 500ms,上限 3 次) |
267+
| `maxFileBytes` | `long` | `104857600L` | 文件大小护栏(100 MB) |
268+
| `maxPages` | `int` | `5000` | 页数护栏 |
269+
| `cacheEnabled` | `boolean` | `false` | 提取结果 LRU 缓存(容量 16,key 含路径/mtime/长度) |
270+
| `headFactor` | `float` | `1.22f` | 标题字号判定因子 |
271+
| `maxHeadingTiers` | `int` | `3` | 标题字号最多档位数 |
272+
| `columnGapPt` | `float` | `55f` | 分栏最小间隙(pt) |
273+
| `streamAlignTolPt` | `float` | `6f` | 流式表格列对齐容差(pt) |
274+
| `coverRatio` | `float` | `1.5f` | 封面艺术字比例阈值 |
275+
| `coverRunMinLines` | `int` | `2` | 封面艺术字最少连续行数 |
276+
| `cjkGapFactor` | `float` | `0.22f` | 中英文词间空格判定系数 |
277+
278+
构建命令(三条版本线一致):`./mvnw -pl easypdf-xhtml -am clean verify`
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.github.easy4j.pdf.xhtml.convert;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
6+
/**
7+
* 报告式提取的结果载体:{@link PdfStructureExtractor#extractWithReport} 的返回值。
8+
* 契约为"永不抛异常"——调用方读字段即可,无需 try/catch:
9+
* 成功时 {@link #success}=true 且 {@link #document} 非 null;失败时 {@link #error} 携带分类码
10+
* ({@link ExtractionException.Code}),已统计的计数保留供部分诊断。
11+
*/
12+
public final class ExtractReport {
13+
14+
/** 提取成功时的文档结构;失败时为 null。 */
15+
public DocumentStructure document;
16+
17+
/** 失败原因(按 {@link ExtractionException.Code} 分级);成功时为 null。 */
18+
public ExtractionException error;
19+
20+
/** true 表示提取成功(document 可用);false 表示失败(看 {@link #error})。 */
21+
public boolean success;
22+
23+
/**
24+
* 页数:从 section 页锚点推断的最大页号;
25+
* 整篇 Tagged 路径不写锚点(缺省 0)时下限记 1(PDF 至少一页)。
26+
*/
27+
public int pages;
28+
29+
/** 全部 section(含子级递归)正文总字符数。 */
30+
public long chars;
31+
32+
/** 表格总数(文档级 + section 内递归)。 */
33+
public long tables;
34+
35+
/** 图片总数(文档级 + section 内递归)。 */
36+
public long images;
37+
38+
/** 提取耗时(毫秒)。 */
39+
public long durationMillis;
40+
41+
/** 非致命提示:如无文本层 PDF 追加 "no text extracted"。 */
42+
public List<String> warnings = new ArrayList<String>();
43+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package io.github.easy4j.pdf.xhtml.convert;
2+
3+
import java.io.IOException;
4+
5+
/**
6+
* PDF 提取失败的分类异常:携带 {@link Code} 判别失败原因(损坏 / 加密 / 超限 / 不存在),
7+
* 供智能体与服务端按类处置而非依赖消息文案。
8+
*
9+
* <p>继承 {@link IOException}——既有按 IOException 捕获的调用方无需改动;
10+
* 消息保持人类可读,机器判定一律走 {@link #getCode()}。
11+
*/
12+
public class ExtractionException extends IOException {
13+
14+
private static final long serialVersionUID = 1L;
15+
16+
/** 失败分级。 */
17+
public enum Code {
18+
/** 文件损坏或不可解析(含非 PDF 字节流)。 */
19+
CORRUPT,
20+
/** 受密码保护 / 已加密且未提供可用口令。 */
21+
ENCRYPTED,
22+
/** 超出护栏上限(文件大小 maxFileBytes 或页数 maxPages)。 */
23+
LIMIT_EXCEEDED,
24+
/** 目标文件不存在。 */
25+
NOT_FOUND
26+
}
27+
28+
private final Code code;
29+
30+
public ExtractionException(Code code, String message) {
31+
super(message);
32+
this.code = code;
33+
}
34+
35+
public ExtractionException(Code code, String message, Throwable cause) {
36+
super(message, cause);
37+
this.code = code;
38+
}
39+
40+
/** 失败分级码。 */
41+
public Code getCode() {
42+
return code;
43+
}
44+
}

0 commit comments

Comments
 (0)