Your detector scope should match your content scope
TLDR: When a pattern detector fails on multi-column pages, the instinct is to tighten thresholds. That is usually wrong. The detector is running on the wrong scope. Split by page column first, then detect. The detector gets cleaner input and the thresholds stay honest.
What the industry does
Most PDF table detectors are trained on or tuned for single-column pages. Multi-column handling is usually an afterthought: try to detect columns first, fail gracefully when the column detection fails, accept that accuracy drops on two-column layouts.
Some detectors add explicit multi-column modes. The multi-column mode adjusts thresholds (tighter fill rate, lower item count caps) hoping to distinguish left-column tables from right-column prose. This sometimes works. It works by coincidence: the pathological inputs that come from cross-column contamination happen to fail the tighter thresholds on the specific documents those thresholds were tuned to.
Why it fails for this problem class
A stream table detector builds column anchors from X position clustering. On a two-column page, items from column A and items from column B are at very different X positions. They don't cluster together. They become two separate anchor groups with a large gap between them.
That gap looks, statistically, like a page column boundary. And it is. But there is no clean threshold for "gap this large means page column, gap smaller means intra-table." The gap size depends on the page layout, the column widths, and the content of each column. A dense narrow table next to a wide prose column has a smaller inter-anchor gap than two sparsely populated columns on an A3 page.
Every threshold you add is tuned on the documents you tested. It breaks on layouts outside that range. And the failure mode is silent: false positives that look plausible, false negatives on real tables that happen to have unusual column spacing.
The better approach
Split the item set before detection, not inside it.
The contextClassifier already runs column detection as part of the main layout pass. The same function works on unclaimed text. The output is a list of X positions that divide the page into columns. Before stream detection runs, call the column detector on unclaimed text and filter items by zone.
const { splits } = detectPageColumns(unclaimedMeta, viewport, scale);
const streamColXs = splits
.map(s => s.x)
.filter(x => x > viewport.width 0.1 && x < viewport.width 0.9);
// Detect per zone, not on the whole page const streamTables = detectStreamTables( unclaimedMeta, scale, latticeRegions, segments, streamColXs );
The detector receives items from a single page column. Every metric it computes is now meaningful because all items belong to the same content stream. No threshold adjustment needed.
What you give up
Pre-splitting is only as good as the column detector. If the column detector misidentifies column boundaries, items get assigned to the wrong zone and some content gets missed.
This is a real cost. The column detector has its own failure modes on unusual layouts. When it fires incorrectly, it damages the stream detector's recall on that page.
But compare the failure modes. Threshold tuning fails silently and specifically: the detector fires on exactly the inputs that don't match the training distribution, with no way to know which inputs those are. Pre-splitting fails visibly: if the column detector finds no split on a two-column page, the stream detector runs on the full page (same behavior as before, just without the relaxed thresholds). Failure is graceful.
Pre-splitting also makes the detector itself simpler. The _splitAnchorsAtXGap logic inside the detector, which tried to detect page columns from within the anchor set, can stay as a fallback for cases where the pre-pass column detector finds nothing. But the happy path no longer depends on it.
When the common pattern is right
If your pages are reliably single-column, running a detector on the full page item set is fine. There is no contamination when there is only one content stream.
If your pages can be multi-column but the column detector is unreliable (unusual layouts, text art, mixed orientations), then you need a different strategy: run detection on the full page but add a stricter distribution check inside the detector. Not a threshold adjustment. A structural check: are the anchors evenly distributed across rows, or are they concentrated in one column-shaped cluster?
That check is anchor quality: for each anchor, how many distinct rows contain it? If most anchors appear in fewer than 25% of rows, the candidate is not a table. This complements the pre-split approach rather than replacing it.
The general rule: detection scope should match content scope. Anything that creates content-scope boundaries (page columns, text boxes, explicit regions) should be applied before the detector runs, not compensated for inside the detector.