BlogExtraction7 min read

The row that starts on page two

Table rows split across a page break produce two malformed rows that both pass validation. Detecting the split is the job; the join is trivial.

A twelve-page delivery note. The line items table runs from page two to page eleven. Somewhere on page seven, a row with a long description starts at the bottom of one page and finishes at the top of the next.

Extract page seven and you get a row with a description and no quantity. Extract page eight and you get a row with quantities and half a description. Neither is malformed enough to fail a schema check, because both are shaped like rows.

The failure is not that the row is broken. It is that one row became two plausible rows, and plausible is what your validation is checking for.

Why it survives validation

A schema requiring description, quantity and amount catches the fragment with missing numbers — good. But real line items legitimately have blanks: a sub-heading row, a note, a discount line with no quantity. So most schemas relax those constraints, and the relaxation lets the fragment through.

The one check that reliably catches it is arithmetic: line items no longer sum to the subtotal, because one row's amount got attached to a fragment and counted twice, or lost entirely. That is the argument for cross-field arithmetic in validation — it is the only thing that notices.

Catching it after the fact is not the same as fixing it, though. You get a document routed to review with "does not balance" and a person has to work out why.

Ask the extractor to mark the boundary

The reliable approach is not to reconstruct the split afterwards. It is to make the extractor report it, per page, as part of its output:

type PageTableResult = {
  /** Complete rows on this page. */
  rows: LineItem[]

  /**
   * The last row on this page is incomplete — it continues overleaf.
   * Set when the row has a description but no terminating amount.
   */
  trailingPartial?: Partial<LineItem>

  /**
   * The first row on this page continues one from the previous page.
   * Set when the page opens mid-row: no description, or an indented
   * fragment under no heading.
   */
  leadingContinuation?: Partial<LineItem>

  /** Whether a header row was repeated on this page. */
  headerRepeated: boolean
}

The model is looking at the page and can see that the bottom row has no amount in the amount column. Asking it directly is far more reliable than inferring the same thing from a fragment two steps downstream.

Then the join is mechanical:

function stitch(pages: PageTableResult[]): LineItem[] {
  const out: LineItem[] = []

  for (let i = 0; i < pages.length; i++) {
    const page = pages[i]
    const prev = pages[i - 1]

    if (page.leadingContinuation) {
      if (!prev?.trailingPartial) {
        // One side without the other. Do not guess — this is either a
        // detection error or a missing page, and both need a person.
        throw new SplitMismatch(i, "continuation without partial")
      }
      out.push(merge(prev.trailingPartial, page.leadingContinuation))
    }

    out.push(...page.rows)
  }
  return out
}

The throw is the important line. A one-sided marker means something is wrong with the document or the detection, and silently dropping the fragment is how the original bug comes back wearing a different hat.

Repeated headers and repeated totals

Two related patterns, both of which corrupt the data quietly.

Headers repeat on every page. Description | Qty | Unit | Amount appears at the top of pages 2 through 11. An extractor processing pages independently may read the header as a data row. Ten junk rows, each with the string "Qty" in a quantity field.

Carried-forward subtotals. Long tables often print a running subtotal at the bottom of each page and repeat it at the top of the next, labelled carried forward or brought forward. Read as line items, they inflate the total by roughly the sum of the document — and the arithmetic check then fails in a way that looks like a magnitude error rather than a structural one.

Both need to be recognised and excluded explicitly:

const STRUCTURAL = /^(carried|brought)\s+forward$|^subtotal$|^continued$/i

const realRows = rows.filter(
  (r) => !STRUCTURAL.test(r.description.trim()) && !isHeaderLike(r),
)

Keep the excluded rows rather than dropping them. A carried-forward figure is a free checksum: it should equal the running total at that point, and comparing them catches a missed row long before the document total does.

The alternative: do not paginate

Where the source is a native PDF rather than a scan, you sometimes have a better option — extract the table as a whole rather than page by page.

Table-aware PDF parsers work on the document's internal structure, and a table object frequently spans pages in a single logical unit. Where that works it removes the problem rather than handling it.

It only works on born-digital PDFs with intact structure, and a text layer that can be trusted. For scans and photographs you are back to page-by-page with explicit continuation markers.

Test it deliberately

This is one of the easiest failure modes to build a regression test for, and one of the least likely to occur in a hand-picked sample:

// Documents where a row is known to split. Put at least three in the
// golden set, with the correct stitched output as the label.
const SPLIT_CASES = ["dn-4471", "dn-8823", "inv-9910"]

If your golden set was assembled by choosing documents, it almost certainly has none of these — multi-page documents with mid-row breaks are exactly what a person browsing a folder skips over. They have to be sought out on purpose, which is the general argument for stratified sampling stated in one specific case.


More in page two assumes you read page one, a schema that fails loudly, and how we build extraction pipelines.

Something here

the audit is the cheapest way to find out for certain.