Multi-Page PDFs in Financial Document Extraction Pipelines
How to reliably extract structured data from multi-page Indian financial PDFs — bank statements, CAS, ITR — without losing context across pages.
Multi-page PDFs are the norm in Indian financial document processing. A 12-month HDFC bank statement runs 30+ pages. A CAS (Consolidated Account Statement) from CAMS can span 80 pages when a client holds 20+ mutual fund folios. An ITR-3 with Schedule BP and foreign asset disclosures easily hits 60 pages.
Standard extraction approaches that work fine on a single-page invoice fall apart here. Transactions get dropped. Running balances don't reconcile. Folios bleed across page boundaries. This guide covers the patterns that actually work in production.
Why Multi-Page PDFs Break Naive Extractors
The core problem is context fragmentation. When you chunk a document by page and send each page independently to a vision model, you lose three critical signals:
Indian banks make this worse with their formatting choices. SBI statements often break mid-transaction. HDFC uses faint grey horizontal rules that OCR engines confuse with table separators. ICICI PDFs embed table data as image layers in a scanned PDF wrapper.
Strategy 1: Full-Document Vision (Best for ≤ 50 Pages)
Send the entire PDF as a single document content block to a vision-capable model. Lekha's API handles this transparently — you don't need to pre-split.
import { LekhaClient } from "@lekhadev/sdk";
import * as fs from "fs";
const lekha = new LekhaClient({ apiKey: process.env.LEKHA_API_KEY });
async function extractBankStatement(pdfPath: string) {
const pdfBuffer = fs.readFileSync(pdfPath);
const result = await lekha.extract({
document: {
type: "bank_statement",
content: pdfBuffer.toString("base64"),
mimeType: "application/pdf",
},
});
if (!result.success) {
throw new Error(Extraction failed: ${result.error.message});
}
return result.data;
}
const statement = await extractBankStatement("hdfc-12months.pdf");
console.log(Extracted ${statement.transactions.length} transactions);
console.log(Date range: ${statement.period.from} → ${statement.period.to});
Full-document vision preserves every cross-page signal. The model sees column headers, running balances, and continuation rows in a single context window. For documents under 50 pages, this is almost always the right call — don't over-engineer it.
Strategy 2: Semantic Chunking (50–200 Pages)
For longer documents — a year's CAS, or an ITR with full schedules — you need to split intelligently. The key rule: never split at page boundaries. Split at semantic boundaries.
For bank statements, semantic boundaries are month separators. For CAS documents, they are folio separators. For ITRs, they are schedule separators.
import { LekhaClient } from "@lekhadev/sdk";
import { PDFDocument } from "pdf-lib";
async function extractLargeCAS(pdfBuffer: Buffer) {
const lekha = new LekhaClient({ apiKey: process.env.LEKHA_API_KEY });
const pdf = await PDFDocument.load(pdfBuffer);
// Find folio boundaries by scanning for the pattern "Folio No:" in text layer
const folioRanges = await detectFolioPageRanges(pdf);
const extractions = await Promise.all(
folioRanges.map(async ({ from, to, folioId }) => {
const chunk = await extractPageRange(pdf, from, to);
const result = await lekha.extract({
document: {
type: "cas",
content: chunk.toString("base64"),
mimeType: "application/pdf",
hints: { folioId }, // optional context hint
},
});
return result.success ? result.data : null;
}),
);
return mergeCASSections(extractions.filter(Boolean));
}
Providing hints is optional but improves accuracy on long runs — it tells the model what it's looking at without requiring it to re-discover document structure on every chunk.
Strategy 3: Sliding Window with Overlap (Scanned PDFs)
Scanned PDFs have no text layer. You get raw pixel data. When a scanned document has no semantic markers the model can detect, use a sliding window with overlap: each chunk includes the last page of the previous chunk. This provides continuity context — the model can see "this is a continuation" from the overlap.
async function extractScannedStatement(
pdfBuffer: Buffer,
windowSize = 10,
overlap = 2,
) {
const pdf = await PDFDocument.load(pdfBuffer);
const totalPages = pdf.getPageCount();
const lekha = new LekhaClient({ apiKey: process.env.LEKHA_API_KEY });
const windows: Array<{ start: number; end: number }> = [];
let start = 0;
while (start < totalPages) {
const end = Math.min(start + windowSize, totalPages);
windows.push({ start, end });
start = end - overlap; // overlap provides continuity
}
const results = [];
for (const window of windows) {
const chunk = await extractPageRange(pdf, window.start, window.end);
const result = await lekha.extract({
document: {
type: "bank_statement",
content: chunk.toString("base64"),
mimeType: "application/pdf",
},
});
if (result.success) {
results.push({
...result.data,
_pageRange: window,
});
}
}
return deduplicateTransactions(results);
}
Deduplication after a sliding window extraction is critical. Overlap pages will yield duplicate transactions — deduplicate by matching (date, amount, description) tuples and keeping the instance from the earliest window (it has more left-context).
Validating Cross-Page Integrity
Extraction is not done until you validate. The most important check for multi-page financial documents is balance reconciliation: does every transaction chain from opening balance to closing balance without gaps?
interface Transaction {
date: string;
description: string;
debit: number | null;
credit: number | null;
balance: number;
}
interface StatementData {
openingBalance: number;
closingBalance: number;
transactions: Transaction[];
}
function validateBalanceChain(data: StatementData): {
valid: boolean;
gaps: Array<{ index: number; expected: number; found: number }>;
} {
let running = data.openingBalance;
const gaps: Array<{ index: number; expected: number; found: number }> = [];
for (let i = 0; i < data.transactions.length; i++) {
const tx = data.transactions[i];
const delta = (tx.credit ?? 0) - (tx.debit ?? 0);
running = Math.round((running + delta) * 100) / 100;
// Allow ±0.50 tolerance for rounding differences across banks
if (Math.abs(running - tx.balance) > 0.5) {
gaps.push({ index: i, expected: running, found: tx.balance });
running = tx.balance; // re-anchor to statement value
}
}
const closingDrift = Math.abs(running - data.closingBalance);
return {
valid: gaps.length === 0 && closingDrift < 1,
gaps,
};
}
// Usage
const { valid, gaps } = validateBalanceChain(statement);
if (!valid) {
console.warn(
Balance chain has ${gaps.length} gap(s). Re-extracting affected pages.,
);
// Implement targeted re-extraction for the pages around gap indices
}
Running this check catches two common failure modes: dropped transactions (the balance jumps) and duplicate transactions (the balance drifts negative against the statement).
Handling Password-Protected PDFs
Many Indian financial institutions send password-protected PDFs — CAMS CAS statements use the investor's PAN as the password, and several banks use the account holder's date of birth.
Never decrypt PDFs server-side if you can avoid it. The correct pattern is to have your client decrypt and send the decrypted buffer. If you must handle it server-side, decrypt in memory and zero the buffer immediately after extraction — don't write the decrypted file to disk. Lekha processes everything in memory and never persists documents, so your compliance posture is clean once the response is returned.
import { PDFDocument } from "pdf-lib";
async function decryptAndExtract(
encryptedBuffer: Buffer,
password: string,
docType: "bank_statement" | "cas" | "itr",
) {
// Decrypt in memory — never write to disk
const decrypted = await PDFDocument.load(encryptedBuffer, {
password,
ignoreEncryption: false,
});
const decryptedBytes = await decrypted.save({ useObjectStreams: false });
const lekha = new LekhaClient({ apiKey: process.env.LEKHA_API_KEY });
const result = await lekha.extract({
document: {
type: docType,
content: Buffer.from(decryptedBytes).toString("base64"),
mimeType: "application/pdf",
},
});
// Zero-fill the decrypted buffer immediately
decryptedBytes.fill(0);
return result;
}
Production Checklist
Before shipping a multi-page extraction pipeline to production, verify these:
| Check | Why it matters | | ------------------------------------------------------ | ------------------------------------------------------------------------------ | | Balance chain reconciles end-to-end | Confirms no dropped or duplicated transactions | | Transaction count matches statement summary | Some banks print a "total transactions" count on the last page | | Date range covers the full requested period | Chunked extraction can miss boundary months | | All folio/account numbers extracted | CAS documents list all holdings; missing folios mean incomplete portfolio view | | PDF decrypt happens in memory, not on disk | DPDP and data minimization compliance | | Retry on extraction failure, not on validation failure | Retrying a valid-but-incomplete extraction wastes quota |
When to Use the Lekha Playground for Debugging
Before coding against the API, test your specific document at lekhadev.com/playground. Upload the actual PDF your users send — not a test file — and inspect the raw JSON output. This immediately tells you:
Most multi-page extraction bugs are document-specific, not code bugs. The Playground lets you rule out the document before you debug your pipeline.
FAQ
How many pages can Lekha handle in a single API call? Lekha can process documents up to 200 pages in a single call. For documents beyond that, use the semantic chunking approach above — split at natural section boundaries (months, folios, schedules) rather than arbitrary page counts. What's the best chunk size for sliding-window extraction on scanned PDFs? 10 pages per window with 2 pages of overlap works well for Indian bank statement scans. Adjust overlap up to 3 pages if you're seeing many gaps in balance reconciliation — it means transactions are frequently split at your window boundaries. Why does my transaction count differ slightly between Lekha and the bank's statement summary? Indian bank statements sometimes include "memo" or informational rows — UPI reference lookups, charge reversals, or interest accrual entries — that don't affect the balance. Lekha extracts all rows that carry a balance figure; your application should filter based on whetherdebit or credit is non-null.
Do I need different code for PDF vs image-based statements?
No — Lekha accepts both PDF and image (JPEG/PNG) inputs. The mimeType field tells it which to expect. For image-based statements, there is no chunking concern since each image is already a single page; use the sequential extraction approach and merge results in date order.
Multi-page extraction is an engineering problem, not just an AI problem. The model handles the hard part — reading tables across page breaks, inferring column semantics, handling regional date formats. Your job is to give it well-scoped input, validate its output, and handle the edge cases that show up in real user documents.
Try your documents at lekhadev.com/playground and see the structured JSON output for yourself. When you're ready to integrate, the API docs have full reference for all document types and response schemas.
Start free at lekhadev.com — no credit card required.