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