DocuChat / src /multimodal_extractor.py
Prateet Mishra
Fix Railway deployment: add built frontend/dist, fix railway.toml
0e78e4d
Raw
History Blame Contribute Delete
7.03 kB
"""
Multimodal extraction from PDFs using PyMuPDF.
Extracts tables as markdown text chunks and images as OCR text chunks,
saving visual assets (table screenshots, image thumbnails) to disk
so the UI can display them in the "Why this answer?" panel.
"""
import os
import io
from pathlib import Path
from typing import List, Dict, Any
from PIL import Image
import fitz # PyMuPDF
def extract_tables_and_images(
pdf_path: str,
assets_dir: str,
source_file: str,
) -> List[Dict[str, Any]]:
"""
Extract tables and images from a PDF file.
Returns a list of dicts, each representing a multimodal chunk:
{
"text": str, # text representation for embedding
"page": int, # 0-indexed page number
"chunk_type": str, # "table" | "figure"
"asset_path": str, # relative path to saved visual asset
"source_file": str,
"file_type": "pdf",
"section": str, # e.g. "Table 1 on Page 3"
}
"""
doc = fitz.open(pdf_path)
os.makedirs(assets_dir, exist_ok=True)
chunks: List[Dict[str, Any]] = []
table_counter = 0
figure_counter = 0
for page_num in range(len(doc)):
page = doc[page_num]
# --- Extract Tables ---
try:
tables = page.find_tables()
for table in tables:
table_counter += 1
# Convert table to markdown text for embedding
markdown = _table_to_markdown(table)
if not markdown or len(markdown.strip()) < 10:
continue
# Save a screenshot of the table region as a PNG
asset_filename = f"table_{page_num}_{table_counter}.png"
asset_path = os.path.join(assets_dir, asset_filename)
_save_table_screenshot(page, table.bbox, asset_path)
chunks.append({
"text": f"[Table {table_counter}, Page {page_num + 1}]\n{markdown}",
"page": page_num,
"chunk_type": "table",
"asset_path": asset_filename,
"source_file": source_file,
"file_type": "pdf",
"section": f"Table {table_counter} on Page {page_num + 1}",
})
except Exception as e:
print(f"[WARN] Table extraction failed on page {page_num}: {e}")
# --- Extract Images ---
try:
image_list = page.get_images(full=True)
for img_idx, img_info in enumerate(image_list):
xref = img_info[0]
figure_counter += 1
# Extract the image bytes
base_image = doc.extract_image(xref)
if not base_image or not base_image.get("image"):
continue
image_bytes = base_image["image"]
image_ext = base_image.get("ext", "png")
# Skip tiny images (likely icons/bullets)
width = base_image.get("width", 0)
height = base_image.get("height", 0)
if width < 50 or height < 50:
continue
# Save the image thumbnail
asset_filename = f"figure_{page_num}_{figure_counter}.{image_ext}"
asset_path = os.path.join(assets_dir, asset_filename)
_save_image_thumbnail(image_bytes, asset_path)
# Get OCR text from the image region
ocr_text = _get_image_region_text(page, xref)
# If no OCR text, generate a descriptive placeholder
if not ocr_text or len(ocr_text.strip()) < 5:
ocr_text = f"Figure on page {page_num + 1} ({width}x{height} pixels)"
chunks.append({
"text": f"[Figure {figure_counter}, Page {page_num + 1}]\n{ocr_text}",
"page": page_num,
"chunk_type": "figure",
"asset_path": asset_filename,
"source_file": source_file,
"file_type": "pdf",
"section": f"Figure {figure_counter} on Page {page_num + 1}",
})
except Exception as e:
print(f"[WARN] Image extraction failed on page {page_num}: {e}")
doc.close()
print(f"[INFO] Extracted {table_counter} tables, {figure_counter} figures from {source_file}")
return chunks
def _table_to_markdown(table) -> str:
"""Convert a PyMuPDF table object to a markdown-formatted string."""
try:
data = table.extract()
if not data or len(data) == 0:
return ""
# Build markdown table
lines = []
for row_idx, row in enumerate(data):
cells = [str(cell).strip() if cell else "" for cell in row]
lines.append("| " + " | ".join(cells) + " |")
if row_idx == 0:
lines.append("| " + " | ".join(["---"] * len(cells)) + " |")
return "\n".join(lines)
except Exception:
return ""
def _save_table_screenshot(page, bbox, output_path: str, scale: float = 2.0):
"""Render the table bounding box region as a PNG image."""
try:
rect = fitz.Rect(bbox)
# Add some padding
rect.x0 = max(0, rect.x0 - 5)
rect.y0 = max(0, rect.y0 - 5)
rect.x1 = min(page.rect.width, rect.x1 + 5)
rect.y1 = min(page.rect.height, rect.y1 + 5)
mat = fitz.Matrix(scale, scale)
clip = rect
pix = page.get_pixmap(matrix=mat, clip=clip)
pix.save(output_path)
except Exception as e:
print(f"[WARN] Failed to save table screenshot: {e}")
def _save_image_thumbnail(image_bytes: bytes, output_path: str, max_size: int = 600):
"""Save image bytes as a thumbnail PNG."""
try:
img = Image.open(io.BytesIO(image_bytes))
img.thumbnail((max_size, max_size), Image.LANCZOS)
# Convert to RGB if needed (e.g., CMYK, P mode)
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
img.save(output_path, "PNG")
except Exception as e:
print(f"[WARN] Failed to save image thumbnail: {e}")
def _get_image_region_text(page, xref: int) -> str:
"""
Try to extract text from the region around an image.
Falls back to nearby text blocks since the image itself
may not contain selectable text.
"""
try:
# Get the bounding boxes of this image on the page
rects = page.get_image_rects(xref)
if not rects:
return ""
rect = rects[0]
# Expand the rect slightly to capture nearby captions/labels
expanded = fitz.Rect(
rect.x0 - 10,
rect.y0 - 30,
rect.x1 + 10,
rect.y1 + 40,
)
# Clip to page bounds
expanded = expanded & page.rect
text = page.get_textbox(expanded)
return text.strip() if text else ""
except Exception:
return ""