1010import java .util .Objects ;
1111
1212import com .itextpdf .kernel .PdfException ;
13+ import com .itextpdf .kernel .pdf .PdfCatalog ;
1314import com .itextpdf .kernel .pdf .PdfDictionary ;
1415import com .itextpdf .kernel .pdf .PdfDocument ;
16+ import com .itextpdf .kernel .pdf .PdfName ;
1517import com .itextpdf .kernel .pdf .PdfReader ;
1618import com .itextpdf .kernel .pdf .canvas .parser .PdfTextExtractor ;
1719import com .itextpdf .kernel .pdf .tagging .IStructureNode ;
2224
2325import io .github .easy4j .pdf .xhtml .convert .layout .ExtractCache ;
2426import 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 ;
2628import io .github .easy4j .pdf .xhtml .convert .layout .PdfExtractionProperties ;
2729import io .github .easy4j .pdf .xhtml .convert .layout .RestLayoutAnalyzer ;
2830import io .github .easy4j .pdf .xhtml .convert .layout .RuleLayoutAnalyzer ;
3335 */
3436public 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