DEV Community

Cover image for Handling Tables That Split Across Page Breaks
Simon Briggs
Simon Briggs

Posted on

Handling Tables That Split Across Page Breaks

You've written the extraction logic. It works beautifully on every test PDF you threw at it. Then a user uploads a real-world invoice with a 40-row table, and your parser hands back two separate tables instead of one, with the second missing its header row and the first missing its last three rows of data. Welcome to one of the most quietly annoying problems in document processing.

Tables that span multiple pages are everywhere in real documents: financial statements, invoices, medical records, research papers, government forms. PDF, unlike HTML, has no native concept of a "table" at all. What looks like a table to a human is just a collection of text fragments and line drawings positioned at specific coordinates. When that visual table happens to straddle a page boundary, your extraction pipeline sees two unrelated pages with unrelated content, and it's on you to prove they're actually one continuous structure.

This is a walkthrough of the two problems that show up together almost every time: stitching a table back together when a row gets physically cut in half, and detecting when a header row has been reprinted so you don't treat it as a duplicate line of data.

Why this is harder than it sounds

A naive approach treats each page independently: extract the table on page 1, extract the table on page 2, done. This breaks in two specific ways.

First, rows get cut mid-page. A row with wrapped text (say, a product description that spans two lines) might have its first line rendered at the bottom of page 1 and its second line at the top of page 2. If your extractor works page by page, it sees these as two separate, incomplete rows rather than one row with a line break in it.

Second, well-formatted documents repeat the header row at the top of each continuation page, because that's good design for a human reader flipping through printed pages. Your parser doesn't know that. It just sees another row of cells that happens to contain the same text as row one, and if you're not careful, that header ends up in your dataset as a bogus data row.

Both problems come from the same root cause: the renderer that produced the PDF was optimized for human eyeballs, not machine parsing. Your job is to reverse-engineer the human-readable presentation back into a structured, page-independent table.

Detecting a table has been cut off

Before you can stitch anything, you need a reliable signal that a table continues onto the next page. A few heuristics work well in combination, and you generally want more than one to agree before you commit to a merge:

  • Column geometry match. Extract the x-coordinates of column boundaries on the last table on page N and the first table on page N+1. If the column count and horizontal positions line up within a small tolerance, that's a strong signal it's the same table.
  • Bottom-of-page proximity. If the last row of a table on page N ends very close to the page's bottom margin, and the next page's table starts near the top margin, that's consistent with a page break interrupting an otherwise continuous flow, rather than the natural end of a table followed by unrelated content.
  • Incomplete row shape. If the last row on page N has fewer populated cells than the established column count, or one cell's text looks truncated (ends without terminal punctuation, mid-sentence, or a cell that clearly needs more line height than the space allows), treat it as a candidate fragment rather than a finished row.
  • No table-ending marker. Genuine tables often close with something like a totals row, a horizontal rule that's visually distinct from the body dividers, or a caption. Absence of any of these at the bottom of the page nudges you toward "continues." None of these signals is bulletproof alone. A short table can legitimately end near the bottom margin. A single-column table can coincidentally match geometry with an unrelated one. Combine at least two or three signals and set a confidence threshold before merging.

The stitching logic

Once you've decided two page-level tables are fragments of one logical table, the actual merge follows a fairly mechanical pattern:

  1. Normalize columns first. Map columns from both fragments to a shared schema by position, not just by index, since PDF layout tools sometimes drop empty trailing cells.
  2. Check the boundary row. Compare the last row of the first fragment to the first row of the second fragment. If the first fragment's last row has fewer cells filled than expected, or a cell ends mid-word, and the second fragment's first row looks like a continuation (starts lowercase, no numeric or date value in a column that expects one, fewer total cells than usual), merge those two rows into one by concatenating cell text with a space or newline as appropriate to the column type.
  3. Reindex row numbers. If your source data has explicit row numbers or IDs, make sure the merge doesn't produce a duplicate or a gap.
  4. Carry formatting metadata across the seam. If you're preserving cell styles (bold totals, currency formatting, alignment), the merged row should inherit from whichever fragment actually contains the substantive content, not the one that just holds a wrapped line.
  5. Re-validate row width. After merging, confirm the combined row matches the column count of the rest of the table. A mismatch here usually means your boundary detection was wrong and you've merged something that shouldn't have been merged. A practical detail worth calling out: don't try to detect and stitch in a single pass over the raw text stream. It's much easier to first extract every page's table independently into a clean intermediate structure (rows and cells with coordinates), and only then run a second pass that looks at adjacent tables across page boundaries. Separating "extract" from "reconcile" keeps each stage testable on its own.

Detecting repeated headers

Once stitching is working, the header duplication problem is comparatively simple, but easy to get subtly wrong.

The straightforward approach is a direct text comparison: cache the header row's cell text when you first encounter the table, then compare each subsequent page's first row against it. If it matches closely (allowing for minor differences like an added page number in one cell, or slight whitespace variance), discard it rather than appending it as data.

A few refinements make this more robust in practice:

  • Fuzzy match, not exact match. Some renderers reprint headers with a subtly different format (different capitalization, an appended "(cont.)"). Use a similarity threshold, like normalized Levenshtein distance or a simple token-overlap ratio, rather than requiring a byte-for-byte match.
  • Structural check as a backstop. If the row's cells match the data types of your header (all short strings, no numeric values, positioned identically to row one) even when the exact text differs, that's still a useful secondary signal.
  • Position matters. Only apply this check to the first row of a continuation page, not to rows in the middle of a page. A legitimate data row that happens to resemble the header text is rare, but not impossible in messy data, and you don't want to silently drop real rows.
  • Log what you discard. Keep a record of any row you filtered out as a suspected duplicate header. When something goes wrong six months from now, that log is what lets you tell the difference between a bug and a genuinely repeated data row.

Putting it together

The general shape of a robust pipeline looks like this: extract tables per page into an intermediate structure, run a boundary-detection pass across adjacent pages to flag likely continuations, stitch flagged fragments using the boundary-row logic above, and strip any repeated header rows from continuation pages before finalizing the table.

None of these steps require exotic tooling. What they require is treating "one table" as a hypothesis you build evidence for across pages, rather than an assumption baked into a page-by-page loop. Once you internalize that, tables that split across page breaks stop being an edge case you patch around and become just another normal shape your parser expects to see.

Top comments (0)