如何用PDFBox解析行数不定、行宽可变的用户银行对账单交易行?
Hey there! I’ve dealt with exactly this kind of bank statement parsing headache before—those variable line widths and unpredictable transaction lines are no joke. Let’s break down how to tackle this with PDFBox effectively:
The default TextStripper and TextArea tools work great for simple layouts, but bank statements need a more targeted approach that leverages text position metadata (coordinates, font, size) to identify and group transaction lines.
1. First, Map the PDF's Layout Patterns
Before writing code, analyze a few sample statements to spot consistent patterns:
- Transaction lines often share a similar vertical spacing (Y-coordinate range)
- Fields like date, description, and amount usually sit in fixed horizontal zones (even if line widths vary)
- Non-transaction lines (headers, footers, totals) have distinct font sizes or positions
You can use PDFBox's PDFRenderer to render pages as images, or temporarily draw bounding boxes around text positions to visualize these zones (more on that later).
2. Build a Custom TextStripper to Track Transaction Lines
Instead of using the default TextStripper, extend it to track text positions by their Y-coordinates—this lets you group text into lines dynamically, even if line widths are variable. Here’s a working example:
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; import org.apache.pdfbox.text.TextPosition; import java.io.IOException; import java.util.ArrayList; import java.util.List; public class TransactionStripper extends PDFTextStripper { private List<List<TextPosition>> transactionLines = new ArrayList<>(); private List<TextPosition> currentLine = new ArrayList<>(); private float lastY = -1; // Adjust this tolerance based on your PDFs (accounts for minor vertical shifts) private final float Y_TOLERANCE = 2.5f; public TransactionStripper() throws IOException { super(); } @Override protected void writeString(String text, List<TextPosition> textPositions) throws IOException { for (TextPosition tp : textPositions) { float currentY = tp.getYDirAdj(); // Check if we've moved to a new line (outside Y tolerance) if (lastY != -1 && Math.abs(currentY - lastY) > Y_TOLERANCE) { // Only add the line if it looks like a transaction if (isPotentialTransaction(currentLine)) { transactionLines.add(new ArrayList<>(currentLine)); } currentLine.clear(); } currentLine.add(tp); lastY = currentY; } } // Custom filter to skip non-transaction lines (tweak this for your statements) private boolean isPotentialTransaction(List<TextPosition> line) { boolean hasNumericAmount = false; int totalTextLength = 0; for (TextPosition tp : line) { String text = tp.getUnicode().trim(); totalTextLength += text.length(); // Check for numbers (including decimals for amounts) if (text.matches(".*\\d+\\.?\\d*.*")) { hasNumericAmount = true; } } // Require a minimum length and a numeric value to filter headers/footers return hasNumericAmount && totalTextLength > 8; } public List<List<TextPosition>> getCleanTransactionLines() { // Add the final line after processing all text if (isPotentialTransaction(currentLine)) { transactionLines.add(currentLine); } return transactionLines; } }
3. Parse Fields Using Horizontal Coordinates
Once you have grouped transaction lines, use each TextPosition's X-coordinate to split the line into fields (date, description, amount). Even if line widths vary, these fields usually stay within consistent horizontal ranges:
public static void parseTransactionFields(List<List<TextPosition>> transactionLines) { for (List<TextPosition> line : transactionLines) { StringBuilder date = new StringBuilder(); StringBuilder description = new StringBuilder(); StringBuilder amount = new StringBuilder(); for (TextPosition tp : line) { float xPos = tp.getXDirAdj(); String text = tp.getUnicode().trim(); if (text.isEmpty()) continue; // Adjust these X ranges based on your PDF's layout if (xPos < 160) { date.append(text).append(" "); } else if (xPos < 480) { description.append(text).append(" "); } else { amount.append(text); } } // Output parsed data (replace with your storage logic) System.out.println("Date: " + date.toString().trim()); System.out.println("Description: " + description.toString().trim()); System.out.println("Amount: " + amount.toString().trim()); System.out.println("------------------------"); } }
4. Handle Multi-Line Transactions
Some transactions have wrapped descriptions that span multiple lines. Add logic to merge these lines into a single transaction by checking if a line lacks a date field:
public static List<List<TextPosition>> mergeMultiLineTransactions(List<List<TextPosition>> rawLines) { List<List<TextPosition>> cleanedTransactions = new ArrayList<>(); List<TextPosition> currentTransaction = null; for (List<TextPosition> line : rawLines) { boolean hasDate = false; // Check if the line contains a date pattern (adjust regex for your date format) for (TextPosition tp : line) { if (tp.getXDirAdj() < 160 && tp.getUnicode().matches("\\d{2}/\\d{2}/\\d{4}")) { hasDate = true; break; } } if (hasDate) { // Save the previous transaction if it exists if (currentTransaction != null) { cleanedTransactions.add(currentTransaction); } currentTransaction = new ArrayList<>(line); } else { // Append to the current transaction's description if (currentTransaction != null) { currentTransaction.addAll(line); } } } // Add the final transaction if (currentTransaction != null) { cleanedTransactions.add(currentTransaction); } return cleanedTransactions; }
5. Quick Tips for Debugging & Adaptation
- Visualize text positions: Use PDFBox's
PDPageContentStreamto draw rectangles around eachTextPosition—this helps you fine-tune X/Y ranges. - Handle scanned PDFs: If your statements are image-based (not text-embedded), you’ll need to combine PDFBox with an OCR tool like Tesseract to extract text first.
- Check for tagged PDFs: Some banks provide structured tagged PDFs—use
PDDocument.isTagged()to check, and extract structured data directly if available.
内容的提问来源于stack exchange,提问作者Amit

