Before you can extract transactions from a PDF, you need to rebuild the rows humans see on the page.
A PDF does not usually give you clean rows. It gives you text fragments with coordinates. For financial documents, this means the parser must reconstruct visual rows before it can identify dates, descriptions, amounts, and balances.
When humans read a bank statement, they naturally group text into horizontal rows. But PDF extraction libraries may return text fragments in a different order.
| Visual row | Meaning |
|---|---|
| 03/14 Amazon Marketplace 49.90 950.10 | One transaction |
Internally, the PDF may expose the same content as separate positioned fragments:
| Text | X | Y |
|---|---|---|
| 03/14 | 42 | 510 |
| Amazon Marketplace | 120 | 511 |
| 49.90 | 460 | 509 |
| 950.10 | 540 | 510 |
The Y values are close, but not identical. A reliable parser must treat them as one visual row.
Row rebuilding is based on vertical proximity. Text fragments that share a similar Y coordinate are likely part of the same visual row.
The parser first normalizes PDF text fragments into a simple structure.
public record TextElement(
int page,
String text,
float x,
float y,
float width,
float height
) {}
This representation keeps the parsing logic independent from the underlying PDF library.
Before grouping, we sort fragments by page, then by vertical position, then by horizontal position.
public class TextElementSorter {
public List<TextElement> sort(List<TextElement> elements) {
return elements.stream()
.sorted(Comparator
.comparingInt(TextElement::page)
.thenComparing(TextElement::y)
.thenComparing(TextElement::x))
.toList();
}
}
Because PDF text coordinates are rarely perfectly aligned, exact Y matching does not work. We use a tolerance threshold.
public class RowBuilder {
private static final float Y_TOLERANCE = 3.0f;
public List<VisualRow> buildRows(List<TextElement> elements) {
List<VisualRow> rows = new ArrayList<>();
for (TextElement element : elements) {
VisualRow target = findMatchingRow(rows, element);
if (target == null) {
target = new VisualRow(element.page(), element.y());
rows.add(target);
}
target.add(element);
}
rows.forEach(VisualRow::sortByX);
return rows;
}
private VisualRow findMatchingRow(List<VisualRow> rows, TextElement element) {
for (VisualRow row : rows) {
if (row.page() == element.page()
&& Math.abs(row.baselineY() - element.y()) <= Y_TOLERANCE) {
return row;
}
}
return null;
}
}
public class VisualRow {
private final int page;
private final float baselineY;
private final List<TextElement> elements = new ArrayList<>();
public VisualRow(int page, float baselineY) {
this.page = page;
this.baselineY = baselineY;
}
public void add(TextElement element) {
elements.add(element);
}
public void sortByX() {
elements.sort(Comparator.comparing(TextElement::x));
}
public int page() {
return page;
}
public float baselineY() {
return baselineY;
}
public List<TextElement> elements() {
return elements;
}
public String text() {
return elements.stream()
.map(TextElement::text)
.collect(Collectors.joining(" "));
}
}
Tolerance is one of the most important parameters in row reconstruction.
Input fragments:
| Text | X | Y |
|---|---|---|
| 03/14 | 42 | 510 |
| Amazon Marketplace | 120 | 511 |
| 49.90 | 460 | 509 |
| 950.10 | 540 | 510 |
Output row:
03/14 Amazon Marketplace 49.90 950.10
03/14 Grocery Store
84.20
The amount was visually aligned with the transaction, but its Y coordinate was 4px lower than the description. The fix was not to increase the threshold blindly, but to combine Y proximity with row height and page-specific spacing.
A production parser should avoid relying only on one Y threshold. Additional signals can improve row matching:
public boolean overlapsVertically(TextElement a, TextElement b) {
float aTop = a.y();
float aBottom = a.y() + a.height();
float bTop = b.y();
float bBottom = b.y() + b.height();
return aTop <= bBottom && bTop <= aBottom;
}
Not every visual row is a complete transaction. Descriptions often span multiple lines.
03/14 ATM Withdrawal
Card Ending 0345
100.00
Row reconstruction should not immediately decide whether each row is a transaction. It should only rebuild visual rows. A later transaction-merging layer can decide whether a row is a continuation.
When row grouping fails, the best debugging tool is a simple coordinate dump:
for (VisualRow row : rows) {
System.out.println("page=" + row.page()
+ " y=" + row.baselineY()
+ " text=" + row.text());
}
This makes it much easier to see whether the parser is splitting rows too aggressively or merging unrelated rows.
During internal testing, most row reconstruction errors came from two situations: fragments from the same visual row having slightly different Y coordinates, and nearby rows being merged when the vertical tolerance was too permissive.
The most useful signals were baseline proximity, text height overlap, page-level spacing, and whether the resulting row preserved the expected order of dates, descriptions, and amounts. When these signals agreed, downstream steps such as table detection and transaction parsing became much less fragile.
This does not make row reconstruction perfect. Dense statements, OCR output, rotated pages, and very small fonts can still create ambiguous row boundaries. But combining multiple layout signals provides a much stronger foundation than relying on raw text order alone.
Once rows are rebuilt reliably, downstream components can detect transaction tables, infer columns, parse amounts, and export structured financial data.
Rebuilding rows from positioned PDF text is a foundational step in financial PDF parsing. It turns raw coordinates into the visual rows that humans naturally see.
Reliable row reconstruction combines sorting, Y-coordinate grouping, tolerance tuning, vertical overlap checks, and careful handling of continuation rows.