Parsing multi-page PDF receipts and folios

A hotel folio or airline invoice that spans several pages breaks single-page extraction in two silent ways: the repeated column header on page two is parsed as a phantom line item, and a charge whose description wraps across the page boundary is split into two half-rows that never reconcile against the printed grand total. This guide handles the stitching problem under the parent pdfplumber Line-Item Parsing guide, inside the broader Receipt Ingestion & OCR Data Extraction framework, where documents routinely arrive as multi-page folios rather than tidy single receipts.

The goal is a single reconciled line-item set per document whose sum matches the folio total, so that a multi-page stay reaches Automated Policy Validation & Anomaly Flagging as one coherent record instead of a scatter of orphaned page fragments.

Multi-page folio stitching pipeline with a grand-total reconciliation gate A multi-page folio carrying repeated headers and footers enters a pipeline. The first stage strips the repeated header and footer from every page so a column header on page two is not read as a line item. The second stage reattaches rows that were split across a page boundary. The third stage stitches the surviving rows and accumulates them into one line-item set. The fourth stage sums the line items into a running total. A reconciliation gate then checks whether the summed line items equal the printed folio grand total. A match passes to a reconciled ledger record; a mismatch is flagged as a discrepancy and routed to duplicate and exception review. Strip, reattach, stitch, then reconcile against the printed total Multi-page folio repeated headers & footers Strip repeated header & footer drop bands that repeat per page Reattach boundary-split rows merge a row cut by a page break Stitch + accumulate items one ordered line-item set Sum items → running total Decimal accumulation == folio total? reconcile match mismatch Reconciled ledger record items sum to grand total Discrepancy flagged duplicate / exception review

Why Standard Approaches Fail

Concatenating page.extract_text() across pages and parsing the blob treats a folio as one flat document, which corrupts exactly the totals an audit depends on. Three named failure modes account for most of the damage.

  • Repeated-header phantom rows. Multi-page folios reprint the column header — Date Description Amount — and often a “carried forward” subtotal on every page. Parse each page independently and page two contributes a header row and a running-balance row as if they were charges, inflating the line-item count and, when the balance line parses as an amount, the total.
  • Boundary-split charges. A charge whose description wraps, or whose amount sits one line below its label, can straddle the page break: the description lands at the bottom of page one and the amount at the top of page two. Independent parsing emits a description with no amount and an amount with no description, and neither reconciles against the printed grand total.
  • Per-page subtotal double-counting. Hotel folios print a per-night or per-department subtotal that repeats and rolls up. Summing every numeric row adds those subtotals on top of the line items they summarize, so the computed total overshoots the real charge and the receipt is wrongly flagged — or, worse, quietly accepted at the inflated figure.

The remedy is to treat the folio as an ordered sequence of pages sharing one header and footer template, strip the repeats, reattach rows split at the boundary, and reconcile the stitched items against the document’s own printed total before anything posts.

Architecture & Algorithm

The stitcher below reads all pages, detects the header and footer bands by finding text that repeats at the same vertical position across pages, drops those bands, reattaches a trailing description on one page to the leading amount on the next, and accumulates the surviving rows into one ordered set with a Decimal running total. It then reconciles that total against the grand-total line so a mismatch is flagged rather than posted. Pages are processed in order and released one at a time, so resident memory stays flat regardless of folio length.

from __future__ import annotations

import json
import logging
import re
from collections import Counter
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
from typing import List, Optional

import pdfplumber

logger = logging.getLogger("expense_audit.multipage_folio")
if not logger.handlers:
    logger.setLevel(logging.INFO)
    _handler = logging.StreamHandler()
    _handler.setFormatter(
        logging.Formatter('{"ts":"%(asctime)s","lvl":"%(levelname)s","msg":"%(message)s"}')
    )
    logger.addHandler(_handler)

_AMOUNT_RE = re.compile(r"(\d[\d,]*\.\d{2})")
_GRAND_TOTAL_RE = re.compile(r"(?i)\b(?:grand\s+total|balance\s+due|amount\s+due|total)\b")
_SUBTOTAL_RE = re.compile(r"(?i)\b(?:subtotal|carried\s+forward|balance\s+forward)\b")


