Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import io | |
| import re | |
| from dataclasses import dataclass | |
| from pypdf import PdfReader | |
| MAX_PDF_PAGES = 3 | |
| TITLE_SEARCH_LINES = 12 | |
| MAX_ABSTRACT_CHARS = 2800 | |
| ABSTRACT_HEADER_RE = re.compile(r"^abstract\b[:\s-]*(.*)$", re.IGNORECASE) | |
| SECTION_HEADER_PATTERNS = ( | |
| re.compile(r"^\d+(\.\d+)*\s+[A-Z]"), | |
| re.compile(r"^[IVXLC]+\.\s+[A-Z]"), | |
| re.compile( | |
| r"^(introduction|background|methods?|materials and methods?|results?|discussion|" | |
| r"conclusion|conclusions|references|keywords|index terms?)\b", | |
| re.IGNORECASE, | |
| ), | |
| ) | |
| NOISE_PATTERNS = ( | |
| re.compile(r"^arXiv:\S+", re.IGNORECASE), | |
| re.compile(r"^page \d+", re.IGNORECASE), | |
| re.compile(r"^(published as|accepted at|to appear in)\b", re.IGNORECASE), | |
| re.compile(r"^\d+$"), | |
| ) | |
| AFFILIATION_HINTS = ( | |
| "university", | |
| "institute", | |
| "department", | |
| "laboratory", | |
| "school", | |
| "college", | |
| "centre", | |
| "center", | |
| "faculty", | |
| ) | |
| LIGATURES = { | |
| "\ufb00": "ff", | |
| "\ufb01": "fi", | |
| "\ufb02": "fl", | |
| "\ufb03": "ffi", | |
| "\ufb04": "ffl", | |
| } | |
| class PdfArticleFields: | |
| title: str | |
| abstract: str | |
| pages_read: int | |
| def _normalize_text(text: str) -> str: | |
| normalized = text | |
| for source, target in LIGATURES.items(): | |
| normalized = normalized.replace(source, target) | |
| normalized = re.sub(r"\b([A-Z])\s+([A-Z]{2,}\b)", r"\1\2", normalized) | |
| return normalized | |
| def _clean_line(raw_line: str) -> str: | |
| normalized = _normalize_text(raw_line).replace("\x00", " ") | |
| normalized = re.sub(r"\s+", " ", normalized).strip() | |
| if not normalized: | |
| return "" | |
| for pattern in NOISE_PATTERNS: | |
| if pattern.match(normalized): | |
| return "" | |
| return normalized | |
| def _has_alpha_content(line: str, min_chars: int = 8) -> bool: | |
| return sum(char.isalpha() for char in line) >= min_chars | |
| def _looks_like_author_or_affiliation(line: str) -> bool: | |
| lowered = line.lower() | |
| if "@" in line or "http" in lowered: | |
| return True | |
| if any(hint in lowered for hint in AFFILIATION_HINTS): | |
| return True | |
| if re.search(r"\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b", lowered): | |
| return True | |
| words = [re.sub(r"[^A-Za-z]", "", token) for token in line.split()] | |
| capitalized_words = sum( | |
| bool(word) and word[0].isupper() and (len(word) == 1 or word[1:].islower()) | |
| for word in words | |
| ) | |
| if line.count(",") >= 2 and capitalized_words >= 4: | |
| return True | |
| if len(line.split()) <= 6 and sum(token.istitle() for token in line.split()) >= 2: | |
| return True | |
| return False | |
| def _is_section_heading(line: str) -> bool: | |
| for pattern in SECTION_HEADER_PATTERNS: | |
| if pattern.match(line): | |
| return True | |
| return bool(len(line) < 80 and line.isupper() and any(char.isalpha() for char in line)) | |
| def _extract_text_from_pdf(pdf_bytes: bytes, max_pages: int) -> tuple[str, int]: | |
| reader = PdfReader(io.BytesIO(pdf_bytes)) | |
| page_texts = [] | |
| pages_read = min(len(reader.pages), max_pages) | |
| for page_index in range(pages_read): | |
| page_text = reader.pages[page_index].extract_text() or "" | |
| if page_text.strip(): | |
| page_texts.append(page_text) | |
| if not page_texts: | |
| raise ValueError( | |
| "Could not extract text from the uploaded PDF. Try another PDF or paste the fields manually." | |
| ) | |
| return "\n".join(page_texts), pages_read | |
| def _prepare_lines(raw_text: str) -> list[str]: | |
| return [line for line in (_clean_line(item) for item in raw_text.splitlines()) if line] | |
| def _find_abstract_header(lines: list[str]) -> tuple[int | None, str]: | |
| for index, line in enumerate(lines[:80]): | |
| match = ABSTRACT_HEADER_RE.match(line) | |
| if match: | |
| return index, match.group(1).strip() | |
| return None, "" | |
| def _extract_title(lines: list[str], abstract_index: int | None) -> str: | |
| search_limit = abstract_index if abstract_index is not None else min(len(lines), TITLE_SEARCH_LINES) | |
| search_limit = max(1, min(search_limit, TITLE_SEARCH_LINES)) | |
| title_start = None | |
| for index, line in enumerate(lines[:search_limit]): | |
| if len(line) > 220: | |
| continue | |
| if not _has_alpha_content(line): | |
| continue | |
| if ABSTRACT_HEADER_RE.match(line): | |
| continue | |
| title_start = index | |
| break | |
| if title_start is None: | |
| raise ValueError( | |
| "Could not detect the article title in the uploaded PDF. Try another PDF or edit the fields manually." | |
| ) | |
| title_parts = [lines[title_start]] | |
| for candidate in lines[title_start + 1 : min(search_limit, title_start + 3)]: | |
| if _looks_like_author_or_affiliation(candidate): | |
| break | |
| if not _has_alpha_content(candidate, min_chars=5): | |
| break | |
| current_title = " ".join(title_parts) | |
| if len(current_title) + 1 + len(candidate) > 220: | |
| break | |
| title_parts.append(candidate) | |
| return " ".join(title_parts) | |
| def _extract_abstract(lines: list[str], abstract_index: int | None, inline_text: str) -> str: | |
| if abstract_index is None: | |
| raise ValueError( | |
| "Could not detect the abstract section in the uploaded PDF. Try another PDF or paste the fields manually." | |
| ) | |
| abstract_parts = [inline_text] if inline_text else [] | |
| for line in lines[abstract_index + 1 :]: | |
| if abstract_parts and _is_section_heading(line): | |
| break | |
| if abstract_parts and ABSTRACT_HEADER_RE.match(line): | |
| continue | |
| abstract_parts.append(line) | |
| if len(" ".join(abstract_parts)) >= MAX_ABSTRACT_CHARS: | |
| break | |
| abstract = re.sub(r"\s+", " ", " ".join(part for part in abstract_parts if part)).strip() | |
| if len(abstract) < 120: | |
| raise ValueError( | |
| "The uploaded PDF did not yield a usable abstract. Try another PDF or paste the abstract manually." | |
| ) | |
| return abstract | |
| def extract_article_fields_from_pdf( | |
| pdf_bytes: bytes, | |
| *, | |
| max_pages: int = MAX_PDF_PAGES, | |
| ) -> PdfArticleFields: | |
| raw_text, pages_read = _extract_text_from_pdf(pdf_bytes, max_pages=max_pages) | |
| lines = _prepare_lines(raw_text) | |
| if not lines: | |
| raise ValueError( | |
| "The uploaded PDF does not contain readable text on the first pages. Try another PDF." | |
| ) | |
| abstract_index, inline_abstract = _find_abstract_header(lines) | |
| title = _extract_title(lines, abstract_index) | |
| abstract = _extract_abstract(lines, abstract_index, inline_abstract) | |
| return PdfArticleFields(title=title, abstract=abstract, pages_read=pages_read) | |