Rebuilding Rows from Positioned PDF Text

By StatementSheet Engineering Team

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.

At StatementSheet, row reconstruction became one of the most important layers in our bank statement parser. Small mistakes at this stage can cascade into broken columns, incorrect transactions, and bad CSV exports.

Why Row Reconstruction Matters

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 rowMeaning
03/14 Amazon Marketplace 49.90 950.10One transaction

Internally, the PDF may expose the same content as separate positioned fragments:

TextXY
03/1442510
Amazon Marketplace120511
49.90460509
950.10540510

The Y values are close, but not identical. A reliable parser must treat them as one visual row.

The Core Idea

Row rebuilding is based on vertical proximity. Text fragments that share a similar Y coordinate are likely part of the same visual row.

Positioned text fragments ↓ Sort by page and Y coordinate ↓ Group fragments within Y tolerance ↓ Sort each row by X coordinate ↓ Return visual rows

Step 1 — Represent Positioned Text

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.

Step 2 — Sort Text by Page and Position

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();
    }
}

Step 3 — Group by Y Tolerance

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;
    }
}

VisualRow Model

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(" "));
    }
}

Choosing the Right Y Tolerance

Tolerance is one of the most important parameters in row reconstruction.

In financial PDFs, a tolerance around 2–4px often works well for digitally generated statements. Scanned or OCR-based PDFs may require a larger tolerance because OCR bounding boxes are noisier.

Example: Correct Row Reconstruction

Input fragments:

TextXY
03/1442510
Amazon Marketplace120511
49.90460509
950.10540510

Output row:

03/14 Amazon Marketplace 49.90 950.10

Real Failure Case

One early bug occurred when the parser treated a single transaction row as two different rows because the amount column had a slightly different Y coordinate.
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.

Improving Row Matching

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;
}

Handling Multi-line Transactions

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.

Debugging Row Reconstruction

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.

Row Reconstruction Accuracy in Practice

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.

In our tests, introducing vertical overlap checks in addition to Y-coordinate grouping made row reconstruction significantly more reliable than using a single Y threshold alone.

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.

Connection to Product

At StatementSheet, row reconstruction is a core part of converting bank statement PDFs into structured Excel and CSV datasets.

Once rows are rebuilt reliably, downstream components can detect transaction tables, infer columns, parse amounts, and export structured financial data.

Known Limitations

Row reconstruction is harder on scanned PDFs, rotated pages, low-resolution OCR output, and statements with dense text blocks. These cases may require OCR-specific tuning or page-level layout analysis.

Conclusion

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.

← Previous article Detecting Transaction Tables in Bank Statements