Spaces:
Sleeping
Sleeping
| """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", "")) | |