unsloth/studio/backend/core/rag/parsers/pdf.py
Daniel Han d1348cac3f Studio: tighten RAG code comments
Shorten and condense comments across the RAG backend, frontend, and
tests for readability. Comment text only; no code, strings, identifiers,
or logic changed. License headers and lint/type pragmas are preserved.
2026-05-31 08:31:08 +00:00

162 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""PDF → Markdown via pymupdf4llm; pypdf fallback for malformed files."""
from __future__ import annotations
import logging
import re
from pathlib import Path
from . import ParsedImage, ParsedPage, ParseResult
logger = logging.getLogger(__name__)
# pymupdf4llm wraps OCR'd vector-graphics text with these markers even when
# `ignore_images=True`. Strip the whole block — the VLM captioner describes the
# figure, and the marker text just pollutes the chunked body / citations.
_PICTURE_TEXT_BLOCK_RE = re.compile(
r"-{3,}\s*Start of picture text\s*-{3,}.*?-{3,}\s*End of picture text\s*-{3,}",
re.DOTALL | re.IGNORECASE,
)
def _strip_picture_text_markers(md: str) -> str:
return _PICTURE_TEXT_BLOCK_RE.sub("", md)
def _extract_with_pymupdf(path: Path, want_images: bool) -> ParseResult:
import pymupdf
import pymupdf4llm
doc = pymupdf.open(str(path))
try:
pages: list[ParsedPage] = []
for page_index in range(len(doc)):
try:
md = pymupdf4llm.to_markdown(
doc,
pages = [page_index],
write_images = False,
ignore_images = True,
show_progress = False,
)
except Exception:
# pymupdf4llm can choke on a single page; fall back to plain text.
md = doc[page_index].get_text("text") or ""
md = _strip_picture_text_markers(md).strip()
if md:
pages.append(ParsedPage(text = md, page_number = page_index + 1))
images: list[ParsedImage] = []
if want_images:
images = _extract_images_pymupdf(doc, pages)
return ParseResult(pages = pages, images = images)
finally:
doc.close()
# Pages smaller than this (in PDF points) are ignored as figure regions —
# bigger than a typical icon/glyph, smaller than a banner.
_MIN_FIGURE_PT = 60
# 2× scale renders at 144 dpi (PDF default is 72 dpi). Enough resolution for
# the captioner to read axis labels, arrow text, and inset photos.
_RENDER_SCALE = 2.0
# Expand the union bbox a few points so caption baselines / borders survive.
_FIGURE_MARGIN_PT = 8.0
def _extract_images_pymupdf(doc, pages: list[ParsedPage]) -> list[ParsedImage]:
"""Render each page's figure region (vector drawings + raster sub-images)
as a single PNG. Vector schematics like Figure 1 (no embedded raster)
are visible to the captioner only via rendering — ``page.get_images``
misses them entirely. We union all non-text geometry on a page into
one bbox; for academic papers this typically maps 1:1 to "the figure
on this page".
"""
import pymupdf
captions_by_page: dict[int, str] = {
p.page_number: p.text for p in pages if p.page_number
}
out: list[ParsedImage] = []
for page_index in range(len(doc)):
page = doc[page_index]
page_number = page_index + 1
try:
rects: list[pymupdf.Rect] = []
for drawing in page.get_drawings() or []:
rect = drawing.get("rect")
if rect is not None:
rects.append(pymupdf.Rect(rect))
for info in page.get_image_info(xrefs = True) or []:
bbox = info.get("bbox")
if bbox is not None:
rects.append(pymupdf.Rect(bbox))
except Exception:
continue
if not rects:
continue
union = rects[0]
for r in rects[1:]:
union |= r
if union.width < _MIN_FIGURE_PT or union.height < _MIN_FIGURE_PT:
continue
# Expand and clip to page rect so we don't render past page edges.
union = (
pymupdf.Rect(
union.x0 - _FIGURE_MARGIN_PT,
union.y0 - _FIGURE_MARGIN_PT,
union.x1 + _FIGURE_MARGIN_PT,
union.y1 + _FIGURE_MARGIN_PT,
)
& page.rect
)
try:
matrix = pymupdf.Matrix(_RENDER_SCALE, _RENDER_SCALE)
pix = page.get_pixmap(clip = union, matrix = matrix, alpha = False)
png_bytes = pix.tobytes("png")
except Exception:
continue
if not png_bytes:
continue
caption = (captions_by_page.get(page_number, "") or "")[:1500]
out.append(
ParsedImage(
image_bytes = png_bytes,
mime_type = "image/png",
page_number = page_number,
nearest_caption = caption,
)
)
return out
def _extract_with_pypdf_fallback(path: Path) -> ParseResult:
from pypdf import PdfReader
reader = PdfReader(str(path))
pages: list[ParsedPage] = []
for index, page in enumerate(reader.pages):
try:
text = page.extract_text() or ""
except Exception:
text = ""
text = text.strip()
if text:
pages.append(ParsedPage(text = text, page_number = index + 1))
return ParseResult(pages = pages, images = [])
def extract(path: Path, *, want_images: bool = False) -> ParseResult:
try:
return _extract_with_pymupdf(path, want_images)
except Exception as exc:
logger.warning(
"pymupdf failed for %s (%s: %s); falling back to pypdf",
path,
type(exc).__name__,
exc,
)
return _extract_with_pypdf_fallback(path)