Skip to content

Commit 041dcec

Browse files
Merge pull request #387 from awslabs/feat/acarbo-pdf-table-extraction
[FEATURE] Add table extraction to AdvancedPDFReaderProvider (down-merge)
2 parents 56cbe0c + efeca52 commit 041dcec

9 files changed

Lines changed: 365 additions & 42 deletions

File tree

lexical-graph/src/graphrag_toolkit/lexical_graph/indexing/load/readers/providers/advanced_pdf_reader_provider.py

Lines changed: 92 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,85 @@ class AdvancedPDFReaderProvider(LlamaIndexReaderProviderBase, S3FileMixin):
1818
def __init__(self, config: PDFReaderConfig):
1919

2020
try:
21-
import pymupdf
21+
import pymupdf
2222
except ImportError as e:
2323
raise ImportError(
2424
"pymupdf package not found, install with 'pip install pymupdf'"
2525
) from e
2626

27-
27+
# Hold the module on the instance: the import above is local to __init__,
28+
# so read() cannot reach the bare `pymupdf` name without this.
29+
self._pymupdf = pymupdf
2830
self.config = config
31+
self.extract_tables = config.extract_tables
2932
self.metadata_fn = config.metadata_fn
30-
logger.debug("Initialized AdvancedPDFReaderProvider")
33+
logger.debug(f"Initialized AdvancedPDFReaderProvider with extract_tables={config.extract_tables}")
34+
35+
def _find_tables(self, page, page_number):
36+
"""Return the tables detected on a page, or an empty list on failure."""
37+
try:
38+
return list(page.find_tables().tables)
39+
except Exception as e:
40+
logger.warning(f"Failed to detect tables on page {page_number}: {e}")
41+
return []
42+
43+
@staticmethod
44+
def _block_in_a_table(x0, y0, x1, y1, table_bboxes, threshold=0.5):
45+
"""True if more than `threshold` of a block's area lies inside any table
46+
bbox (default: more than half), i.e. the block belongs to the table."""
47+
block_area = max(0.0, x1 - x0) * max(0.0, y1 - y0)
48+
if block_area == 0:
49+
return False
50+
for bx0, by0, bx1, by1 in table_bboxes:
51+
overlap_w = max(0.0, min(x1, bx1) - max(x0, bx0))
52+
overlap_h = max(0.0, min(y1, by1) - max(y0, by0))
53+
if overlap_w * overlap_h > threshold * block_area:
54+
return True
55+
return False
56+
57+
def _page_text(self, page, tables):
58+
"""Page text with table-region text removed.
59+
60+
get_text() returns each table's cell text inline, and the table is also
61+
appended as markdown, so without this the cell content would land in the
62+
document twice and inflate downstream extraction (which dedups only on
63+
exact-string output). Drop any text block sitting mostly inside a table's
64+
bounding box, leaving the markdown as the table's single representation.
65+
"""
66+
if not tables:
67+
return page.get_text()
68+
# Skip tables with a missing/empty bbox: a None would raise in tuple()
69+
# and a partial bbox would unpack short in _block_in_a_table.
70+
table_bboxes = [tuple(table.bbox) for table in tables if table.bbox]
71+
kept = []
72+
# "blocks" tuple layout: (x0, y0, x1, y1, text, block_no, block_type).
73+
# block_type 1 is an image block (placeholder text); images are handled
74+
# separately below, so keep only text blocks (type 0).
75+
for block in page.get_text("blocks"):
76+
x0, y0, x1, y1, block_text = block[:5]
77+
block_type = block[6] if len(block) > 6 else 0
78+
if block_type != 0:
79+
continue
80+
if not self._block_in_a_table(x0, y0, x1, y1, table_bboxes):
81+
kept.append(block_text)
82+
return "".join(kept)
83+
84+
def _append_tables(self, page_number, tables, text):
85+
"""Append each table to the text as a markdown block.
86+
87+
Returns the augmented text and the number of tables rendered. Row/column
88+
relationships are preserved via the pipe-delimited markdown that pymupdf's
89+
TableFinder produces. The count reflects tables actually written, not just
90+
detected, so table_count never overstates what a consumer can find.
91+
"""
92+
rendered = 0
93+
for tbl_index, table in enumerate(tables):
94+
try:
95+
text += f"\n[TABLE_{page_number}_{tbl_index}]\n{table.to_markdown()}"
96+
rendered += 1
97+
except Exception as e:
98+
logger.warning(f"Failed to render table {tbl_index} on page {page_number}: {e}")
99+
return text, rendered
31100

