sketchnote / pipeline /pdf_parser.py
Janesh's picture
Fix: storyboard beats + per-beat sync + raw Modal logging + pdf text cleaning
c491253 verified
Raw
History Blame Contribute Delete
14.2 kB
"""PDF ingestion -> list of chapters.
Fast path (default): PyMuPDF. Prefer the embedded TOC (PDF bookmarks). If no
TOC, fall back to a font-size/regex heading heuristic, then to N equal page
buckets. Hard path (scanned/complex PDFs): render pages to images and route to
the Nemotron Parse Modal function (see modal_app.parse_pages); wired in
pdf_parser.hard_path_chapters and gated by _needs_hard_path.
Chapter = {"title": str, "text": str, "page_start": int, "page_end": int}
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from typing import Optional, TypedDict
import fitz # PyMuPDF
from pipeline.config import CACHE_DIR
log = logging.getLogger("sketchnote.pdf")
# Truncate each chapter body to keep summarization prompts in budget.
MAX_CHARS_PER_CHAPTER = 6000
# Minimum extractable characters/page to consider the text layer "usable".
MIN_TEXT_CHARS_PER_PAGE = 40
CHAPTER_RE = re.compile(r"^\s*(chapter|section|part|unit|lesson)\s+[\dIVXLC]+", re.I)
# Front/back-matter TOC entries that are not real content chapters.
_TOC_SKIP_RE = re.compile(
r"\b(table of contents|contents|index|copyright|colophon|title page|"
r"dedication|acknowledg|bibliography|references|glossary)\b", re.I)
# Leading numbering to strip from titles: "Chapter 3:", "Section II.", "3.1.2 ".
_TITLE_NUM_RE = re.compile(
r"^\s*(chapter|section|part|unit|lesson)\s+[\dIVXLC]+\s*[:.\-]?\s*", re.I)
_TITLE_SEC_RE = re.compile(r"^\s*\d+(?:\.\d+)*\s*[:.\-]?\s+")
class Chapter(TypedDict):
title: str
text: str
page_start: int
page_end: int
def extract_chapters(
pdf_path: str,
max_chapters: Optional[int] = None,
page_range: Optional[tuple[int, int]] = None,
) -> list[Chapter]:
"""Extract chapters from a PDF. See module docstring for routing logic."""
doc = fitz.open(pdf_path)
try:
n_pages = doc.page_count
lo, hi = _resolve_range(page_range, n_pages)
if _needs_hard_path(doc, lo, hi):
log.info("PDF lacks usable text layer/TOC -> trying Nemotron hard path")
chapters = hard_path_chapters(doc, lo, hi)
if chapters:
return _finalize(chapters, max_chapters)
log.warning("Hard path unavailable/empty -> falling back to bucket split")
chapters = _from_toc(doc, lo, hi)
if not chapters:
chapters = _from_headings(doc, lo, hi)
if not chapters:
chapters = _equal_buckets(doc, lo, hi, max_chapters or 3)
return _finalize(chapters, max_chapters)
finally:
doc.close()
def _resolve_range(page_range, n_pages) -> tuple[int, int]:
if not page_range:
return 0, n_pages - 1
lo, hi = page_range
lo = max(0, lo)
hi = min(n_pages - 1, hi)
if hi < lo:
lo, hi = 0, n_pages - 1
return lo, hi
def _page_text(doc, i: int) -> str:
return doc[i].get_text("text")
def _needs_hard_path(doc, lo: int, hi: int) -> bool:
"""True when the text layer is too sparse to trust (likely scanned/image)."""
sample = range(lo, min(hi, lo + 4) + 1)
chars = sum(len(_page_text(doc, i)) for i in sample)
pages = max(1, len(list(sample)))
return (chars / pages) < MIN_TEXT_CHARS_PER_PAGE
def _from_toc(doc, lo: int, hi: int) -> list[Chapter]:
"""Build chapters from PDF bookmarks.
Picks the SHALLOWEST TOC level that has >=2 real content entries within the
page range (front/back matter skipped). This avoids both over-splitting into
subsections (e.g. "3.1.1 Compute") and the degenerate single-entry case that
used to dump every bookmark as its own chapter.
"""
toc = doc.get_toc(simple=True) # [[level, title, page1based], ...]
if not toc:
log.info("_from_toc: no TOC in PDF")
return []
entries = [(t[0], t[1].strip(), t[2] - 1) for t in toc
if t[2] - 1 >= 0 and lo <= t[2] - 1 <= hi
and not _TOC_SKIP_RE.search(t[1] or "")]
log.info("_from_toc: %d raw TOC entries in range, %d after skip filter",
sum(1 for t in toc if lo <= t[2] - 1 <= hi), len(entries))
if not entries:
return []
levels = sorted({lv for lv, _, _ in entries})
chosen = next((lv for lv in levels
if sum(1 for e in entries if e[0] == lv) >= 2), levels[0])
tops = [(title, start) for lv, title, start in entries if lv == chosen]
log.info("_from_toc: using level %d -> %d chapters: %s",
chosen, len(tops), [t for t, _ in tops])
if not tops:
return []
chapters: list[Chapter] = []
for idx, (title, start) in enumerate(tops):
if idx == 0:
start = lo # first chapter absorbs any intro text before its bookmark
end = (tops[idx + 1][1] - 1) if idx + 1 < len(tops) else hi
end = max(start, min(end, hi))
text = "".join(_page_text(doc, p) for p in range(start, end + 1))
chapters.append(Chapter(title=title or f"Chapter {idx + 1}",
text=text, page_start=start, page_end=end))
return chapters
def _from_headings(doc, lo: int, hi: int) -> list[Chapter]:
"""Detect headings by large font size or 'Chapter N' style regex."""
starts: list[tuple[int, str]] = []
for p in range(lo, hi + 1):
title = _detect_heading(doc[p])
if title:
starts.append((p, title))
if len(starts) < 2:
return []
chapters: list[Chapter] = []
for idx, (start, title) in enumerate(starts):
end = (starts[idx + 1][0] - 1) if idx + 1 < len(starts) else hi
end = max(start, min(end, hi))
text = "".join(_page_text(doc, p) for p in range(start, end + 1))
chapters.append(Chapter(title=title, text=text,
page_start=start, page_end=end))
return chapters
def _detect_heading(page) -> Optional[str]:
"""Return a heading string for a page, or None."""
data = page.get_text("dict")
sizes: list[float] = []
for block in data.get("blocks", []):
for line in block.get("lines", []):
for span in line.get("spans", []):
sizes.append(span.get("size", 0))
if not sizes:
return None
big = max(sizes)
for block in data.get("blocks", []):
for line in block.get("lines", []):
txt = "".join(s.get("text", "") for s in line.get("spans", [])).strip()
if not txt:
continue
line_size = max((s.get("size", 0) for s in line.get("spans", [])), default=0)
if CHAPTER_RE.match(txt) or (line_size >= big - 0.1 and big >= 16 and len(txt) < 90):
return txt
return None
def _equal_buckets(doc, lo: int, hi: int, n: int) -> list[Chapter]:
"""Final fallback: split the page range into N roughly equal buckets."""
log.info("_equal_buckets: pages %d-%d into %d buckets (last resort)", lo, hi, n)
n = max(1, min(n, hi - lo + 1))
total = hi - lo + 1
size = max(1, total // n)
chapters: list[Chapter] = []
start = lo
idx = 0
while start <= hi:
end = min(hi, start + size - 1)
if idx == n - 1: # last bucket absorbs remainder
end = hi
text = "".join(_page_text(doc, p) for p in range(start, end + 1))
chapters.append(Chapter(title=f"Section {idx + 1}", text=text,
page_start=start, page_end=end))
start = end + 1
idx += 1
if idx >= n:
break
return chapters
def hard_path_chapters(doc, lo: int, hi: int) -> list[Chapter]:
"""Render pages to PNG bytes and reconstruct chapters via Nemotron Parse.
Returns [] if the Modal parse function is unavailable so callers can fall
back to the heuristic paths. Page rendering uses PyMuPDF pixmaps.
"""
try:
from pipeline import llm
except Exception:
return []
cached = _parse_cache_get(doc, lo, hi)
if cached is not None:
log.info("Using cached Nemotron parse for pages %d-%d", lo, hi)
return _chapters_from_parsed(cached, lo)
images: list[bytes] = []
for p in range(lo, hi + 1):
pix = doc[p].get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x for legibility
images.append(pix.tobytes("png"))
try:
parsed = llm.parse_pages(images)
except Exception as exc: # Modal not deployed / network / etc.
log.warning("Nemotron parse_pages failed: %s", type(exc).__name__)
return []
_parse_cache_put(doc, lo, hi, parsed)
return _chapters_from_parsed(parsed, lo)
def _parse_cache_key(doc, lo: int, hi: int) -> str:
"""Stable key from file bytes + page range (so re-runs reuse parse output)."""
h = hashlib.sha256()
try:
with open(doc.name, "rb") as fh:
h.update(fh.read())
except Exception:
h.update(str(getattr(doc, "name", "")).encode())
h.update(f"|{lo}|{hi}".encode())
return h.hexdigest()[:24]
def _parse_cache_get(doc, lo: int, hi: int):
path = CACHE_DIR / f"parse_{_parse_cache_key(doc, lo, hi)}.json"
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return None
def _parse_cache_put(doc, lo: int, hi: int, parsed) -> None:
path = CACHE_DIR / f"parse_{_parse_cache_key(doc, lo, hi)}.json"
try:
path.write_text(json.dumps(parsed), encoding="utf-8")
except Exception as exc: # noqa: BLE001
log.warning("Could not write parse cache: %s", type(exc).__name__)
def _chapters_from_parsed(parsed: list[dict], lo: int) -> list[Chapter]:
"""Turn Nemotron Parse page objects into chapters using title/section classes."""
chapters: list[Chapter] = []
cur: Optional[Chapter] = None
for offset, page in enumerate(parsed):
page_no = lo + offset
objs = page.get("objects", []) if isinstance(page, dict) else []
page_text = page.get("text", "") if isinstance(page, dict) else ""
title = None
for obj in objs:
if str(obj.get("class", "")).lower() in {"title", "section", "section-header"}:
title = (obj.get("text") or "").strip()
if title:
break
if title:
if cur:
cur["page_end"] = page_no - 1 if page_no > cur["page_start"] else page_no
chapters.append(cur)
cur = Chapter(title=title, text=page_text,
page_start=page_no, page_end=page_no)
elif cur:
cur["text"] += "\n" + page_text
cur["page_end"] = page_no
else:
cur = Chapter(title="Section 1", text=page_text,
page_start=page_no, page_end=page_no)
if cur:
chapters.append(cur)
return chapters
def _finalize(chapters: list[Chapter], max_chapters: Optional[int]) -> list[Chapter]:
"""Apply max_chapters cap, clean titles, and clean+truncate each body.
Text cleaning runs on EVERY path (TOC/headings/buckets/hard) so the LLM and
the extractive fallback both receive de-noised input.
"""
if max_chapters:
chapters = chapters[:max_chapters]
for i, ch in enumerate(chapters):
ch["text"] = _clean(ch["text"])[:MAX_CHARS_PER_CHAPTER]
ch["title"] = _clean_title(ch["title"])[:120]
# Log enough to verify chapters are distinct (first 120 chars of cleaned text).
log.info(
"Chapter %d/%d: title=%r pages=%d-%d text_len=%d head=%r",
i + 1, len(chapters),
ch["title"], ch["page_start"], ch["page_end"],
len(ch["text"]), ch["text"][:120],
)
return chapters
def _clean_title(title: str) -> str:
"""Strip leading numbering ("Chapter 3:", "3.1 ") from a chapter title."""
t = (title or "").strip()
t = _TITLE_NUM_RE.sub("", t)
t = _TITLE_SEC_RE.sub("", t)
return t.strip() or "Untitled"
def _is_running_header(line: str) -> bool:
"""True for running headers/footers (page numbers, repeated chapter banners)."""
s = line.strip()
if not s:
return False
if re.match(r"^\d+\s+chapter\s+\d+", s, re.I): # "74 Chapter 3: ..."
return True
if re.match(r"^chapter\s+\d+\s*:", s, re.I): # "Chapter 3: Hardware"
return True
if re.match(r"^\d+\s*$", s): # standalone page number
return True
if re.match(r"^inference\s+\d+", s, re.I): # repeated running title
return True
# Short heading-like line ending in a page number, e.g. "Hardware 73" or
# "3.2 GPU Architecture Generations 77" — no sentence punctuation, few words.
if (len(s) <= 60 and len(s.split()) <= 8
and re.search(r"\s\d{1,4}$", s)
and not re.search(r"[.!?,;:]", s)):
return True
return False
def _clean(text: str) -> str:
"""De-noise raw page text for summarization (LLM and fallback alike)."""
if not text:
return ""
# Normalize unicode spaces so header regexes see plain ASCII spacing.
text = text.replace("\u2003", " ").replace("\u2002", " ").replace("\xa0", " ")
# De-hyphenate words split across a line break: "gov-\nernments" -> "governments".
text = re.sub(r"(\w+)-\n(\w+)", r"\1\2", text)
# Drop running headers/footers line by line.
text = "\n".join(ln for ln in text.split("\n") if not _is_running_header(ln))
# Collapse whitespace.
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
# Collapse repeated consecutive words ("the the" -> "the").
text = re.sub(r"\b(\w+)(\s+\1\b)+", r"\1", text, flags=re.I)
return text.strip()
if __name__ == "__main__": # quick manual check: python -m pipeline.pdf_parser file.pdf
import sys
logging.basicConfig(level=logging.INFO)
path = sys.argv[1] if len(sys.argv) > 1 else "assets/sample.pdf"
chs = extract_chapters(path, max_chapters=int(sys.argv[2]) if len(sys.argv) > 2 else 5)
for i, c in enumerate(chs):
print(f"[{i+1}] {c['title']!r} pages {c['page_start']}-{c['page_end']} "
f"({len(c['text'])} chars)")