Skip to content

Commit b55823c

Browse files
committed
feat(extract): sync extraction hardening (stream tables, fullMarkdown dedup, nested tables, heading guards) from 3.0.x
1 parent 2aad9c8 commit b55823c

7 files changed

Lines changed: 350 additions & 28 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ public String toMarkdown() {
3131

3232
public String fullMarkdown() {
3333
StringBuilder sb = new StringBuilder();
34-
if (title != null && !title.isEmpty()) {
34+
boolean dedup = title != null && !title.isEmpty()
35+
&& sections != null && !sections.isEmpty()
36+
&& sections.get(0).level == 1
37+
&& title.trim().equals(sections.get(0).title == null ? "" : sections.get(0).title.trim());
38+
if (!dedup && title != null && !title.isEmpty()) {
3539
sb.append("# ").append(title).append('\n').append('\n');
3640
}
3741
sb.append(toMarkdown());

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

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,11 +227,73 @@ private static void readCells(PdfStructElem tr, List<String> cells, Ctx ctx) {
227227
PdfStructElem e = (PdfStructElem) child;
228228
String r = normRole(e);
229229
if (StandardRoles.TD.equals(r) || StandardRoles.TH.equals(r)) {
230-
cells.add(textOf(e, ctx).trim());
230+
cells.add(cellMarkdown(e, ctx));
231231
}
232232
}
233233
}
234234

235+
/**
236+
* 单元格内容:非 Table 子元素的 mcid 文本 + 每个嵌套 Table 渲染为 GFM 子表,
237+
* 以 {@code <br>} 连接(保证外层 pipe 表结构不被换行破坏)。
238+
*/
239+
private static String cellMarkdown(PdfStructElem td, Ctx ctx) {
240+
StringBuilder main = new StringBuilder();
241+
List<DocumentTable> subs = new ArrayList<DocumentTable>();
242+
if (td.getKids() != null) {
243+
for (IStructureNode k : td.getKids()) {
244+
if (!(k instanceof PdfStructElem)) continue;
245+
PdfStructElem ke = (PdfStructElem) k;
246+
if (StandardRoles.TABLE.equals(normRole(ke))) {
247+
DocumentTable sub = readTable(ke, ctx);
248+
if (sub != null && (sub.headers.size() + sub.rows.size()) > 0) {
249+
subs.add(sub);
250+
}
251+
} else {
252+
collectText(ke, ctx, main);
253+
}
254+
}
255+
}
256+
StringBuilder cell = new StringBuilder(main.toString().trim());
257+
for (DocumentTable sub : subs) {
258+
if (cell.length() > 0) {
259+
cell.append("<br>");
260+
}
261+
cell.append(tableMarkdown(sub));
262+
}
263+
return cell.toString();
264+
}
265+
266+
/** DocumentTable → GFM pipe 表文本(与 DocumentStructure.appendTable 同格式)。 */
267+
private static String tableMarkdown(DocumentTable t) {
268+
StringBuilder sb = new StringBuilder();
269+
if (t.headers.isEmpty()) {
270+
for (List<String> row : t.rows) {
271+
sb.append('|').append(joinCells(row)).append("|\n");
272+
}
273+
return sb.toString().trim();
274+
}
275+
for (List<String> hdr : t.headers) {
276+
sb.append('|').append(joinCells(hdr)).append("|\n");
277+
}
278+
sb.append('|');
279+
for (int i = 0; i < t.headers.get(0).size(); i++) {
280+
sb.append(" --- |");
281+
}
282+
sb.append('\n');
283+
for (List<String> row : t.rows) {
284+
sb.append('|').append(joinCells(row)).append("|\n");
285+
}
286+
return sb.toString().trim();
287+
}
288+
289+
private static String joinCells(List<String> cs) {
290+
StringBuilder sb = new StringBuilder();
291+
for (String c : cs) {
292+
sb.append(' ').append(c == null ? "" : c.trim()).append(" |");
293+
}
294+
return sb.toString();
295+
}
296+
235297
private static void readList(PdfStructElem list, StringBuilder out, Ctx ctx) {
236298
if (list.getKids() == null) return;
237299
for (IStructureNode child : list.getKids()) {

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

Lines changed: 129 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ public final class RuleLayoutAnalyzer implements LayoutAnalyzer {
2424
private static final Pattern UNORDERED = Pattern.compile("^[•·◦‣\\-]\\s*");
2525
private static final Pattern ORDERED = Pattern.compile("^(\\d{1,2}|[a-z]|[ivxIVX]{1,4})[.)、]\\s*");
2626
private static final float COLUMN_GAP = 55f;
27+
28+
/** 字号量化到 0.5pt 桶,消除渲染浮点噪声(11.2 vs 11.4 等)。 */
29+
private static float qsize(float v) {
30+
return Math.round(v * 2f) / 2f;
31+
}
2732
private static final float HEAD_FACTOR = 1.22f;
2833

2934
private final LatticeTableFinder tableFinder = new LatticeTableFinder();
@@ -52,6 +57,7 @@ public DocumentStructure analyze(List<PageModel> pages, List<int[]> taggedHeadin
5257
// 1) 每页:格线表格(含单元格图片)+ 表格外 chunks 进入分栏/行流水线
5358
List<Line> allLines = new ArrayList<Line>();
5459
List<DocumentTable> tables = new ArrayList<DocumentTable>();
60+
List<DocumentTable> streamTables = new ArrayList<DocumentTable>();
5561
List<String> looseImages = new ArrayList<String>();
5662
if (pages != null) {
5763
for (PageModel page : pages) {
@@ -84,9 +90,10 @@ public DocumentStructure analyze(List<PageModel> pages, List<int[]> taggedHeadin
8490
// 2) 页眉页脚剔除(≥2 页、≥60% 页面重复的顶部/底部行)
8591
allLines = stripHeaderFooter(allLines, pages != null ? pages.size() : 0);
8692

87-
// 3) 跨页断词合并 + 4) 正文字号众数
93+
// 3) 跨页断词合并 + 4) 正文字号众数(排除封面艺术字 run)
8894
joinHyphenated(allLines);
89-
float bodySize = bodyMode(allLines);
95+
float coverSize = coverRunSize(allLines);
96+
float bodySize = bodyMode(allLines, coverSize);
9097

9198
// 5) 组装 sections(标题切分)+ 列表 + 流式表格
9299
DocumentSection current = new DocumentSection();
@@ -95,31 +102,41 @@ public DocumentStructure analyze(List<PageModel> pages, List<int[]> taggedHeadin
95102
List<DocumentSection> sections = new ArrayList<DocumentSection>();
96103
StringBuilder body = new StringBuilder();
97104

105+
boolean currentIsHeading = false;
98106
int i = 0;
99107
while (i < allLines.size()) {
100108
// 流式表格尝试(连续 ≥3 行、≥2 列 x 对齐)
101109
int tableLen = streamTableLength(allLines, i);
102110
if (tableLen >= 3) {
103111
DocumentTable st = buildStreamTable(allLines, i, tableLen);
104-
current.tables.add(st);
112+
streamTables.add(st);
105113
i += tableLen;
106114
continue;
107115
}
108116
Line ln = allLines.get(i);
109117
String text = ln.text.trim();
110118
if (text.isEmpty()) { i++; continue; }
111119

112-
if (ln.size >= bodySize * HEAD_FACTOR && !text.isEmpty()) {
113-
// 标题:新 section
114-
if (body.length() > 0) {
115-
current.content = body.toString().trim();
116-
sections.add(current);
117-
body = new StringBuilder();
120+
// 标题护栏:候选字号仅取最大 3 档;行长 >80 的大字不判标题;
121+
// 封面艺术字(均匀大字号多行 run)排除;标题须为孤立行(下一行字号不同)
122+
List<Float> headSizes = headingSizes(allLines, bodySize);
123+
boolean isolated = i == allLines.size() - 1
124+
|| Math.abs(allLines.get(i + 1).size - ln.size) > 0.5f;
125+
if (ln.size >= bodySize * HEAD_FACTOR && text.length() <= 80
126+
&& Math.abs(ln.size - coverSize) > 0.5f
127+
&& isolated
128+
&& headSizes.contains(Float.valueOf(qsize(ln.size)))) {
129+
// 标题:flush 旧段(有内容才入列),换新 current(延迟入列)
130+
current.content = body.toString().trim();
131+
if (!current.content.isEmpty() || currentIsHeading) {
132+
sections.add(current); // 标题段即使暂无正文也保留(相邻标题场景)
118133
}
134+
body = new StringBuilder();
119135
current = new DocumentSection();
120136
current.title = text;
121137
current.level = headingLevel(allLines, i, bodySize);
122-
sections.add(current);
138+
currentIsHeading = true;
139+
// 延迟入列:由下一次 flush 或循环末尾统一 add,避免标题段整段重复
123140
i++;
124141
continue;
125142
}
@@ -132,7 +149,7 @@ public DocumentStructure analyze(List<PageModel> pages, List<int[]> taggedHeadin
132149
i++;
133150
}
134151
current.content = body.toString().trim();
135-
if (!current.content.isEmpty() || current.title != null) {
152+
if (!current.content.isEmpty() || (current.title != null && !current.title.isEmpty())) {
136153
sections.add(current);
137154
}
138155
if (sections.isEmpty()) {
@@ -141,6 +158,7 @@ public DocumentStructure analyze(List<PageModel> pages, List<int[]> taggedHeadin
141158
}
142159
doc.sections = sections;
143160
doc.tables.addAll(tables);
161+
doc.tables.addAll(streamTables);
144162
StringBuilder sec;
145163
for (String uri : looseImages) {
146164
doc.sections.get(doc.sections.size() - 1).content =
@@ -292,31 +310,79 @@ private static void joinHyphenated(List<Line> lines) {
292310
}
293311
}
294312

295-
private static float bodyMode(List<Line> lines) {
313+
private static float bodyMode(List<Line> lines, float coverSize) {
296314
Map<Integer, Long> hist = new HashMap<Integer, Long>();
315+
long total = 0;
297316
for (Line l : lines) {
317+
if (coverSize > 0 && Math.abs(l.size - coverSize) <= 0.5f) {
318+
continue; // 封面艺术字不参与正文众数
319+
}
298320
int key = Math.round(l.size * 2);
299321
Long c = hist.get(key);
300322
long add = Math.max(1, l.text.length());
301323
hist.put(key, c == null ? add : c.longValue() + add);
324+
total += add;
302325
}
303-
long best = -1; int bestKey = 24;
326+
if (hist.isEmpty()) {
327+
return 11.0f; // 无可用正文行(如纯表格文档):取常规正文默认值,避免空表死循环
328+
}
329+
long best = -1; int bestKey = Integer.MAX_VALUE;
304330
for (Map.Entry<Integer, Long> e : hist.entrySet()) {
305-
if (e.getValue() > best) { best = e.getValue(); bestKey = e.getKey(); }
331+
boolean better = e.getValue() > best
332+
|| (e.getValue() == best && e.getKey() < bestKey); // 并列取最小字号(正文偏置)
333+
if (better) { best = e.getValue(); bestKey = e.getKey(); }
306334
}
307335
return bestKey / 2f;
308336
}
309337

310-
private static int headingLevel(List<Line> lines, int idx, float bodySize) {
311-
// 候选标题字号降序 → 1..6
338+
/**
339+
* 封面艺术字检测:最大字号构成 ≥2 行的连续 run,且比次大 distinct 字号大 50% 以上。
340+
* 返回该字号;无则返回 -1。
341+
*/
342+
private static float coverRunSize(List<Line> lines) {
343+
List<Float> distinct = new ArrayList<Float>();
344+
for (Line l : lines) {
345+
Float q = Float.valueOf(qsize(l.size));
346+
if (!distinct.contains(q)) distinct.add(q);
347+
}
348+
if (distinct.isEmpty()) return -1f;
349+
Collections.sort(distinct, Collections.reverseOrder());
350+
float largest = distinct.get(0);
351+
int run = 1, maxRun = 1;
352+
for (int i = 1; i < lines.size(); i++) {
353+
if (Math.abs(lines.get(i).size - lines.get(i - 1).size) <= 0.5f
354+
&& Math.abs(lines.get(i).size - largest) <= 0.5f) {
355+
run++;
356+
maxRun = Math.max(maxRun, run);
357+
} else {
358+
run = 1;
359+
}
360+
}
361+
if (maxRun < 2) return -1f;
362+
if (distinct.size() < 2) return -1f;
363+
float next = distinct.get(1);
364+
return largest > next * 1.5f ? largest : -1f;
365+
}
366+
367+
/** 候选标题字号(降序,最多 3 档):超出档位的大字降为正文。 */
368+
private static List<Float> headingSizes(List<Line> lines, float bodySize) {
312369
List<Float> sizes = new ArrayList<Float>();
313370
for (Line l : lines) {
314-
if (l.size >= bodySize * HEAD_FACTOR && !sizes.contains(Float.valueOf(l.size))) {
315-
sizes.add(l.size);
371+
float q = qsize(l.size);
372+
if (l.size >= bodySize * HEAD_FACTOR && !sizes.contains(Float.valueOf(q))) {
373+
sizes.add(Float.valueOf(q));
316374
}
317375
}
318376
Collections.sort(sizes, Collections.reverseOrder());
319-
int lv = sizes.indexOf(Float.valueOf(lines.get(idx).size)) + 1;
377+
if (sizes.size() > 3) {
378+
sizes = new ArrayList<Float>(sizes.subList(0, 3));
379+
}
380+
return sizes;
381+
}
382+
383+
private static int headingLevel(List<Line> lines, int idx, float bodySize) {
384+
List<Float> sizes = headingSizes(lines, bodySize);
385+
int lv = sizes.indexOf(Float.valueOf(qsize(lines.get(idx).size))) + 1;
320386
return Math.max(1, Math.min(6, lv));
321387
}
322388

@@ -337,31 +403,68 @@ private static int markerLen(String text) {
337403
// ---------------- 流式表格(无格线,x 对齐) ----------------
338404

339405
private static int streamTableLength(List<Line> lines, int start) {
340-
int n = 0;
341-
for (int i = start; i < lines.size(); i++) {
342-
if (lines.get(i).chunks.size() >= 2) n++;
343-
else break;
406+
if (start >= lines.size()) {
407+
return 0;
408+
}
409+
List<Float> first = clusterStarts(lines.get(start));
410+
if (first.size() < 2) {
411+
return 0;
412+
}
413+
int n = 1;
414+
for (int i = start + 1; i < lines.size(); i++) {
415+
List<Float> cs = clusterStarts(lines.get(i));
416+
if (cs.size() != first.size() || !aligned(first, cs)) {
417+
break;
418+
}
419+
n++;
344420
}
345421
return n;
346422
}
347423

424+
/** 行内列簇起始 x(列边界:净间隙 > max(size*1.2, 12pt))。 */
425+
private static List<Float> clusterStarts(Line l) {
426+
List<Float> xs = new ArrayList<Float>();
427+
PageChunk prev = null;
428+
for (PageChunk c : l.chunks) {
429+
if (prev == null || c.x - (prev.x + prev.text.length() * prev.size * 0.55f) > Math.max(prev.size * 1.2f, 12f)) {
430+
xs.add(Float.valueOf(c.x));
431+
}
432+
prev = c;
433+
}
434+
return xs;
435+
}
436+
437+
/** 各行第 k 列起始 x 跨行对齐(±6pt)。 */
438+
private static boolean aligned(List<Float> a, List<Float> b) {
439+
for (int i = 0; i < a.size(); i++) {
440+
if (Math.abs(a.get(i).floatValue() - b.get(i).floatValue()) > 6f) {
441+
return false;
442+
}
443+
}
444+
return true;
445+
}
446+
348447
private static DocumentTable buildStreamTable(List<Line> lines, int start, int len) {
349448
DocumentTable tbl = new DocumentTable();
350449
for (int i = 0; i < len; i++) {
351450
Line l = lines.get(start + i);
352451
List<String> cells = new ArrayList<String>();
353-
PageChunk prev = null;
354452
StringBuilder cell = new StringBuilder();
453+
PageChunk prev = null;
355454
for (PageChunk c : l.chunks) {
356-
if (prev != null && c.x - prev.x > Math.max(prev.size * 4f, 30f)) {
455+
if (prev != null && c.x - (prev.x + prev.text.length() * prev.size * 0.55f) > Math.max(prev.size * 1.2f, 12f)) {
357456
cells.add(cell.toString().trim());
358457
cell = new StringBuilder();
359458
}
360459
cell.append(c.text);
361460
prev = c;
362461
}
363462
cells.add(cell.toString().trim());
364-
if (i == 0) tbl.headers.add(cells); else tbl.rows.add(cells);
463+
if (i == 0) {
464+
tbl.headers.add(cells);
465+
} else {
466+
tbl.rows.add(cells);
467+
}
365468
}
366469
return tbl;
367470
}

easypdf-xhtml/src/test/java/io/github/easy4j/pdf/xhtml/convert/DocumentStructureTest.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,26 @@ void fullMarkdownSkipsDuplicateTitle() {
4444
doc.sections = Collections.singletonList(h1);
4545
assertThat(doc.fullMarkdown()).contains("# 标题").contains("x");
4646
}
47+
48+
@Test
49+
void fullMarkdownDeduplicatesDocTitleAndFirstHeading() {
50+
DocumentStructure doc = new DocumentStructure();
51+
doc.title = "合同";
52+
DocumentSection h1 = new DocumentSection();
53+
h1.title = "合同"; h1.level = 1; h1.content = "正文";
54+
doc.sections = Collections.singletonList(h1);
55+
String md = doc.fullMarkdown();
56+
assertThat(md).contains("# 合同").contains("正文");
57+
assertThat(md.indexOf("# 合同")).isEqualTo(md.lastIndexOf("# 合同")); // 只出现一次
58+
}
59+
60+
@Test
61+
void fullMarkdownKeepsTitleWhenFirstSectionDiffers() {
62+
DocumentStructure doc = new DocumentStructure();
63+
doc.title = "文档元标题";
64+
DocumentSection h1 = new DocumentSection();
65+
h1.title = "章标题"; h1.level = 1; h1.content = "x";
66+
doc.sections = Collections.singletonList(h1);
67+
assertThat(doc.fullMarkdown()).contains("# 文档元标题").contains("# 章标题");
68+
}
4769
}

0 commit comments

Comments
 (0)