Skip to content

Commit bc36244

Browse files
diluculoclaude
andcommitted
Recover ruled-table cells from drawn column slots
Drive table cell recovery off where columns physically start and end rather than off text alignment, so fully ruled tables recover reliably. - Flow each detected table region through XY-cut reading order as a placeholder block (like images and lists), then resolve it back to a TableElement/RegionElement after classification, fixing paragraph vs table ordering without a separate insertion heuristic. - Add a ruled-slot fallback: when leaf recovery from repeated word centres fails the confidence gate, place each word into the x-range its centre falls in between drawn vertical separators. - Merge collinear horizontal rule fragments to one y-level before using them as logical-row boundaries, so a rule split at a column gap still bounds rows. - Trim a wide empty left rule overhang off the detected box so a blank drawn left margin is not read as a first column (left only; a short last column legitimately leaves a wide right gap). - Split a rule band that stacks several whole records, merge dash-only continuation lines into the record above, drop a permanently empty leading column, and read a wrapped cell's words top-then-left. Consolidate the duplicated baseline clustering and stacked-record splitting from LogicalRowBuilder and TableCellRecovery into a shared TableRowGrouping helper; the record test stays per-caller as a delegate. Add XML summaries on the new private members. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent c0da565 commit bc36244

9 files changed

Lines changed: 594 additions & 102 deletions

src/PdfStruct.Tests/LogicalRowBuilderTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,29 @@ public void Build_RuledGrid_BandsAreLogicalRowsAcrossWrappedLines()
9494
Assert.Contains("label", rows[0].Select(w => w.Text));
9595
}
9696

97+
[Fact]
98+
public void Build_RuledGrid_SplitsMultipleCompleteRecordsInsideOneBand()
99+
{
100+
// Dense tables may omit the rule between adjacent body rows. If each
101+
// baseline opens the row-label column and carries data columns, they are
102+
// separate records, not a wrapped cell.
103+
var ruleYs = new List<double> { 712, 690, 668, 646 };
104+
var words = new List<TableCellRecovery.Word>
105+
{
106+
W("Alice", 80, 705), W("91", 200, 705),
107+
W("Bob", 80, 697), W("82", 200, 697),
108+
W("Cara", 80, 679), W("77", 200, 679),
109+
W("Dana", 80, 657), W("73", 200, 657),
110+
};
111+
var region = new BoundingBox(60, 646, 260, 712);
112+
113+
var rows = LogicalRowBuilder.Build(words, region, ruleYs);
114+
115+
Assert.Equal(4, rows.Count);
116+
Assert.Equal(["Alice", "91"], rows[0].Select(w => w.Text).ToArray());
117+
Assert.Equal(["Bob", "82"], rows[1].Select(w => w.Text).ToArray());
118+
}
119+
97120
private static TableCellRecovery.Word W(string text, double centerX, double centerY, double width = 24, double height = 8) =>
98121
new(new BoundingBox(centerX - width / 2, centerY - height / 2, centerX + width / 2, centerY + height / 2), text);
99122
}

src/PdfStruct.Tests/TableCellRecoveryTests.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,76 @@ public void Recover_NoInteriorRules_SingleBandRecoversLeafColumns()
115115
Assert.All(rows, r => Assert.All(r.Cells, c => Assert.Equal(1, c.ColumnSpan)));
116116
}
117117

