22// Licensed under the Apache License, Version 2.0.
33
44using PdfStruct . Models ;
5+ using BaselineRow = PdfStruct . Analysis . Tables . TableRowGrouping . BaselineRow ;
56using Word = PdfStruct . Analysis . Tables . TableCellRecovery . Word ;
67
78namespace PdfStruct . Analysis . Tables ;
@@ -30,12 +31,6 @@ namespace PdfStruct.Analysis.Tables;
3031/// </remarks>
3132internal static class LogicalRowBuilder
3233{
33- /// <summary>Two blocks share a baseline when their baselines differ by at most this factor of the smaller font height.</summary>
34- private const double RowBaselineToleranceFactor = 0.4 ;
35-
36- /// <summary>Absolute floor, in PDF points, for the same-baseline tolerance.</summary>
37- private const double MinRowBaselineTolerance = 1.5 ;
38-
3934 /// <summary>Two word centres within this factor of the word height are the same column.</summary>
4035 private const double ColumnToleranceFactor = 0.6 ;
4136
@@ -70,7 +65,7 @@ public static List<List<Word>> Build(
7065 ArgumentNullException . ThrowIfNull ( words ) ;
7166 ArgumentNullException . ThrowIfNull ( horizontalRuleYs ) ;
7267
73- var baselineRows = ClusterBaselineRows ( words ) ;
68+ var baselineRows = TableRowGrouping . ClusterBaselineRows ( words ) ;
7469 if ( baselineRows . Count <= 1 ) return baselineRows . Select ( r => r . Words . ToList ( ) ) . ToList ( ) ;
7570
7671 if ( TryRuleBands ( words , region , horizontalRuleYs , baselineRows . Count , out var bandRows ) )
@@ -79,41 +74,6 @@ public static List<List<Word>> Build(
7974 return MergeContinuations ( baselineRows ) ;
8075 }
8176
82- /// <summary>Clusters words into baseline rows, top to bottom.</summary>
83- private static List < Row > ClusterBaselineRows ( IReadOnlyList < Word > words )
84- {
85- var ordered = words . OrderByDescending ( w => w . BoundingBox . CenterY ) . ToList ( ) ;
86- var rows = new List < Row > ( ) ;
87- var current = new List < Word > ( ) ;
88- var baseline = 0.0 ;
89-
90- foreach ( var word in ordered )
91- {
92- var height = word . BoundingBox . Height ;
93- if ( current . Count == 0 )
94- {
95- current . Add ( word ) ;
96- baseline = word . BoundingBox . CenterY ;
97- continue ;
98- }
99-
100- var smaller = Math . Min ( current . Min ( w => w . BoundingBox . Height ) , height ) ;
101- var tolerance = Math . Max ( MinRowBaselineTolerance , RowBaselineToleranceFactor * smaller ) ;
102- if ( baseline - word . BoundingBox . CenterY <= tolerance )
103- {
104- current . Add ( word ) ;
105- }
106- else
107- {
108- rows . Add ( MakeRow ( current ) ) ;
109- current = [ word ] ;
110- baseline = word . BoundingBox . CenterY ;
111- }
112- }
113- if ( current . Count > 0 ) rows . Add ( MakeRow ( current ) ) ;
114- return rows ;
115- }
116-
11777 /// <summary>
11878 /// Buckets words into the bands a regular full-width rule grid draws. Returns
11979 /// <c>false</c> when the rules are not a per-row grid (too few bands, irregular
@@ -150,14 +110,30 @@ private static bool TryRuleBands(
150110 for ( var i = 1 ; i < boundaries . Count ; i ++ ) heights . Add ( boundaries [ i - 1 ] - boundaries [ i ] ) ;
151111 if ( CoefficientOfVariation ( heights ) > MaxBandHeightCv ) return false ;
152112
153- if ( buckets . Count ( band => band . Count > 0 ) >= baselineRowCount ) return false ;
113+ bandRows = SplitRuleBands ( buckets . Where ( band => band . Count > 0 ) . ToList ( ) ) ;
114+ if ( bandRows . Count >= baselineRowCount ) return false ;
154115
155- bandRows = buckets . Where ( band => band . Count > 0 ) . ToList ( ) ;
156116 return bandRows . Count > 0 ;
157117 }
158118
119+ /// <summary>
120+ /// A rule band is usually one logical row, but dense ruled tables sometimes
121+ /// omit the interior rule between consecutive body records. Split only when
122+ /// multiple baselines in the same band each open the anchor column and at
123+ /// least one data column; label-only baselines remain wrapped cell text.
124+ /// </summary>
125+ private static List < List < Word > > SplitRuleBands ( List < List < Word > > bands ) =>
126+ TableRowGrouping . SplitStackedRecords ( bands , band =>
127+ {
128+ var tolerance = Math . Max ( MinColumnTolerance , ColumnToleranceFactor * Median ( band . Select ( w => w . BoundingBox . Height ) ) ) ;
129+ var anchor = band . Min ( w => w . BoundingBox . CenterX ) ;
130+ return words =>
131+ words . Any ( w => w . BoundingBox . CenterX <= anchor + tolerance )
132+ && words . Any ( w => w . BoundingBox . CenterX > anchor + Math . Max ( 3 * tolerance , 12.0 ) ) ;
133+ } ) ;
134+
159135 /// <summary>Merges wrapped continuation rows into the row above, leaving new records and finer header levels as their own rows.</summary>
160- private static List < List < Word > > MergeContinuations ( List < Row > baselineRows )
136+ private static List < List < Word > > MergeContinuations ( List < BaselineRow > baselineRows )
161137 {
162138 var tolerance = Math . Max ( MinColumnTolerance , ColumnToleranceFactor * Median ( baselineRows . SelectMany ( r => r . Words ) . Select ( w => w . BoundingBox . Height ) ) ) ;
163139 var stable = StableColumnCenters ( baselineRows , tolerance ) ;
@@ -166,7 +142,7 @@ private static List<List<Word>> MergeContinuations(List<Row> baselineRows)
166142 var anchor = stable [ 0 ] ;
167143 var medianGap = MedianBaselineGap ( baselineRows ) ;
168144
169- var groups = new List < List < Row > > ( ) ;
145+ var groups = new List < List < BaselineRow > > ( ) ;
170146 foreach ( var row in baselineRows )
171147 {
172148 if ( groups . Count == 0 ) { groups . Add ( [ row ] ) ; continue ; }
@@ -189,7 +165,7 @@ private static List<List<Word>> MergeContinuations(List<Row> baselineRows)
189165 /// or a finer header level (introducing columns the group lacks) fails both
190166 /// tests and stays a separate row.
191167 /// </summary>
192- private static bool IsContinuation ( Row row , List < Row > group , double anchor , double tolerance )
168+ private static bool IsContinuation ( BaselineRow row , List < BaselineRow > group , double anchor , double tolerance )
193169 {
194170 if ( row . Words . Any ( w => w . BoundingBox . CenterX <= anchor + tolerance ) ) return false ;
195171
@@ -198,7 +174,7 @@ private static bool IsContinuation(Row row, List<Row> group, double anchor, doub
198174 }
199175
200176 /// <summary>Returns the centres of word columns that recur across baseline rows, left to right.</summary>
201- private static List < double > StableColumnCenters ( List < Row > rows , double tolerance )
177+ private static List < double > StableColumnCenters ( List < BaselineRow > rows , double tolerance )
202178 {
203179 var all = new List < ( double Center , int Row ) > ( ) ;
204180 for ( var r = 0 ; r < rows . Count ; r ++ )
@@ -233,16 +209,13 @@ void Flush()
233209 return stable ;
234210 }
235211
236- private static double MedianBaselineGap ( List < Row > rows )
212+ private static double MedianBaselineGap ( List < BaselineRow > rows )
237213 {
238214 var gaps = new List < double > ( ) ;
239215 for ( var i = 1 ; i < rows . Count ; i ++ ) gaps . Add ( rows [ i - 1 ] . Baseline - rows [ i ] . Baseline ) ;
240216 return Median ( gaps ) ;
241217 }
242218
243- private static Row MakeRow ( List < Word > words ) =>
244- new ( words . Average ( w => w . BoundingBox . CenterY ) , words ) ;
245-
246219 private static double CoefficientOfVariation ( List < double > values )
247220 {
248221 if ( values . Count == 0 ) return double . MaxValue ;
@@ -259,7 +232,4 @@ private static double Median(IEnumerable<double> values)
259232 var mid = sorted . Count / 2 ;
260233 return sorted . Count % 2 == 1 ? sorted [ mid ] : ( sorted [ mid - 1 ] + sorted [ mid ] ) / 2.0 ;
261234 }
262-
263- /// <summary>A baseline-grouped row of words.</summary>
264- private sealed record Row ( double Baseline , IReadOnlyList < Word > Words ) ;
265235}
0 commit comments