Skip to content

Commit 534bbd0

Browse files
committed
feat(extract): sync round4-part2 and round5 improvements from 3.0.x
1 parent 1809703 commit 534bbd0

13 files changed

Lines changed: 785 additions & 224 deletions

File tree

easypdf-xhtml/src/main/java/io/github/easy4j/pdf/xhtml/convert/EasyPdf.java

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public static void markdownToPdf(String markdown, OutputStream out) throws IOExc
3737

3838
/** PDF 文件 → Markdown 文本(尽力而为:文本提取 + 段落整理,结构还原以可读性为准)。 */
3939
public static String pdfToMarkdown(File pdf) throws IOException {
40-
Objects.requireNonNull(pdf, "pdf must not be null");
40+
pdf = requireFile(pdf);
4141
return MarkdownConverter.textToMarkdown(HtmlPdfConverter.pdfToText(pdf));
4242
}
4343

@@ -55,13 +55,13 @@ public static String pdfToMarkdown(InputStream in) throws IOException {
5555

5656
/** PDF 文件 → 结构化 Markdown(标题/列表/表格,document 顶层;Task 2-3 新增)。 */
5757
public static String pdfToStructuredMarkdown(File pdf) throws IOException {
58-
Objects.requireNonNull(pdf, "pdf must not be null");
58+
pdf = requireFile(pdf);
5959
return PdfToMarkdownConverter.pdfToFullMarkdown(pdf);
6060
}
6161

6262
/** PDF 文件 → 结构化 Document(智能体按需取章节/表格,Task 2-3 新增)。 */
6363
public static DocumentStructure pdfToStructured(File pdf) throws IOException {
64-
Objects.requireNonNull(pdf, "pdf must not be null");
64+
pdf = requireFile(pdf);
6565
return PdfToMarkdownConverter.pdfToStructured(pdf);
6666
}
6767

@@ -99,7 +99,7 @@ public static void markdownToPdfTagged(String markdown, OutputStream out) throws
9999
* 智能体先看目录树决定要取哪些章节,避免整篇驻留。
100100
*/
101101
public static DocumentSummary summary(File pdf) throws IOException {
102-
Objects.requireNonNull(pdf, "pdf must not be null");
102+
pdf = requireFile(pdf);
103103
return DocumentSummaryBuilder.build(pdf, PdfExtractionProperties.defaults());
104104
}
105105

@@ -121,7 +121,16 @@ public static DocumentSummary summary(InputStream in, String filename) throws IO
121121
* 按页流式提取并只拼接区间内各页的 partial 结果。
122122
*/
123123
public static String pageRange(File pdf, int fromPage, int toPage) throws IOException {
124-
Objects.requireNonNull(pdf, "pdf must not be null");
124+
pdf = requireFile(pdf);
125+
if (fromPage > toPage) {
126+
throw new IllegalArgumentException("fromPage(" + fromPage + ") > toPage(" + toPage + ")");
127+
}
128+
if (fromPage <= 0) {
129+
throw new IllegalArgumentException("fromPage must be >= 1, was " + fromPage);
130+
}
131+
if (toPage <= 0) {
132+
throw new IllegalArgumentException("toPage must be >= 1, was " + toPage);
133+
}
125134
final StringBuilder md = new StringBuilder();
126135
PdfStructureExtractor.extractPerPage(pdf, PdfExtractionProperties.defaults(),
127136
new PdfStructureExtractor.PageConsumer() {
@@ -142,7 +151,34 @@ public boolean page(int pageNo, DocumentStructure partial) {
142151

143152
/** PDF 文件 → RAG / Embedding 友好的切片流(配置见 {@link ChunkOptions})。 */
144153
public static List<DocumentChunk> chunked(File pdf, ChunkOptions opts) throws IOException {
145-
Objects.requireNonNull(pdf, "pdf must not be null");
154+
pdf = requireFile(pdf);
146155
return DocumentChunker.chunk(PdfStructureExtractor.extract(pdf), opts);
147156
}
157+
158+
// ---------------- Round5-Security Task 3: 入口文件护栏 + 日志转义 ----------------
159+
160+
/**
161+
* 所有接受 PDF 文件的公开入口统一护栏:null 拒绝(NPE,与历史一致)+
162+
* 文件存在性校验(缺失时按 {@link ExtractionException.Code#NOT_FOUND} 分级抛出,
163+
* 文案头部保持历史 "PDF not found:"——{@link ExtractionException} 为 IOException 子类,调用方兼容)。
164+
*/
165+
private static File requireFile(File pdf) throws IOException {
166+
Objects.requireNonNull(pdf, "pdf must not be null");
167+
if (!pdf.isFile()) {
168+
throw new ExtractionException(ExtractionException.Code.NOT_FOUND,
169+
"PDF not found: " + escapeForLog(pdf.getName()));
170+
}
171+
return pdf;
172+
}
173+
174+
/**
175+
* 日志/异常消息安全转义:反斜杠先转义(保证转义序列不可被二次解释),
176+
* 再把换行/回车/制表符转为可见转义序列,防伪造日志行注入。
177+
*/
178+
static String escapeForLog(String s) {
179+
return s.replace("\\", "\\\\")
180+
.replace("\n", "\\n")
181+
.replace("\r", "\\r")
182+
.replace("\t", "\\t");
183+
}
148184
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package io.github.easy4j.pdf.xhtml.convert;
2+
3+
import java.util.Collections;
4+
import java.util.LinkedHashMap;
5+
import java.util.Map;
6+
import java.util.Objects;
7+
import java.util.concurrent.ConcurrentHashMap;
8+
import java.util.concurrent.atomic.AtomicLong;
9+
10+
/**
11+
* PDF 提取的进程内计数器(无需 JMX / Spring Boot Actuator,直接 {@link #snapshot()} 读数即可)。
12+
*
13+
* <p>埋点口径:每次结构化提取计一次(成功与失败都计入 {@code total})——
14+
* 由 {@link PdfStructureExtractor#extract(File, io.github.easy4j.pdf.xhtml.convert.layout.PdfExtractionProperties)
15+
* extract} 单一收口,
16+
* 报告式入口 {@code extractWithReport} 内部委托同一方法,因此不会重复计数;
17+
* 纯字符串操作(如 {@code MarkdownConverter.mdToHtml})不计。
18+
*
19+
* <p>线程安全:多字段更新在 synchronized 块内完成,{@link #snapshot()} 读取一致视图;
20+
* 返回的 Map 为独立不可变副本,后续计数不影响已取出的快照。
21+
*
22+
* <p>用法:诊断/巡检接口直接读 {@link #INSTANCE};单测可用 {@code new ExtractorMetrics()}
23+
* 构造独立实例避免进程级状态串扰。
24+
*/
25+
public final class ExtractorMetrics {
26+
27+
/** 进程级共享实例:常规调用方经此读数。 */
28+
public static final ExtractorMetrics INSTANCE = new ExtractorMetrics();
29+
30+
/** 总提取次数(成功 + 失败)。 */
31+
private final AtomicLong totalExtracts = new AtomicLong();
32+
33+
/** 成功次数。 */
34+
private final AtomicLong totalSuccesses = new AtomicLong();
35+
36+
/** 失败总次数(按分级码细分见 {@link #failureByCode})。 */
37+
private final AtomicLong totalFailures = new AtomicLong();
38+
39+
/** 累计耗时(毫秒,含失败尝试;时间源 System.currentTimeMillis())。 */
40+
private final AtomicLong totalDurationMs = new AtomicLong();
41+
42+
/** 按失败分级码的累计次数。 */
43+
private final ConcurrentHashMap<ExtractionException.Code, AtomicLong> failureByCode =
44+
new ConcurrentHashMap<ExtractionException.Code, AtomicLong>();
45+
46+
/** 记一次成功提取及其耗时(负值按 0 计)。 */
47+
public void recordSuccess(long durationMs) {
48+
synchronized (this) {
49+
totalExtracts.incrementAndGet();
50+
totalSuccesses.incrementAndGet();
51+
addDuration(durationMs);
52+
}
53+
}
54+
55+
/**
56+
* 记一次失败提取:分级码取自 {@link ExtractionException#getCode()};
57+
* 未分级的异常由埋点侧归入 {@link ExtractionException.Code#CORRUPT} 后传入。
58+
*/
59+
public void recordFailure(ExtractionException.Code code, long durationMs) {
60+
Objects.requireNonNull(code, "code must not be null");
61+
synchronized (this) {
62+
totalExtracts.incrementAndGet();
63+
totalFailures.incrementAndGet();
64+
addDuration(durationMs);
65+
counterFor(code).incrementAndGet();
66+
}
67+
}
68+
69+
/**
70+
* 只读诊断快照(不可变、与内部状态隔离)。key 契约:
71+
* {@code total}(成功+失败总数)、{@code successes}、{@code durationMs}
72+
* 与每个分级码固定存在的 {@code failures.CORRUPT / failures.ENCRYPTED /
73+
* failures.LIMIT_EXCEEDED / failures.NOT_FOUND}(未发生为 0;
74+
* 无顶层 "failures" 聚合 key,避免与逐码 key 混淆)。
75+
*/
76+
public Map<String, Long> snapshot() {
77+
synchronized (this) {
78+
Map<String, Long> snap = new LinkedHashMap<String, Long>();
79+
snap.put("total", Long.valueOf(totalExtracts.get()));
80+
snap.put("successes", Long.valueOf(totalSuccesses.get()));
81+
snap.put("durationMs", Long.valueOf(totalDurationMs.get()));
82+
for (ExtractionException.Code code : ExtractionException.Code.values()) {
83+
AtomicLong c = failureByCode.get(code);
84+
snap.put("failures." + code.name(), Long.valueOf(c == null ? 0L : c.get()));
85+
}
86+
return Collections.unmodifiableMap(snap);
87+
}
88+
}
89+
90+
/** 清零全部计数(仅测试/巡检复位用;不影响进行中的提取)。 */
91+
public void reset() {
92+
synchronized (this) {
93+
totalExtracts.set(0);
94+
totalSuccesses.set(0);
95+
totalFailures.set(0);
96+
totalDurationMs.set(0);
97+
failureByCode.clear();
98+
}
99+
}
100+
101+
private void addDuration(long durationMs) {
102+
if (durationMs > 0) {
103+
totalDurationMs.addAndGet(durationMs);
104+
}
105+
}
106+
107+
private AtomicLong counterFor(ExtractionException.Code code) {
108+
return failureByCode.computeIfAbsent(code, new java.util.function.Function<ExtractionException.Code, AtomicLong>() {
109+
@Override
110+
public AtomicLong apply(ExtractionException.Code k) {
111+
return new AtomicLong();
112+
}
113+
});
114+
}
115+
}

easypdf-xhtml/src/main/java/io/github/easy4j/pdf/xhtml/convert/PdfStructureExtractor.java

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
import java.util.Objects;
1111

1212
import com.itextpdf.kernel.PdfException;
13+
import com.itextpdf.kernel.pdf.PdfCatalog;
1314
import com.itextpdf.kernel.pdf.PdfDictionary;
1415
import com.itextpdf.kernel.pdf.PdfDocument;
16+
import com.itextpdf.kernel.pdf.PdfName;
1517
import com.itextpdf.kernel.pdf.PdfReader;
1618
import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor;
1719
import com.itextpdf.kernel.pdf.tagging.IStructureNode;
@@ -22,7 +24,7 @@
2224

2325
import io.github.easy4j.pdf.xhtml.convert.layout.ExtractCache;
2426
import io.github.easy4j.pdf.xhtml.convert.layout.PageModel;
25-
import io.github.easy4j.pdf.xhtml.convert.layout.PageModelListener;
27+
import io.github.easy4j.pdf.xhtml.convert.layout.PageModelCollector;
2628
import io.github.easy4j.pdf.xhtml.convert.layout.PdfExtractionProperties;
2729
import io.github.easy4j.pdf.xhtml.convert.layout.RestLayoutAnalyzer;
2830
import io.github.easy4j.pdf.xhtml.convert.layout.RuleLayoutAnalyzer;
@@ -33,15 +35,42 @@
3335
*/
3436
public final class PdfStructureExtractor {
3537

38+
/** 结构化日志:入口 debug / 结果 INFO / warnings 与失败 WARN。 */
39+
private static final org.slf4j.Logger LOG =
40+
org.slf4j.LoggerFactory.getLogger(PdfStructureExtractor.class);
41+
3642
private PdfStructureExtractor() {
3743
}
3844

3945
public static DocumentStructure extract(File pdf) throws IOException {
4046
return extract(pdf, PdfExtractionProperties.defaults());
4147
}
4248

49+
/**
50+
* 结构化提取(带进程级计数):成功与失败各计一次进 {@link ExtractorMetrics#INSTANCE}。
51+
* 本方法是全部提取路径的单一收口(报告式 {@link #extractWithReport} 也委托至此),
52+
* 因此不会重复计数;耗时用 System.currentTimeMillis() 测量。未分级的
53+
* IOException / 运行时异常按 CORRUPT 归类,与报告式入口的兜底口径一致。
54+
*/
4355
public static DocumentStructure extract(File pdf, PdfExtractionProperties props) throws IOException {
4456
Objects.requireNonNull(pdf, "pdf must not be null");
57+
long start = System.currentTimeMillis();
58+
try {
59+
DocumentStructure doc = doExtract(pdf, props);
60+
ExtractorMetrics.INSTANCE.recordSuccess(System.currentTimeMillis() - start);
61+
return doc;
62+
} catch (IOException | RuntimeException e) {
63+
ExtractionException.Code code = e instanceof ExtractionException
64+
? ((ExtractionException) e).getCode()
65+
: ExtractionException.Code.CORRUPT;
66+
ExtractorMetrics.INSTANCE.recordFailure(code, System.currentTimeMillis() - start);
67+
throw e;
68+
}
69+
}
70+
71+
/** 提取主体:原 extract(File, props) 逻辑,不含计数。 */
72+
private static DocumentStructure doExtract(File pdf, PdfExtractionProperties props) throws IOException {
73+
LOG.debug("extract requested file={}", pdf.getName());
4574
if (!pdf.isFile()) {
4675
// NOT_FOUND 分级包装;文案与历史行为一致(仍为 IOException 子类)
4776
throw new ExtractionException(ExtractionException.Code.NOT_FOUND,
@@ -138,6 +167,17 @@ public static ExtractReport extractWithReport(File pdf, PdfExtractionProperties
138167
"PDF extraction failed (" + t.getClass().getSimpleName() + "): " + t.getMessage(), t);
139168
}
140169
r.durationMillis = System.currentTimeMillis() - start;
170+
if (r.success) {
171+
// file 只取 basename,避免日志泄漏路径 PII
172+
LOG.info("extract file={} pages={} chars={} tables={} images={} durationMs={}",
173+
pdf.getName(), r.pages, r.chars, r.tables, r.images, r.durationMillis);
174+
if (!r.warnings.isEmpty()) {
175+
LOG.warn("extract warnings file={} warnings={}", pdf.getName(), r.warnings);
176+
}
177+
} else {
178+
LOG.warn("extract failed file={} code={} msg={}",
179+
pdf.getName(), r.error.getCode(), r.error.getMessage());
180+
}
141181
return r;
142182
}
143183

@@ -335,9 +375,10 @@ private static final class ParsedDoc implements AutoCloseable {
335375
ParsedDoc(File pdf) throws IOException {
336376
this.source = pdf;
337377
this.pdfDoc = new PdfDocument(new PdfReader(pdf));
378+
stripEmbeddedJavaScript(this.pdfDoc);
338379
String metaTitle = pdfDoc.getDocumentInfo() != null ? pdfDoc.getDocumentInfo().getTitle() : null;
339380
this.title = (metaTitle == null || metaTitle.isEmpty()) ? pdf.getName() : metaTitle;
340-
this.models = PageModelListener.collect(pdfDoc);
381+
this.models = PageModelCollector.collect(pdfDoc);
341382
PdfStructTreeRoot root = pdfDoc.getStructTreeRoot();
342383
boolean t = false;
343384
if (root != null && root.getKids() != null) {
@@ -352,6 +393,29 @@ private static final class ParsedDoc implements AutoCloseable {
352393
public void close() throws IOException {
353394
pdfDoc.close();
354395
}
396+
397+
/**
398+
* 纵深防御:打开后立即剥离 catalog 顶层的脚本向量(/JS、/JavaScript 与
399+
* JavaScript 型 OpenAction)。iText 内核解析从不执行嵌入 JS(库内无解释器,
400+
* 也无 setIgnoreJavaScript 开关——7.x/8.x API 均不存在),此剥离保证本
401+
* 上下文以及任何下游复用(序列化/再转换)都不会把脚本带出去。
402+
* 全部 reader 构造经由 ParsedDoc,extract 与 extractPerPage 两路径均被覆盖;
403+
* 缓存命中分支不打开文件、无 reader,无需处理。
404+
*/
405+
private static void stripEmbeddedJavaScript(PdfDocument doc) {
406+
PdfCatalog cat = doc.getCatalog();
407+
if (cat == null || cat.getPdfObject() == null) {
408+
return;
409+
}
410+
// PdfCatalog 是 PdfObjectWrapper 包装而非字典本身:经 getPdfObject 操作条目
411+
PdfDictionary root = cat.getPdfObject();
412+
root.remove(PdfName.JS);
413+
root.remove(PdfName.JavaScript);
414+
PdfDictionary oa = root.getAsDictionary(PdfName.OpenAction);
415+
if (oa != null && PdfName.JavaScript.equals(oa.get(PdfName.S))) {
416+
root.remove(PdfName.OpenAction); // 仅摘除 JS 型动作,保留普通页面定位
417+
}
418+
}
355419
}
356420

357421
/** 整篇语义提取:与历史行为一致——Tagged 全树优先,REST 服务次之,规则引擎兜底。 */

0 commit comments

Comments
 (0)