fix(tables): Prevent graph labels from merging into adjacent tables

When a PDF page has a graph directly above a table (e.g. Figure 2 above
Table II in real-estate-pricing.pdf), the heuristic table detector would
merge graph axis labels and legend items into the table, producing a
corrupted result.

Add rect hint regions: when a small cluster of cell-border rects (4-6)
fails full grid validation (e.g. only row borders, no column dividers),
extract their Y bounding box as a "hint region". The heuristic detector
then runs separately on items inside vs outside hint regions, preventing
unrelated content from being merged into tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-18 18:03:25 -08:00
co-authored by Claude Opus 4.6
parent b6828fff8c
commit 0cf80025f6
4 changed files with 170 additions and 62 deletions
+51 -35
View File
@@ -215,7 +215,7 @@ pub fn to_markdown_from_items_with_rects(
let mut rect_claimed: HashSet<usize> = HashSet::new();
// Try rectangle-based table detection first
let rect_tables = detect_tables_from_rects(&page_items, rects, page);
let (rect_tables, hint_regions) = detect_tables_from_rects(&page_items, rects, page);
for table in &rect_tables {
for &idx in &table.item_indices {
rect_claimed.insert(idx);
@@ -231,43 +231,16 @@ pub fn to_markdown_from_items_with_rects(
.push((table_y, table_md));
}
// Run heuristic detection on unclaimed items only
if rect_claimed.is_empty() {
// No rect tables — run heuristic on all items
let tables = detect_tables(&page_items, base_size, false);
for table in tables {
for &idx in &table.item_indices {
if let Some(&(global_idx, _)) = group.get(idx) {
table_items.insert(global_idx);
}
// Helper: run heuristic on a subset of items, remapping indices back to page-space
let mut run_heuristic =
|subset_items: &[TextItem], index_map: &[usize], min_items: usize| {
if subset_items.len() < min_items {
return;
}
let table_y = table.rows.first().copied().unwrap_or(0.0);
let table_md = table_to_markdown(&table);
page_tables
.entry(page)
.or_default()
.push((table_y, table_md));
}
} else {
// Rect tables found — run heuristic on unclaimed items
let unclaimed_items: Vec<TextItem> = page_items
.iter()
.enumerate()
.filter(|(idx, _)| !rect_claimed.contains(idx))
.map(|(_, item)| item.clone())
.collect();
if unclaimed_items.len() >= 6 {
let tables = detect_tables(&unclaimed_items, base_size, false);
let tables = detect_tables(subset_items, base_size, false);
for table in tables {
// Remap indices from unclaimed-space back to page-space
let unclaimed_map: Vec<usize> = page_items
.iter()
.enumerate()
.filter(|(idx, _)| !rect_claimed.contains(idx))
.map(|(idx, _)| idx)
.collect();
for &idx in &table.item_indices {
if let Some(&page_idx) = unclaimed_map.get(idx) {
if let Some(&page_idx) = index_map.get(idx) {
if let Some(&(global_idx, _)) = group.get(page_idx) {
table_items.insert(global_idx);
}
@@ -280,7 +253,50 @@ pub fn to_markdown_from_items_with_rects(
.or_default()
.push((table_y, table_md));
}
};
// Run heuristic detection on unclaimed items
if rect_claimed.is_empty() && hint_regions.is_empty() {
// No rect tables or hints — run heuristic on all items
let identity_map: Vec<usize> = (0..page_items.len()).collect();
run_heuristic(&page_items, &identity_map, 6);
} else if rect_claimed.is_empty() && !hint_regions.is_empty() {
// No rect tables but hint regions exist — run heuristic separately
// on items inside each hint region and on items outside all hints.
// This prevents graph labels from being merged into nearby tables.
let padding = 15.0; // include header lines slightly above rects
for hint in &hint_regions {
let (inside_items, inside_map): (Vec<TextItem>, Vec<usize>) = page_items
.iter()
.enumerate()
.filter(|(_, item)| {
item.y >= hint.y_bottom - padding && item.y <= hint.y_top + padding
})
.map(|(idx, item)| (item.clone(), idx))
.unzip();
run_heuristic(&inside_items, &inside_map, 6);
// Mark hint-region items as claimed so they aren't re-processed
for &page_idx in &inside_map {
rect_claimed.insert(page_idx);
}
}
// Run heuristic on remaining items outside all hint regions
let (outside_items, outside_map): (Vec<TextItem>, Vec<usize>) = page_items
.iter()
.enumerate()
.filter(|(idx, _)| !rect_claimed.contains(idx))
.map(|(idx, item)| (item.clone(), idx))
.unzip();
run_heuristic(&outside_items, &outside_map, 6);
} else {
// Rect tables found — run heuristic on unclaimed items
let (unclaimed_items, unclaimed_map): (Vec<TextItem>, Vec<usize>) = page_items
.iter()
.enumerate()
.filter(|(idx, _)| !rect_claimed.contains(idx))
.map(|(idx, item)| (item.clone(), idx))
.unzip();
run_heuristic(&unclaimed_items, &unclaimed_map, 6);
}
}
+104 -16
View File
@@ -97,13 +97,36 @@ pub(crate) fn cluster_rects(
result.into_iter().map(|(_, g)| g).collect()
}
/// A bounding box hint from cell-border rects that failed full grid validation.
///
/// When a rect cluster contains cell-sized borders but they don't form a valid
/// grid (e.g. only horizontal row borders with no vertical column dividers),
/// the bounding box of those cell-sized rects can still be used to scope
/// heuristic table detection, preventing unrelated items (graph labels, etc.)
/// from being merged into the table.
#[derive(Debug, Clone)]
pub struct RectHintRegion {
/// Y coordinate of the top edge (highest value in PDF space)
pub y_top: f32,
/// Y coordinate of the bottom edge (lowest value in PDF space)
pub y_bottom: f32,
}
/// Detect tables from explicit rectangle (`re`) operators in the PDF.
///
/// Many PDFs draw cell borders using `re` (rectangle) operators. Table pages
/// typically have 100-200+ rects while non-table pages have < 30. This function
/// clusters spatially connected rectangles into groups, then identifies grids of
/// cell-sized rectangles within each cluster and assigns text items to cells.
pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32) -> Vec<Table> {
///
/// Also returns hint regions: bounding boxes of cell-sized rects from clusters
/// that failed full grid validation. These can be used to scope heuristic
/// detection and prevent unrelated items from being merged into tables.
pub fn detect_tables_from_rects(
items: &[TextItem],
rects: &[PdfRect],
page: u32,
) -> (Vec<Table>, Vec<RectHintRegion>) {
// Filter rects on this page; normalize negative widths/heights; skip tiny rects.
let mut page_rects: Vec<(f32, f32, f32, f32)> = Vec::new(); // (x, y, w, h) normalized
for r in rects {
@@ -133,25 +156,90 @@ pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32
rects.iter().filter(|r| r.page == page).count(),
);
// Need a reasonable number of cell rects to form a table
if page_rects.len() < 6 {
return vec![];
}
// Cluster spatially connected rects into groups
let clusters = cluster_rects(&page_rects, 3.0, 6);
debug!("page {}: {} clusters with >= 6 rects", page, clusters.len());
let mut tables = Vec::new();
for cluster_indices in &clusters {
let group_rects: Vec<(f32, f32, f32, f32)> =
cluster_indices.iter().map(|&i| page_rects[i]).collect();
if let Some(table) = detect_table_from_rect_group(items, &group_rects, page) {
tables.push(table);
let mut hint_regions = Vec::new();
// Full grid detection requires ≥ 6 rects
if page_rects.len() >= 6 {
let clusters = cluster_rects(&page_rects, 3.0, 6);
debug!("page {}: {} clusters with >= 6 rects", page, clusters.len());
for cluster_indices in &clusters {
let group_rects: Vec<(f32, f32, f32, f32)> =
cluster_indices.iter().map(|&i| page_rects[i]).collect();
if let Some(table) = detect_table_from_rect_group(items, &group_rects, page) {
tables.push(table);
}
}
}
tables
// On rect-sparse pages (≤ 6 rects), a few cell-border rects may define the
// table region even though they can't form a full grid (e.g. only horizontal
// row borders, no column dividers). Extract a hint region so the heuristic
// detector can be scoped to just that area, preventing nearby graph labels
// or other content from being merged into the table.
if tables.is_empty() && page_rects.len() >= 4 && page_rects.len() <= 6 {
let clusters = cluster_rects(&page_rects, 3.0, 4);
for cluster_indices in &clusters {
let group_rects: Vec<(f32, f32, f32, f32)> =
cluster_indices.iter().map(|&i| page_rects[i]).collect();
if let Some(hint) = extract_hint_region(&group_rects) {
debug!(
"page {}: hint region y={:.1}..{:.1}",
page, hint.y_bottom, hint.y_top
);
hint_regions.push(hint);
}
}
}
(tables, hint_regions)
}
/// Extract a hint region from a rect cluster that failed grid validation.
///
/// Only produces hints from small clusters (≤ 8 rects) where a few cell-border
/// rects define a table's row boundaries. Large clusters (form-style decorative
/// rects) are not suitable for hint regions since they typically span the whole page.
///
/// Filters out oversized "bounding box" rects (height > 4× the median height),
/// then computes the Y bounding box of the remaining cell-sized rects.
fn extract_hint_region(group_rects: &[(f32, f32, f32, f32)]) -> Option<RectHintRegion> {
// Only produce hints from small clusters — large clusters that fail grid
// validation are likely form-style decorative rects, not table cell borders.
if group_rects.len() < 2 || group_rects.len() > 8 {
return None;
}
// Compute median height to identify cell-sized rects
let mut heights: Vec<f32> = group_rects.iter().map(|&(_, _, _, h)| h).collect();
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_h = heights[heights.len() / 2];
// Keep only cell-sized rects (height ≤ 4× median)
let cell_rects: Vec<&(f32, f32, f32, f32)> = group_rects
.iter()
.filter(|(_, _, _, h)| *h <= median_h * 4.0)
.collect();
if cell_rects.len() < 2 {
return None;
}
// Compute Y bounding box of cell-sized rects
let y_bottom = cell_rects.iter().map(|(_, y, _, _)| *y).reduce(f32::min)?;
let y_top = cell_rects
.iter()
.map(|(_, y, _, h)| *y + *h)
.reduce(f32::max)?;
// The region must have meaningful height but not span an unreasonable area
let region_height = y_top - y_bottom;
if !(10.0..=300.0).contains(&region_height) {
return None;
}
Some(RectHintRegion { y_top, y_bottom })
}
/// Detect a single table from a cluster of spatially connected rects.
+1 -1
View File
@@ -9,7 +9,7 @@ mod format;
mod grid;
pub use detect_heuristic::detect_tables;
pub use detect_rects::detect_tables_from_rects;
pub use detect_rects::{detect_tables_from_rects, RectHintRegion};
pub use format::table_to_markdown;
/// Detection mode controls thresholds for table validation.
+14 -10
View File
@@ -28,22 +28,26 @@ R E V I E W 8 5
-800
| 1982 | 1986 | | 1994 | 1998 | 2002 | 2006 |
| --------------------------------------------- | --------- | ----------- | --------------------------------------------- | ---------- | ---------- | ------ |
| | Apartment | Industrial | | Office-CBD | | Retail |
| Table II: Correlationsofspreadsbypropertytype | | Multifamily | Correlation of Cap Rate Spreads Over Treasury | Industrial | CBD Office | |
| Industrial | | 0.937 | | | | |
| CBDOffice | | 0.924 | | | | |
| Retail | | 0.922 | | 0.969 | 0.964 | |
| 1982 | 1986 | 1990 | 1998 | 2006 |
| ---- | --------- | ---------- | ---------- | ------ |
| | Apartment | Industrial | Office-CBD | Retail |
1982 1986 1990 1994 1998 2002 2006
more about investing in tax losses than real estate cash streams. When tax laws dramatically changed in 1986, cap rate spreads rose, though they generally remained negative due to the availability of excess leverage through 1990 and pro- jections of strong cash flow growth, in spite of weak fundamentals. Throughout the first two-thirds of the 1990s, spreads substantially widened as capital abandoned real estate. Spreads fur- ther widened in the latter part of the 1990s, as investors scorned cash flow dur- ing the tech bubble and treasury rates drifted downward. As the tech bubble
**Table II: Correlationsofspreadsbypropertytype**
**Correlation of Cap Rate Spreads Over Treasury** **Multifamily Industrial CBD Office**
| | Multifamily | Industrial | CBD Office |
| ---------- | ----------- | ---------- | ---------- |
| Industrial | 0.937 | | |
| CBDOffice | 0.924 | | |
| Retail | 0.922 | 0.969 | 0.964 |
more about investing in tax losses than burst, cap rates spreads steadily com- real estate cash streams. When tax laws pressed, recently falling to approximately dramatically changed in 1986, cap rate zero. And if NOI cap rate spreads are spreads rose, though they generally roughly zero, cash flow cap rate spreads remained negative due to the availability (after reserves for tenant improvements, of excess leverage through 1990 and pro-leasing commissions, and capital expendi- jections of strong cash flow growth, in tures) are well below zero. spite of weak fundamentals. This compression of cap rates and cap Throughout the first two-thirds of the rate spreads over the past five years has 1990s, spreads substantially widened as generated enormous wealth for real estate capital abandoned real estate. Spreads fur-owners. In fact, the combination of cheap ther widened in the latter part of the debt and cap rate compression covered a 1990s, as investors scorned cash flow dur-multitude of property underwriting ing the tech bubble and treasury rates errors made during the past five years, as drifted downward. As the tech bubble neither cap rate compression nor narrow-
8 6 Z E L L / L U R I E R E A L E S T A T E C E N T E R
burst, cap rates spreads steadily com- pressed, recently falling to approximately zero. And if NOI cap rate spreads are roughly zero, cash flow cap rate spreads (after reserves for tenant improvements, leasing commissions, and capital expendi- tures) are well below zero. This compression of cap rates and cap rate spreads over the past five years has generated enormous wealth for real estate owners. In fact, the combination of cheap debt and cap rate compression covered a multitude of property underwriting errors made during the past five years, as neither cap rate compression nor narrow-
ingdebtspreadswerepartoforiginalpro formamodels.Thiscapratespreadcom- pressionoffsetweakcashflowsinapost- recessionary economy from 2002 to 2005, while continued compression, combined with improved cash flows, pushed property values skyward in 2006 throughmid-2007. Cap rate compression reduced the importance of the ability to add value. After all, if all you had to do to make moneywastoleveragetothehiltwhilecap ratesfell,whytakeontheextraworkand riskofattemptingtoaddvalue?Stateddif- ferently: Why print money if it is laying everywhereonthestreets? In Tables III and IV, we demonstrate thepowerofcapratecompressionviavery simple pro forma cash flow analyses that assume Year 1 NOI of $100; a going-in cap rate of 9 percent; an LTV of 70 per- cent; and an interest rate of 7 percent. Withineachfigure,wedisplaytwoscenar- ios, which vary based on NOI growth assumptions.ScenarioIassumesthatNOI growsby3percentperyear,whileScenario IIassumesavalue-addNOIgrowthof20 percentbetweenyearstwoandthree. The only other difference between TablesIIIandIVisinresidualcaprates, which are assumed to be 6 percent and 9 percent, respectively. Based on these assumptions, we calculate the equity IRRs. It is clear that cap rate compres- sion is a significant factor in driving
returns. That is, cap rate compression from 9 percent to 6 percent increased IRR on leveraged stabilized properties by 250 percent, to a staggering 57 per- cent. Who needs to take on value add riskatthisreturnforstabilizedassets? Intheearly1980s,moneywasmadein real estate by mastering the creation and syndication of tax gimmicks. In the late 1980s, one made money by mastering bank and S&L connections to over-lever- age.Intheearly1990s,onemademoneyin realestatebyhavingaccesstoequity—the morethebetter.Duringthelate1990s,one made money from real estate by realizing large spreads between cap rates and debt costs.And,overthepastfiveyears,theway to make money in real estate was to own realestateonahighlyleveragedbasisascap ratesplunged. Theclassicassetpricingmodelisthe capital asset pricing model (CAPM). CAPM is a simple, yet elegant, model that relates asset pricing to the risk-free rate(F),theabilityofanassettoreduce portfolio variance (B), and the expected rate of return on the market bundle of investableassets(M).CAPMisfarfrom perfect,butprovidesacrudebenchmark for asset pricing, around which discrep- ancies and novelties arise. Specifically, CAPM states that an assets price is set suchthattheexpectedreturnforanasset