@dataclass(frozen=True)
class FolioLine:
    """One stitched charge line with the page it originated on."""

    description: str
    amount: Decimal
    source_page: int


@dataclass
class FolioResult:
    """Reconciled multi-page extraction with an explicit audit verdict."""

    correlation_id: str
    line_items: List[FolioLine]
    computed_total: Decimal
    stated_total: Optional[Decimal]
    reconciled: bool
    page_count: int
    flags: List[str] = field(default_factory=list)


def _parse_amount(text: str) -> Optional[Decimal]:
    match = _AMOUNT_RE.search(text)
    if not match:
        return None
    try:
        return Decimal(match.group(1).replace(",", ""))
    except InvalidOperation:
        return None


def _repeated_band_lines(pages_text: List[List[str]], min_pages: int = 2) -> set:
    """Return line strings that repeat verbatim across pages (header/footer)."""
    counter: Counter = Counter()
    for lines in pages_text:
        # A repeated header/footer line appears once per page; dedupe within page.
        for line in set(l.strip() for l in lines if l.strip()):
            counter[line] += 1
    return {line for line, n in counter.items() if n >= min_pages}


def _page_lines(page) -> List[str]:
    text = page.extract_text() or ""
    return [ln for ln in text.splitlines() if ln.strip()]


def parse_folio(pdf_path: str, correlation_id: str) -> FolioResult:
    """Stitch a multi-page folio into one reconciled line-item set."""
    with pdfplumber.open(pdf_path) as pdf:
        pages_text = [_page_lines(page) for page in pdf.pages]

    page_count = len(pages_text)
    repeated = _repeated_band_lines(pages_text)

    items: List[FolioLine] = []
    stated_total: Optional[Decimal] = None
    pending_description: str = ""      # carries a split row across a page break

    for page_idx, lines in enumerate(pages_text):
        for line in lines:
            stripped = line.strip()
            if stripped in repeated:
                continue                      # drop repeated header/footer band
            if _SUBTOTAL_RE.search(stripped):
                continue                      # never sum a subtotal into the items
            if _GRAND_TOTAL_RE.search(stripped):
                stated_total = _parse_amount(stripped) or stated_total
                continue

            amount = _parse_amount(stripped)
            label = _AMOUNT_RE.sub("", stripped).strip(" .-")
            if amount is None:
                # No amount yet: hold the label as a possible split row.
                pending_description = f"{pending_description} {label}".strip()
                continue

            description = f"{pending_description} {label}".strip() or "(unlabeled charge)"
            pending_description = ""
            items.append(FolioLine(description=description, amount=amount, source_page=page_idx))

    computed = sum((i.amount for i in items), Decimal("0"))
    reconciled = stated_total is not None and computed == stated_total

    flags: List[str] = []
    if stated_total is None:
        flags.append("NO_STATED_TOTAL")
    elif not reconciled:
        flags.append("TOTAL_MISMATCH")

    result = FolioResult(
        correlation_id=correlation_id,
        line_items=items,
        computed_total=computed,
        stated_total=stated_total,
        reconciled=reconciled,
        page_count=page_count,
        flags=flags,
    )
    logger.info(json.dumps({
        "correlation_id": correlation_id, "pages": page_count,
        "items": len(items), "computed": str(computed),
        "stated": str(stated_total), "reconciled": reconciled, "flags": flags,
    }))
    return result

Reconciliation is the audit anchor: parse_folio never trusts its own sum, it checks the stitched line items against the folio’s printed grand total and raises TOTAL_MISMATCH when they disagree. The pending_description carry handles the boundary split — a label with no amount is held and joined to the next amount it precedes, even across a page break — and subtotal lines are dropped so they can never be double-counted.