118+
[Fact]
119+
public void Recover_RuledTable_TrimsCompletelyEmptyLeadingSlot()
120+
{
121+
// A ruled table can carry a decorative or over-wide leading slot before
122+
// the real stub column. The recovered model should not preserve a
123+
// permanently empty first column, because Markdown would render it as a
124+
// blank column with no information.
125+
var words = new List<TableCellRecovery.Word>
126+
{
127+
W("Method", 170, 700, 42), W("SVT", 270, 700, 28), W("IC15", 350, 700, 34),
128+
W("ABBYY", 170, 686, 44), W("40.5", 270, 686, 30), W("-", 350, 686, 12),
129+
W("Ours", 170, 672, 32), W("94.3", 270, 672, 30), W("68.8", 350, 672, 30),
130+
};
131+
var region = new BoundingBox(50, 660, 400, 712);
132+
var boundaries = new List<double> { 120, 230, 320 };
133+
134+
var rows = TableCellRecovery.Recover(words, region, boundaries, [], pageNumber: 1);
135+
136+
Assert.Equal(3, rows.Count);
137+
Assert.All(rows, row => Assert.Equal([1, 2, 3], row.Cells.Select(c => c.ColumnNumber).ToArray()));
138+
}
139+
140+
[Fact]
141+
public void Recover_RuledTable_SplitsStackedCompleteRecords()
142+
{
143+
// Dense ruled tables may have a rule band that visually contains more
144+
// than one data record. Each baseline that has both a stub label and
145+
// value columns must become its own table row.
146+
var words = new List<TableCellRecovery.Word>
147+
{
148+
W("Alice", 90, 705, 34), W("91", 190, 705, 20),
149+
W("Bob", 90, 697, 28), W("82", 190, 697, 20),
150+
W("Cara", 90, 679, 34), W("77", 190, 679, 20),
151+
W("Dana", 90, 657, 34), W("73", 190, 657, 20),
152+
};
153+
var region = new BoundingBox(60, 646, 230, 712);
154+
var boundaries = new List<double> { 140 };
155+
var ruleYs = new List<double> { 712, 690, 668, 646 };
156+
157+
var rows = TableCellRecovery.Recover(words, region, boundaries, ruleYs, pageNumber: 1);
158+
159+
Assert.Equal(4, rows.Count);
160+
Assert.Equal(["Alice", "Bob", "Cara", "Dana"], rows.Select(r => CellText(r.Cells[0])).ToArray());
161+
Assert.Equal(["91", "82", "77", "73"], rows.Select(r => CellText(r.Cells[1])).ToArray());
162+
}
163+
164+
[Fact]
165+
public void Recover_RuledTable_MergesDashOnlyContinuationIntoPreviousRecord()
166+
{
167+
// Some PDFs draw placeholder dashes on a slightly lower baseline. They
168+
// are empty-value markers for the labelled record above, not standalone
169+
// table rows.
170+
var words = new List<TableCellRecovery.Word>
171+
{
172+
W("Alice", 90, 705, 34), W("91", 190, 705, 20),
173+
W("-", 250, 697, 12),
174+
W("Bob", 90, 679, 28), W("82", 190, 679, 20), W("73", 250, 679, 20),
175+
};
176+
var region = new BoundingBox(60, 668, 290, 712);
177+
var boundaries = new List<double> { 140, 220 };
178+
var ruleYs = new List<double> { 712, 701, 690, 668 };
179+
180+
var rows = TableCellRecovery.Recover(words, region, boundaries, ruleYs, pageNumber: 1);
181+
182+
Assert.Equal(2, rows.Count);
183+
Assert.Equal("Alice", CellText(rows[0].Cells[0]));
184+
Assert.Equal("-", CellText(rows[0].Cells[2]));
185+
Assert.Equal("Bob", CellText(rows[1].Cells[0]));
186+
}
187+
118188
private static string CellText(TableCell cell) =>
119189
string.Join(" ", cell.Kids.OfType<ParagraphElement>().Select(p => p.Text.Content));
120190

src/PdfStruct.Tests/TableReconciliationTests.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ public void Parse_TableCaptionContinuation_IsNotClaimedAsTableRow()
6666

6767
var table = doc.Kids.OfType<TableElement>()
6868
.Single(t => t.PageNumber == 6 && t.NumberOfColumns == 3);
69+
Assert.True(caption.Id < table.Id);
70+
Assert.Equal(table.Id, caption.LinkedContentId);
6971
var cellTexts = table.Rows
7072
.SelectMany(r => r.Cells)
7173
.SelectMany(c => c.Kids.OfType<ParagraphElement>())
@@ -103,6 +105,41 @@ public void Parse_TableTrailingNotes_AreReleasedNotClaimedAsTableContent()
103105
Assert.Contains(nonTableText, text => text.Contains(doi, StringComparison.Ordinal));
104106
}
105107

