Source code for klea_utils.biblio.pdf
#!/usr/bin/env python3
"""
PDF bibliographic metadata extraction
File: klea_utils/biblio/pdf.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
import logging
from pathlib import Path
from .regex import DOI_RE, URL_RE
logger = logging.getLogger(__name__)
#: PDF Info-dict fields with bibliographic value, in preference order.
#: Date and tooling fields (Creator, Producer) are omitted.
_BIBLIO_FIELDS = ("Title", "Author", "Keywords", "Subject")
#: Standard Info-dict keys that pypdfium2 returns, scanned in this order
#: for an embedded DOI/URL. pdfium only exposes these standard keys --
#: arbitrary custom keys (e.g. ``DOI``/``URL``) are dropped, so a DOI or
#: URL is picked up from the *value* of these fields (journals commonly
#: place the DOI in ``Subject``).
_SCAN_ORDER = ("Subject", "Title", "Author", "Keywords", "Creator", "Producer")
[docs]
def extract_pdf_info(path: str) -> dict[str, str]:
"""Extract bibliographic metadata from a PDF's Info dict.
Reads the document's metadata fields (Title, Author, Keywords,
Subject) with pypdfium2, which is already installed as a Docling
dependency. A DOI or URL is also picked up from the values of the
standard fields (journals commonly embed the DOI in ``Subject``).
Only non-empty fields are returned, keyed lower-case (``title``,
``author``, ``keywords``, ``subject``, ``doi``, ``url``).
Returns an empty dict for non-PDF files, files without metadata,
and when the backend is unavailable, so callers can simply fall
through to the next extraction tier.
:param path: Path to the PDF file
:returns: Lower-case mapping of bibliographic PDF metadata fields to
their string values (only non-empty fields)
"""
# Lazy: importing pypdfium2 loads the native pdfium bindings. It is
# only needed when a PDF is being ingested, so defer the import.
try:
import pypdfium2 as pdfium
except ImportError:
logger.error("pypdfium2 not available, skipping PDF metadata")
return {}
pdf_path = Path(path)
if pdf_path.suffix.lower() != ".pdf" or not pdf_path.is_file():
return {}
try:
doc = pdfium.PdfDocument(str(pdf_path))
except (pdfium.PdfiumError, OSError) as e:
logger.error(f"Could not open PDF {pdf_path.name}: {e}")
return {}
try:
metadata = doc.get_metadata_dict() or {}
except (pdfium.PdfiumError, OSError) as e:
logger.error(f"Could not read metadata from {pdf_path.name}: {e}")
return {}
finally:
doc.close()
result = {}
for field in _BIBLIO_FIELDS:
value = metadata.get(field)
if value is not None and str(value).strip():
result[field.lower()] = str(value).strip()
doi, url = _find_doi_url(metadata)
if doi:
result["doi"] = doi
if url:
result["url"] = url
logger.debug(f"extracted PDF Info dict for {pdf_path.name}: {result}")
return result
def _find_doi_url(metadata: dict) -> tuple[str | None, str | None]:
"""Pick a DOI and URL out of a PDF Info dict.
Scans the standard metadata field values (Subject first -- journals
commonly embed the DOI there) for a DOI or URL pattern. pypdfium2
does not return arbitrary custom keys, so this works on the values
of the fields it does return.
:param metadata: Metadata dict from pypdfium2
:returns: ``(doi, url)``, either or both possibly ``None``
"""
doi: str | None = None
url: str | None = None
for key in _SCAN_ORDER:
value = metadata.get(key)
if not value or not str(value).strip():
continue
value_str = str(value)
if doi is None:
match = DOI_RE.search(value_str)
if match:
doi = _rstrip_punct(match.group(0))
if url is None:
match = URL_RE.search(value_str)
if match:
url = _rstrip_punct(match.group(0))
if doi is not None and url is not None:
break
return doi, url
def _rstrip_punct(value: str) -> str:
"""Strip trailing punctuation that may follow a DOI/URL in a value."""
return value.rstrip(".,;:)]}")