Spaces:
Sleeping
Sleeping
File size: 9,885 Bytes
454f118 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 | """
src/ingestion/table_extractor.py
=================================
Table extraction from PDFs using pdfplumber.
Why pdfplumber for tables?
- It uses a different algorithm than PyMuPDF — better at detecting cell boundaries
- Handles both bordered AND borderless tables (common in BSE annual reports)
- Returns tables as Python lists of lists → easy to convert to Markdown
What this module does:
- For each page in a PDF, detect and extract all tables
- Convert each table to Markdown format (pipe syntax)
- Clean up merged cells, None values, and whitespace
- Return TableChunk objects with page metadata
Markdown tables are the best format to pass to LLMs because:
- LLMs natively understand | column | syntax
- Preserves row/column structure without confusion
- Much better than CSV (no header ambiguity) or raw text
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import pdfplumber
from src.utils.logger import logger
# ------------------------------------------------------------------ #
# Data Classes
# ------------------------------------------------------------------ #
@dataclass
class ExtractedTable:
"""A single extracted table from a PDF page."""
markdown: str # Table in Markdown pipe format
page_number: int
table_index: int # Table number on this page (0-indexed)
row_count: int
col_count: int
bbox: Optional[tuple] = None # Bounding box on the page
has_header: bool = True
@dataclass
class PageTables:
"""All tables extracted from one page."""
page_number: int
tables: list[ExtractedTable] = field(default_factory=list)
# ------------------------------------------------------------------ #
# Table Extractor
# ------------------------------------------------------------------ #
class TableExtractor:
"""
Extracts tables from PDF files using pdfplumber and converts to Markdown.
Table extraction settings are tuned for BSE annual report layouts:
- snap_tolerance: handles slightly misaligned cell borders (common in BSE PDFs)
- join_tolerance: merges text fragments within the same cell
- edge_min_length: minimum line length to be considered a table border
"""
# pdfplumber table settings tuned for financial reports
TABLE_SETTINGS = {
"vertical_strategy": "lines_strict", # use actual drawn lines (not text-based)
"horizontal_strategy": "lines_strict",
"snap_tolerance": 5, # pixels — snap nearby lines together
"join_tolerance": 5, # pixels — join nearby text in same cell
"edge_min_length": 25, # ignore tiny lines (< 25px)
"min_words_vertical": 2, # at least 2 words to form vertical boundary
"min_words_horizontal": 1,
"text_tolerance": 3,
"text_x_tolerance": 3,
"text_y_tolerance": 3,
"intersection_tolerance": 3,
"intersection_x_tolerance": 3,
"intersection_y_tolerance": 3,
}
# Fallback settings if strict line detection finds no tables
TABLE_SETTINGS_FALLBACK = {
"vertical_strategy": "text", # fall back to text-based column detection
"horizontal_strategy": "text",
"snap_tolerance": 8,
"join_tolerance": 8,
"edge_min_length": 10,
}
def extract_all_tables(
self,
pdf_path: Path,
pages_to_check: Optional[list[int]] = None,
) -> dict[int, PageTables]:
"""
Extract all tables from a PDF file.
Args:
pdf_path: Path to the PDF
pages_to_check: Optional list of 1-indexed page numbers to process.
If None, processes all pages.
Returns:
Dict mapping page_number → PageTables
"""
logger.info(f"Extracting tables from: {pdf_path.name}")
results: dict[int, PageTables] = {}
total_tables = 0
with pdfplumber.open(str(pdf_path)) as pdf:
pages = pdf.pages
if pages_to_check:
# Convert 1-indexed to 0-indexed
pages = [pdf.pages[i - 1] for i in pages_to_check if 0 < i <= len(pdf.pages)]
for page in pages:
page_num = page.page_number # pdfplumber is 1-indexed
page_tables = self._extract_page_tables(page, page_num)
if page_tables.tables:
results[page_num] = page_tables
total_tables += len(page_tables.tables)
logger.info(f"✓ Extracted {total_tables} tables from {pdf_path.name}")
return results
def _extract_page_tables(self, page: pdfplumber.page.Page, page_num: int) -> PageTables:
"""Extract all tables from a single page."""
page_tables = PageTables(page_number=page_num)
# Try strict line-based detection first
tables = page.extract_tables(self.TABLE_SETTINGS)
# If nothing found, try text-based fallback
if not tables:
tables = page.extract_tables(self.TABLE_SETTINGS_FALLBACK)
if not tables:
return page_tables
# Get bounding boxes for tables on this page
table_bboxes = []
try:
finder = page.find_tables(self.TABLE_SETTINGS)
table_bboxes = [t.bbox for t in finder]
except Exception:
pass
for idx, raw_table in enumerate(tables):
if not raw_table or len(raw_table) < 2:
continue # Skip single-row "tables" (not real tables)
cleaned = self._clean_table(raw_table)
if not cleaned:
continue
markdown = self._table_to_markdown(cleaned)
if not markdown.strip():
continue
bbox = table_bboxes[idx] if idx < len(table_bboxes) else None
table = ExtractedTable(
markdown=markdown,
page_number=page_num,
table_index=idx,
row_count=len(cleaned),
col_count=max(len(row) for row in cleaned),
bbox=bbox,
has_header=True,
)
page_tables.tables.append(table)
return page_tables
# ---------------------------------------------------------------- #
# Table Cleaning
# ---------------------------------------------------------------- #
def _clean_table(self, raw_table: list[list]) -> list[list[str]]:
"""
Clean raw table data from pdfplumber.
Handles:
- None values (merged cells) → forward-fill or empty string
- Whitespace normalization
- Empty row removal
- Duplicate header rows
"""
if not raw_table:
return []
cleaned = []
for row in raw_table:
# Replace None with empty string
clean_row = []
for cell in row:
if cell is None:
clean_row.append("")
else:
# Normalize whitespace, remove newlines within cells
cell_str = str(cell).strip()
cell_str = re.sub(r'\s+', ' ', cell_str)
cell_str = cell_str.replace('\n', ' ').replace('\r', '')
clean_row.append(cell_str)
# Skip completely empty rows
if all(c == "" for c in clean_row):
continue
cleaned.append(clean_row)
# Normalize column count (handle ragged tables)
if cleaned:
max_cols = max(len(row) for row in cleaned)
cleaned = [row + [""] * (max_cols - len(row)) for row in cleaned]
return cleaned
# ---------------------------------------------------------------- #
# Markdown Conversion
# ---------------------------------------------------------------- #
def _table_to_markdown(self, table: list[list[str]]) -> str:
"""
Convert a cleaned table (list of lists) to Markdown pipe format.
Example output:
| Revenue | FY2024 | FY2023 |
|---|---|---|
| Net Revenue | ₹240,893 Cr | ₹225,458 Cr |
| Other Income | ₹3,212 Cr | ₹2,890 Cr |
"""
if not table:
return ""
lines = []
# Header row
header = table[0]
lines.append("| " + " | ".join(self._escape_cell(c) for c in header) + " |")
# Separator row (standard Markdown)
lines.append("|" + "|".join("---" for _ in header) + "|")
# Data rows
for row in table[1:]:
lines.append("| " + " | ".join(self._escape_cell(c) for c in row) + " |")
return "\n".join(lines)
def _escape_cell(self, text: str) -> str:
"""Escape pipe characters inside cells so they don't break Markdown table."""
return text.replace("|", "\\|").strip()
# ---------------------------------------------------------------- #
# Utility: Get table bounding boxes (for PDF parser coordination)
# ---------------------------------------------------------------- #
def get_table_bboxes(self, pdf_path: Path, page_num: int) -> list[tuple]:
"""
Return bounding boxes of all tables on a given page.
Used by the PDF parser to avoid double-extracting table text as paragraphs.
"""
bboxes = []
with pdfplumber.open(str(pdf_path)) as pdf:
if page_num > len(pdf.pages):
return []
page = pdf.pages[page_num - 1]
try:
tables = page.find_tables(self.TABLE_SETTINGS)
bboxes = [t.bbox for t in tables]
except Exception:
pass
return bboxes
|