108+
[Fact]
109+
public void Parse_UtilizingLlm_RuledTablesRecoverCells()
110+
{
111+
var doc = ParseFixture("plos_utilizing_llm.pdf");
112+
113+
var tables = doc.Kids.OfType<TableElement>().ToList();
114+
Assert.Equal(5, tables.Count);
115+
Assert.All(tables, table =>
116+
{
117+
Assert.NotEmpty(table.Rows);
118+
Assert.Empty(table.TextLines);
119+
Assert.True(table.NumberOfRows >= 4);
120+
Assert.True(table.NumberOfColumns >= 3);
121+
});
122+
}
123+
124+
[Fact]
125+
public void Parse_GameBasedEducation_RuledResultTablesRecoverCells()
126+
{
127+
var doc = ParseFixture("plos_game_based_education.pdf");
128+
129+
var tables = doc.Kids.OfType<TableElement>().ToList();
130+
Assert.Equal(5, tables.Count);
131+
Assert.Equal(4, tables.Count(t => t.Rows.Count > 0));
132+
Assert.Single(tables, t => t.Rows.Count == 0);
133+
134+
var beck = tables.Single(t => t.Rows
135+
.SelectMany(r => r.Cells)
136+
.Any(c => CellText(c).Contains("Beck Anxiety Inventory", StringComparison.Ordinal)));
137+
Assert.Equal(3, beck.NumberOfRows);
138+
Assert.Equal(6, beck.NumberOfColumns);
139+
Assert.Contains(beck.Rows.SelectMany(r => r.Cells), c => CellText(c) == "Post-test");
140+
Assert.Contains(beck.Rows.SelectMany(r => r.Cells), c => CellText(c).Contains("−7.04", StringComparison.Ordinal));
141+
}
142+
106143
private static string ElementText(ContentElement element) => element switch
107144
{
108145
ParagraphElement p => p.Text.Content,
@@ -112,6 +149,9 @@ public void Parse_TableTrailingNotes_AreReleasedNotClaimedAsTableContent()
112149
_ => string.Empty
113150
};
114151

152+
private static string CellText(TableCell cell) =>
153+
string.Join(" ", cell.Kids.OfType<ParagraphElement>().Select(p => p.Text.Content));
154+
115155
private static PdfDocument ParseFixture(string fixtureName)
116156
{
117157
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", fixtureName);

src/PdfStruct.Tests/TextAwareRuledTableDetectorTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,26 @@ public void Detect_GridColumnsWitnessedByVerticalRulesOnly_StillDetected()
115115
Assert.True(region.BoundingBox.Width > 250, "Region should span the ruled grid.");
116116
}
117117

118+
[Fact]
119+
public void Detect_WideLeadingRuleOverhang_TrimsToPayloadStart()
120+
{
121+
// 1901 page 12 Table 9 has horizontal rules that start far left of the
122+
// real payload. Other tables have only ordinary border padding; this
123+
// wide empty overhang should not become a blank first table column.
124+
var rules = new[] { HRule(50, 400, 200), HRule(50, 400, 170), HRule(50, 400, 140) };
125+
var lines = new List<TextLineBlock>
126+
{
127+
Cell("Method", 140, 185), Cell("SVT", 240, 185), Cell("IC15", 330, 185),
128+
Cell("ABBYY", 140, 158), Cell("40.5", 240, 158), Cell("-", 330, 158),
129+
Cell("Ours", 140, 148), Cell("94.3", 240, 148), Cell("68.8", 330, 148),
130+
};
131+
132+
var region = Assert.Single(TextAwareRuledTableDetector.Detect(lines, rules, []));
133+
134+
Assert.True(region.BoundingBox.Left > 100, "Wide empty leading rule overhang should be trimmed.");
135+
Assert.True(region.BoundingBox.Left <= 140, "Trimmed box must still include the first payload column.");
136+
}
137+
118138
private static BoundingBox HRule(double left, double right, double y) => new(left, y, right, y + 0.5);
119139

120140
private static BoundingBox VRule(double x, double bottom, double top) => new(x, bottom, x + 0.5, top);

src/PdfStruct/Analysis/Tables/LogicalRowBuilder.cs

Lines changed: 25 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0.
33

44
using PdfStruct.Models;
5+
using BaselineRow = PdfStruct.Analysis.Tables.TableRowGrouping.BaselineRow;
56
using Word = PdfStruct.Analysis.Tables.TableCellRecovery.Word;
67

78
namespace PdfStruct.Analysis.Tables;
@@ -30,12 +31,6 @@ namespace PdfStruct.Analysis.Tables;
3031
/// </remarks>
3132
internal 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

Comments
 (0)