32101
def read(self, input_source) -> List[Document]:
33102
"""Read PDF with text, images, and tables."""
@@ -41,32 +110,41 @@ def read(self, input_source) -> List[Document]:
41110
try:
42111
pdf_path = processed_paths[0]
43112
logger.debug(f"Opening PDF file: {pdf_path}")
44-
doc = pymupdf.open(pdf_path)
113+
doc = self._pymupdf.open(pdf_path)
45114
documents = []
46-
115+
47116
for page_num in range(len(doc)):
48117
page = doc[page_num]
49-
text = page.get_text()
50-
118+
# 1-indexed page number used consistently across markers, log
119+
# messages, and metadata (page_num itself stays 0-based for the
120+
# doc[page_num] lookup).
121+
page_number = page_num + 1
122+
123+
tables = self._find_tables(page, page_number) if self.extract_tables else []
124+
text = self._page_text(page, tables)
125+
51126
image_list = page.get_images()
52127
for img_index, img in enumerate(image_list):
53128
try:
54129
xref = img[0]
55-
pix = pymupdf.Pixmap(doc, xref)
130+
pix = self._pymupdf.Pixmap(doc, xref)
56131
if pix.n - pix.alpha < 4:
57132
img_data = pix.tobytes("png")
58133
img_b64 = base64.b64encode(img_data).decode()
59-
text += f"\n[IMAGE_{page_num}_{img_index}: base64_data={img_b64[:100]}...]"
134+
text += f"\n[IMAGE_{page_number}_{img_index}: base64_data={img_b64[:100]}...]"
60135
pix = None
61136
except Exception as e:
62-
logger.warning(f"Failed to extract image {img_index} from page {page_num}: {e}")
63-
137+
logger.warning(f"Failed to extract image {img_index} from page {page_number}: {e}")
138+
139+
text, table_count = self._append_tables(page_number, tables, text)
140+
64141
page_doc = Document(
65142
text=text,
66143
metadata={
67-
'page_number': page_num + 1,
144+
'page_number': page_number,
68145
'source': 'advanced_pdf',
69-
'file_path': original_paths[0]
146+
'file_path': original_paths[0],
147+
'table_count': table_count
70148
}
71149
)
72150

@@ -84,4 +162,4 @@ def read(self, input_source) -> List[Document]:
84162
logger.error(f"Failed to read advanced PDF from {input_source}: {e}", exc_info=True)
85163
raise RuntimeError(f"Failed to read advanced PDF: {e}") from e
86164
finally:
87-
self._cleanup_temp_files(temp_files)
165+
self._cleanup_temp_files(temp_files)

lexical-graph/src/graphrag_toolkit/lexical_graph/indexing/load/readers/reader_provider_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
@dataclass
1111
class PDFReaderConfig(ReaderProviderConfig):
1212
return_full_document: bool = False
13+
extract_tables: bool = True # AdvancedPDFReaderProvider only: parse tables to markdown
1314
metadata_fn: Optional[Callable[[str], Dict[str, Any]]] = None
1415

1516
@dataclass
Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import builtins
5+
46
import pytest
5-
import sys
6-
7-
def test_raises_exception_if_dependencies_not_installed():
8-
# Clean up any mocked providers module from other tests
9-
if 'graphrag_toolkit.lexical_graph.indexing.load.readers.providers' in sys.modules:
10-
providers_module = sys.modules['graphrag_toolkit.lexical_graph.indexing.load.readers.providers']
11-
# Only remove if it's a Mock object (from other tests)
12-
if hasattr(providers_module, '_mock_name'):
13-
del sys.modules['graphrag_toolkit.lexical_graph.indexing.load.readers.providers']
14-
15-
from graphrag_toolkit.lexical_graph.indexing.load.readers.providers import AdvancedPDFReaderProvider
16-
from graphrag_toolkit.lexical_graph.indexing.load.readers.reader_provider_config import PDFReaderConfig
17-
18-
with pytest.raises(ImportError) as exc_info:
19-
reader = AdvancedPDFReaderProvider(PDFReaderConfig())
207

21-
assert exc_info.value.args[0] == "pymupdf package not found, install with 'pip install pymupdf'"
22-
23-
8+
from graphrag_toolkit.lexical_graph.indexing.load.readers.providers.advanced_pdf_reader_provider import (
9+
AdvancedPDFReaderProvider,
10+
)
11+
from graphrag_toolkit.lexical_graph.indexing.load.readers.reader_provider_config import (
12+
PDFReaderConfig,
13+
)
14+
15+
16+
def test_raises_exception_if_dependencies_not_installed(monkeypatch):
17+
# Force the pymupdf import to fail regardless of whether it is installed,
18+
# so the test is deterministic in any environment.
19+
real_import = builtins.__import__
2420

21+
def fake_import(name, *args, **kwargs):
22+
if name == "pymupdf":
23+
raise ImportError("No module named 'pymupdf'")
24+
return real_import(name, *args, **kwargs)
2525

26-
26+
monkeypatch.setattr(builtins, "__import__", fake_import)
27+
28+
with pytest.raises(ImportError) as exc_info:
29+
AdvancedPDFReaderProvider(PDFReaderConfig())
30+
31+
assert exc_info.value.args[0] == "pymupdf package not found, install with 'pip install pymupdf'"

0 commit comments

Comments
 (0)