auto-paper-list / parse_arbitrary_pdf_first_page.py
walston's picture
Deploy upload-to-HTML demo with runtime analytics
9ef19d7 verified
Raw
History Blame Contribute Delete
19.8 kB
"""Parse authors, affiliations, and short abstract summaries from arbitrary PDFs.
This module intentionally uses the same lightweight dependency as the ICLR parser
(`pypdf`) but does not assume an ICLR/OpenReview template. It focuses on the
first page and handles common layouts where affiliations appear below authors,
inline with numeric markers, or are absent.
"""
from __future__ import annotations
import re
from pathlib import Path
import pypdf
from parse_pdf_affiliations import (
MARKER_GLYPH_RE,
_INST_KW_RE,
_PLACE_KW,
_clean_affil_text,
_is_footnote_text,
_split_affiliations,
)
SECTION_RE = re.compile(
r"^(abstract|a\s*b\s*s\s*t\s*r\s*a\s*c\s*t|summary|index\s+terms|"
r"keywords|1\.?\s+introduction|i\.?\s+introduction|introduction)\b",
re.I,
)
ABSTRACT_RE = re.compile(r"^(abstract|a\s*b\s*s\s*t\s*r\s*a\s*c\s*t)", re.I)
INTRO_RE = re.compile(r"^(1\.?\s+introduction|i\.?\s+introduction|introduction)\b", re.I)
EMAIL_RE = re.compile(r"\S+@\S+\.\w+")
AUTHOR_MARK_RE = re.compile(r"(\d+|" + MARKER_GLYPH_RE + r"|[A-Z])+$")
AFFIL_HINT_RE = re.compile(
r"\b("
r"University|Universit|Université|Universität|Institute|Institut|College|"
r"School|Department|Laboratory|Lab\b|Research|Academy|Foundation|Center|"
r"Centre|Inc\.?|Corp\.?|Ltd\.?|LLC|GmbH|Company|NVIDIA|Google|Meta|"
r"Microsoft|Amazon|Alibaba|Tencent|ByteDance|OpenAI|Anthropic"
r")\b",
re.I,
)
BAD_AUTHOR_WORDS = {
"abstract",
"introduction",
"preprint",
"arxiv",
"github",
"demo",
"code",
"date",
"keywords",
"index terms",
"correspondence",
"corresponding author",
"equal contribution",
}
def _read_first_page(path: str | Path) -> str | None:
try:
reader = pypdf.PdfReader(str(path))
if not reader.pages:
return ""
return reader.pages[0].extract_text() or ""
except Exception:
return None
def _clean_line(s: str) -> str:
s = s.replace("\u00a0", " ")
s = re.sub(r"\s+", " ", s)
return s.strip()
def _first_page_lines(text: str) -> list[str]:
return [_clean_line(ln) for ln in text.splitlines() if _clean_line(ln)]
def _section_index(lines: list[str], pattern: re.Pattern[str]) -> int | None:
for i, line in enumerate(lines):
if pattern.match(line):
return i
return None
def _is_affiliation_line(line: str) -> bool:
if EMAIL_RE.search(line):
return False
if _is_footnote_text(line):
return False
if "foundation model" in line.lower():
return False
if re.match(r"^\s*\d{1,2}\s*[A-ZÀ-Ý]", line):
return True
if AFFIL_HINT_RE.search(line) or _INST_KW_RE.search(line) or _PLACE_KW.search(line):
return True
return False
def _looks_like_short_org(line: str) -> bool:
if EMAIL_RE.search(line) or _is_footnote_text(line) or SECTION_RE.match(line):
return False
if re.search(r"\b(model|models|report|title|paper)\b", line, re.I):
return False
words = line.split()
if not (1 <= len(words) <= 3):
return False
if all(re.match(r"^[A-ZÀ-Ý][A-Za-zÀ-ÿ'’.\-]+$", word) for word in words):
return True
return False
def _capitalized_token_count(line: str) -> int:
return len(re.findall(r"\b[A-ZÀ-Ý][A-Za-zÀ-ÿ'’.\-]+\b", line))
def _looks_like_author_start(line: str) -> bool:
low = line.lower()
if any(w in low for w in BAD_AUTHOR_WORDS):
return False
if ":" in line and "," not in line:
return False
if EMAIL_RE.search(line) or _is_affiliation_line(line):
return False
cap_count = _capitalized_token_count(line)
if cap_count < 2:
return False
has_author_punctuation = "," in line or re.search(r"\d|" + MARKER_GLYPH_RE, line)
if has_author_punctuation and len(line) <= 700:
return True
return False
def _looks_like_author_continuation(line: str) -> bool:
if _looks_like_author_start(line):
return True
if EMAIL_RE.search(line) or _is_affiliation_line(line):
return False
return "," in line and _capitalized_token_count(line) >= 2
def _find_author_block(header: list[str]) -> tuple[list[str], int, int]:
"""Return (author_lines, start, end_exclusive)."""
best: tuple[list[str], int, int] | None = None
for i, line in enumerate(header):
if not _looks_like_author_start(line):
continue
block = [line]
j = i + 1
while j < len(header) and _looks_like_author_continuation(header[j]):
block.append(header[j])
j += 1
text = " ".join(block)
# Prefer candidates with explicit author markers or comma-separated names.
score = (2 if re.search(r"\d|" + MARKER_GLYPH_RE, text) else 0) + text.count(",")
if best is None or score > (" ".join(best[0]).count(",") + 2):
best = (block, i, j)
return best or ([], -1, -1)
def _is_individual_author_line(line: str) -> bool:
if _is_affiliation_line(line) or _is_footnote_text(line):
return False
line = EMAIL_RE.sub("", line)
name, _ = _strip_author_markers(line)
low = name.lower()
if any(w in low for w in BAD_AUTHOR_WORDS):
return False
if any(w in low for w in ("interaction", "dialogue", "generation", "model", "benchmark", "technical report")):
return False
tokens = name.split()
if not (2 <= len(tokens) <= 6):
return False
if not re.match(r"^[A-ZÀ-Ý]", tokens[0]):
return False
# Long title fragments tend to have many lowercase function words.
stopwords = {"a", "an", "the", "of", "for", "in", "on", "with", "to", "and"}
if sum(1 for tok in tokens if tok.lower() in stopwords) >= 2:
return False
return True
def _find_individual_author_block(header: list[str]) -> tuple[list[str], int, int]:
first_affil = next((i for i, line in enumerate(header) if _is_affiliation_line(line)), len(header))
block: list[str] = []
start = -1
end = -1
scan_start = 1 if len(header) > 1 else 0
for i, line in enumerate(header[:first_affil]):
if i < scan_start:
continue
if _is_individual_author_line(line):
if start < 0:
start = i
block.append(line)
end = i + 1
elif block:
# Allow a short run of one-author-per-line stanzas only.
break
if len(block) >= 2:
return block, start, end
return [], -1, -1
def _find_space_separated_author_block(header: list[str]) -> tuple[list[str], int, int]:
first_affil = next((i for i, line in enumerate(header) if _is_affiliation_line(line)), len(header))
candidates: list[str] = []
start = -1
scan_start = 1 if len(header) > 1 else 0
for i, line in enumerate(header[:first_affil]):
if i < scan_start:
continue
low = line.lower()
if any(k in low for k in ("figure", "demo", " model", " code", "github", "huggingface", "technical report")):
break
if "team" in low and "," in line:
break
letters = [ch for ch in line if ch.isalpha()]
if letters and sum(ch.isupper() for ch in letters) / len(letters) > 0.75:
continue
if _is_individual_author_line(line):
if start < 0:
start = i
candidates.append(line)
continue
if "," not in line and _capitalized_token_count(line) >= 4 and not _is_affiliation_line(line):
words = line.split()
stopwords = {"a", "an", "the", "of", "for", "in", "on", "with", "to", "and"}
if sum(1 for word in words if word.lower() in stopwords) <= 1:
if start < 0:
start = i
candidates.append(line)
continue
if candidates:
break
if candidates and sum(_capitalized_token_count(line) for line in candidates) >= 4:
return candidates, start, start + len(candidates)
return [], -1, -1
def _find_single_or_team_author_block(header: list[str]) -> tuple[list[str], int, int]:
scan_start = 1 if len(header) > 1 else 0
for i, line in enumerate(header):
if i < scan_start:
continue
if SECTION_RE.match(line):
break
if "team" in line.lower() and "," in line:
return [line], i, i + 1
if _is_affiliation_line(line):
break
if _is_individual_author_line(line):
return [line], i, i + 1
return [], -1, -1
def _strip_author_markers(name: str) -> tuple[str, list[str]]:
name = name.strip(" ,;")
markers = re.findall(r"\d+|" + MARKER_GLYPH_RE, name)
marker_atom = r"(?:\d+|" + MARKER_GLYPH_RE + r"|\*)"
name = re.sub(r"(?:\s*,?\s*" + marker_atom + r")+\s*$", "", name)
name = re.sub(r"\s+", " ", name)
return name.strip(" ,;"), markers
def _parse_authors(author_lines: list[str]) -> list[tuple[str, list[str]]]:
if len(author_lines) >= 2 and all(_is_individual_author_line(line) for line in author_lines):
parts = [EMAIL_RE.sub("", line).strip() for line in author_lines]
elif author_lines and all("," not in line for line in author_lines) and sum(_capitalized_token_count(line) for line in author_lines) >= 4:
words = " ".join(EMAIL_RE.sub("", line) for line in author_lines).split()
parts = [" ".join(words[i:i + 2]) for i in range(0, len(words) - 1, 2)]
else:
text = " ".join(author_lines)
text = EMAIL_RE.sub("", text)
text = re.sub(r"\s*,\s*", ", ", text)
text = re.sub(r"\s+and\s+", ", ", text, flags=re.I)
text = re.sub(r"\s*&\s*", ", ", text)
# Preserve affiliation marker lists ("2,4") while splitting authors.
text = re.sub(r"(?<=\d)\s*,\s*(?=\d)", "|", text)
# Insert a separator in extraction glitches: "Wang1,Yuhao" or
# "Zhou 2 Julia Wang2".
text = re.sub(r"(?<=[a-zà-ÿ])(\d+)(?=[A-ZÀ-Ý])", r"\1, ", text)
text = re.sub(r"(?<=[\d*†‡§¶∗⋆⋄♯♭♮])\s+(?=[A-ZÀ-Ý][a-zà-ÿ])", ", ", text)
parts = [p.strip().replace("|", ",") for p in text.split(",") if p.strip()]
authors: list[tuple[str, list[str]]] = []
for part in parts:
if AFFIL_HINT_RE.search(part) or EMAIL_RE.search(part):
continue
name, markers = _strip_author_markers(part)
low = name.lower()
if any(w in low for w in BAD_AUTHOR_WORDS):
continue
tokens = name.split()
if "team" in name.lower():
pass
elif not (2 <= len(tokens) <= 6):
continue
if not re.match(r"^[A-ZÀ-Ý]", tokens[0]):
continue
if len(name) > 90:
continue
authors.append((name, markers))
# Deduplicate while preserving order.
deduped: list[tuple[str, list[str]]] = []
seen = set()
for name, markers in authors:
key = re.sub(r"\W+", "", name).lower()
if key and key not in seen:
deduped.append((name, markers))
seen.add(key)
return deduped
def _parse_affiliations(lines: list[str]) -> tuple[dict[str, str], list[str]]:
marker_to_inst: dict[str, str] = {}
shared: list[str] = []
joined_lines: list[str] = []
buf: list[str] = []
for line in lines:
if EMAIL_RE.search(line) or _is_footnote_text(line):
if buf:
joined_lines.append(" ".join(buf))
buf = []
continue
if _is_affiliation_line(line) or _looks_like_short_org(line):
buf.append(line)
continue
if buf and re.search(r"\b\d{1,2}\s*[A-ZÀ-Ý]", line):
buf.append(line)
continue
if buf:
joined_lines.append(" ".join(buf))
buf = []
if buf:
joined_lines.append(" ".join(buf))
for line in joined_lines:
if EMAIL_RE.search(line) or _is_footnote_text(line):
continue
if not (_is_affiliation_line(line) or _looks_like_short_org(line)):
continue
pairs = _split_affiliations(line)
if not pairs:
cleaned = _clean_affil_text(line)
if cleaned:
pairs = [("all", cleaned)]
for marker, inst in pairs:
inst = _clean_affil_text(inst)
if not inst or _is_footnote_text(inst):
continue
if marker.isdigit():
marker_to_inst[marker] = inst
elif marker == "all":
shared.append(inst)
shared = list(dict.fromkeys(shared))
return marker_to_inst, shared
def _extract_title(header: list[str], author_start: int) -> str:
title_lines = header[:author_start] if author_start > 0 else []
cleaned: list[str] = []
for line in title_lines:
low = line.lower()
if low.startswith(("arxiv:", "preprint", "code:", "demo:", "date:")):
continue
if SECTION_RE.match(line):
break
cleaned.append(line)
return " ".join(cleaned).strip()
def _normalize_page_text(text: str) -> str:
text = re.sub(r"(\w)-\s+(\w)", r"\1\2", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
def _extract_abstract(text: str) -> str:
lines = _first_page_lines(text)
start = _section_index(lines, ABSTRACT_RE)
if start is None:
return ""
chunks: list[str] = []
first = re.sub(ABSTRACT_RE, "", lines[start], count=1).strip(" :-")
if first:
chunks.append(first)
for line in lines[start + 1:]:
if INTRO_RE.match(line) or re.match(r"^(index\s+terms|keywords)\b", line, re.I):
break
chunks.append(line)
return _normalize_page_text(" ".join(chunks))
def _extract_unlabeled_summary(lines: list[str], author_end: int) -> str:
if author_end < 0:
return ""
chunks: list[str] = []
for line in lines[author_end:]:
if INTRO_RE.match(line):
break
if re.match(r"^(code|demo|date|keywords|index\s+terms)\s*:", line, re.I):
break
if EMAIL_RE.search(line) or _is_affiliation_line(line) or _is_footnote_text(line):
continue
if not chunks and not re.match(r"^(we|this|while|current|recent|natural|speech|real-time|as)\b", line, re.I):
continue
chunks.append(line)
return _normalize_page_text(" ".join(chunks))
def _split_sentences(text: str) -> list[str]:
text = _normalize_page_text(text)
if not text:
return []
pieces = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text)
return [p.strip() for p in pieces if len(p.strip()) > 25]
def _one_sentence_summary(abstract: str) -> tuple[str, str]:
sentences = _split_sentences(abstract)
if not sentences:
return "", ""
problem_kw = re.compile(
r"\b(however|despite|lack|lacks|lacking|challenge|challenging|gap|"
r"limited|limitation|remain|fails?|difficult|need|requires?|ignore|"
r"suboptimal|overhead|latency|costly)\b",
re.I,
)
solution_kw = re.compile(
r"\b(we\s+(propose|present|introduce|develop|build|construct|design|"
r"release|evaluate)|this\s+(paper|work|study)\s+(proposes|presents|"
r"introduces|develops)|our\s+(benchmark|framework|model|method|system))\b",
re.I,
)
problem = next((s for s in sentences if problem_kw.search(s)), sentences[0])
solution = next((s for s in sentences if solution_kw.search(s) and s != problem), "")
if not solution:
solution = next((s for s in sentences[1:] if s != problem), "")
return problem, solution
def parse_arbitrary_pdf(path: str | Path) -> dict:
text = _read_first_page(path)
if text is None:
return {"success": False, "reason": "pdf_read_error"}
lines = _first_page_lines(text)
section_idx = _section_index(lines, SECTION_RE)
header_end = section_idx if section_idx is not None else min(len(lines), 40)
header = lines[:header_end]
author_lines, author_start, author_end = _find_author_block(header)
authors_with_markers = _parse_authors(author_lines)
space_lines, space_start, space_end = _find_space_separated_author_block(header)
space_authors = _parse_authors(space_lines)
if len(space_authors) > len(authors_with_markers):
author_lines, author_start, author_end = space_lines, space_start, space_end
authors_with_markers = space_authors
if len(authors_with_markers) < 2:
author_lines, author_start, author_end = _find_individual_author_block(header)
authors_with_markers = _parse_authors(author_lines)
if len(authors_with_markers) < 2:
author_lines, author_start, author_end = _find_space_separated_author_block(header)
authors_with_markers = _parse_authors(author_lines)
if not authors_with_markers:
author_lines, author_start, author_end = _find_single_or_team_author_block(header)
authors_with_markers = _parse_authors(author_lines)
if not authors_with_markers:
for i, line in enumerate(header[1:], start=1):
if _looks_like_short_org(line) or _is_affiliation_line(line):
author_lines, author_start, author_end = [line], i, i + 1
authors_with_markers = [(line, [])]
break
affil_region = header[author_end:] if author_end >= 0 else header
marker_to_inst, shared_affils = _parse_affiliations(affil_region)
if author_lines and len(author_lines) == 1 and "team" in author_lines[0].lower() and "," in author_lines[0]:
inline_affil = _clean_affil_text(author_lines[0].split(",", 1)[1])
if inline_affil:
shared_affils.append(inline_affil)
shared_affils = list(dict.fromkeys(shared_affils))
if not marker_to_inst and not shared_affils and author_lines and len(author_lines) == 1:
if _looks_like_short_org(author_lines[0]) or _is_affiliation_line(author_lines[0]):
shared_affils = [author_lines[0]]
authors = [name for name, _ in authors_with_markers]
per_author: list[list[str]] = []
for _, markers in authors_with_markers:
numeric_markers = [m for m in markers if m.isdigit()]
affils = [marker_to_inst[m] for m in numeric_markers if m in marker_to_inst]
if not affils and shared_affils:
affils = list(shared_affils)
per_author.append(list(dict.fromkeys(affils)))
institutions = list(dict.fromkeys([a for affs in per_author for a in affs] + shared_affils + list(marker_to_inst.values())))
abstract = _extract_abstract(text)
if not abstract:
abstract = _extract_unlabeled_summary(lines, author_end)
problem, solution = _one_sentence_summary(abstract)
return {
"success": bool(authors),
"reason": "" if authors else "authors_not_found",
"title": _extract_title(header, author_start),
"authors": authors,
"affiliations_per_author": per_author,
"institutions_set": institutions,
"abstract": abstract,
"problem_solved": problem,
"how_solved": solution,
"pattern": "arbitrary_first_page",
}
if __name__ == "__main__":
import sys
for pdf in sys.argv[1:]:
result = parse_arbitrary_pdf(pdf)
print(f"\n=== {pdf} ===")
print(f"success={result.get('success')} reason={result.get('reason', '')}")
print(f"title={result.get('title', '')}")
for author, affils in zip(result.get("authors", []), result.get("affiliations_per_author", [])):
print(f" {author} -> {', '.join(affils)}")
print("problem:", result.get("problem_solved", ""))
print("solution:", result.get("how_solved", ""))