@@ -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 }
0 commit comments