Step-by-Step Integration

  1. Confirm the document is genuinely multi-page. Route only documents with more than one page through this stitcher; single receipts stay on the standard parser. A one-page folio sent through repeated-band detection loses nothing, but the check keeps the fast path fast.

  2. Detect header and footer bands from repetition, not hard-coded coordinates. Vendors move their headers, so identify the repeated band by finding lines that appear on multiple pages rather than assuming the top N points. Pin the pdfplumber version so extract_text line breaks stay stable:

    pip install "pdfplumber==0.11.4" "pdfminer.six==20231228"
  3. Carry split rows across the boundary explicitly. Keep a pending description when a line has a label but no amount, and attach it to the next amount. Verify a boundary-split charge stitches back into one line:

    result = parse_folio("fixtures/hotel_folio_3pages.pdf", "EXP-7742")
    assert result.page_count == 3
    assert all(item.amount > 0 for item in result.line_items)
    assert "(unlabeled charge)" not in {i.description for i in result.line_items}
  4. Reconcile against the printed grand total before posting. Treat the folio’s own total as ground truth and fail loudly on a mismatch:

    assert result.reconciled, (
        f"folio did not reconcile: items sum {result.computed_total} "
        f"vs stated {result.stated_total}, flags {result.flags}"
    )
  5. Feed reconciled line items to cross-checks, not just the total. Hand the stitched item set to duplicate receipt detection so a folio re-submitted across overlapping expense reports is caught on its line items, and reuse the per-vendor grids from Improving table extraction accuracy in pdfplumber when a folio page is a ruled table rather than free text.

  6. Route mismatches to review. Send any document carrying TOTAL_MISMATCH or NO_STATED_TOTAL to the queue owned by Receipt Error Categorization with both totals attached, rather than posting an unreconciled figure.

Edge Cases & Gotchas

Edge condition What breaks Mitigation
Header text shifts a few points per page Coordinate-based header stripping misses it Detect repeated bands by text repetition across pages, not fixed y
Charge split across the page break Description and amount become two orphan rows Carry a pending description and join it to the next amount
Per-night or department subtotal lines Summing all numbers double-counts the charges Skip lines matching the subtotal pattern before accumulation
Multiple grand-total candidates The wrong figure is treated as the total Prefer the last grand-total match on the final page; log all candidates
Currency changes mid-folio (FX stay) One Decimal sum mixes currencies Accumulate per currency; reconcile each currency separately
Blank continuation page Repeated-band detection over-counts a blank line Filter empty lines before band detection (handled in _page_lines)
Folio with a native table on some pages Free-text stitching under-reads the ruled page Route ruled pages through the table extractor, then merge item sets

Because an unreconciled or double-counted total would undermine SOX Section 404 control expectations, the computed total, the stated total, and every flag are logged per document so the reconciliation verdict is reproducible from the pinned configuration.

FAQ

How do I stop repeated page headers from becoming line items?

Detect the header and footer as bands of text that repeat across pages rather than assuming a fixed region at the top and bottom. Collect the distinct lines on each page, count how many pages each line appears on, and drop any line that recurs on two or more pages before you parse charges. This survives vendors that nudge their header position between pages, which coordinate-based stripping does not.

A charge is split across the page break — how do I stitch it back?

Carry the incomplete part forward. When a line has a description but no amount, hold that description as pending instead of emitting a broken row, and when the next amount appears, join the pending description to it. Because the carry persists across the page boundary, a description at the foot of page one reunites with its amount at the head of page two into a single line item.

Why do my folio totals come out too high?

Almost always because per-night or per-department subtotal lines are being summed alongside the individual charges they already summarize. Match and skip subtotal, carried-forward, and balance-forward lines before accumulation, and reconcile the remaining line items against the printed grand total. If the sum still overshoots, look for a running-balance column being parsed as a charge amount.

Should I trust my computed total or the printed grand total?

Treat the printed grand total as ground truth and use your computed sum only to verify it. Extraction can miss or double a line, so a computed total that matches the stated total is strong evidence the stitch is complete, and a mismatch is a signal to hold the document for review. Never overwrite the stated total with your own figure — flag the discrepancy and route it out.

How do I keep memory flat on very long folios?

Read pages in order and release each one as you go rather than materializing every page image at once. The stitcher only needs each page’s text lines and a small pending-description carry, so resident memory is a function of one page plus the accumulated item list, not of the total page count. For high volume, run the whole parse behind the async batch queue so a long folio never blocks a request path.