Source code for plotastro._authors

"""Generate journal-ready LaTeX author/affiliation blocks from a CSV file.

Works directly with real collaboration author lists. Recognised columns
(header names are case-insensitive; **any other column is ignored**, so
collaboration bookkeeping like ``JoinedAsBuilder`` is fine):

    Authorname     display name, e.g. "Behnood Bandi" (alias: "name");
                   if absent, built from "Firstname" + "Lastname"
    Affiliation    one or more affiliations separated by ";"
                   (aliases: "affiliations", "affil", "affiliation1", ...)
    ORCID          optional
    Email          optional; authors with an email are marked corresponding

Example (a typical collaboration list — see examples/authors_example.csv
for the full file)::

    Lastname,Firstname,Authorname,Email,JoinedAsBuilder,Affiliation,ORCID,
    Bandi,Behnood,Behnood Bandi, b.bandi@sussex.ac.uk, False,"Astronomy Centre, University of Sussex, Falmer, Brighton BN1 9QH, UK",0000-0001-5838-3903,
    Rocher,Antoine,Antoine Rocher,antoine.rocher@epfl.ch,False,"EPFL, \\'{E}cole polytechnique f\\'{e}d\\'{e}rale de Lausanne, Chemin des Maillettes, 51, 1290 Versoix, Switzerland",0000-0003-4349-6424,

Notes:

- LaTeX already present in the CSV (accents like ``\\'{e}``, maths, ...)
  is passed through untouched; only unescaped ``& % # _`` are escaped.
- Whitespace around values is stripped (`` b.bandi@...`` is fine).
- An author appearing on **several rows** (one per affiliation, as some
  collaborations do) is merged into one entry with all affiliations.
- Author order = row order; affiliations are numbered in order of first
  appearance and shared between authors automatically.

Usage from Python::

    import plotastro as pa
    print(pa.authorlist("authors.csv", journal="mnras"))

or from the command line (installed with the package)::

    plotastro-authors authors.csv --journal mnras
    plotastro-authors authors.csv -j apj -o authors.tex

The output is a starting point that compiles with the journal's template —
fine-tune addresses, footnotes etc. in the .tex file.
"""

from __future__ import annotations

import argparse
import csv
import re
import string
from pathlib import Path

# Journals sharing an author-block format:
_FORMATS = {
    "mnras": "mnras", "rasti": "mnras",
    "aanda": "aanda", "euclid": "aanda",   # Euclid papers use A&A's aaEC class
    "apj": "aastex", "oja": "aastex",
    "prd": "revtex",
    "jcap": "jcap",
    "generic": "generic", "natastro": "generic",
    "thesis": "generic", "beamer": "generic",
}

def _escape(text):
    """Escape unescaped & % # _ for LaTeX; leave existing LaTeX alone."""
    return re.sub(r"(?<!\\)([&%#_])", r"\\\1", str(text).strip())


def _norm_key(key):
    return str(key).strip().lower().replace(" ", "").replace("_", "")


def _read_authors(source):
    """Return a list of {name, affils, orcid, email} dicts.

    `source` is a CSV path, or an already-parsed list of dicts (with the
    same keys as the CSV columns) for programmatic use. See the module
    docstring for the recognised columns; rows repeating an author's name
    are merged (extra affiliations appended).
    """
    if isinstance(source, (str, Path)):
        with open(source, newline="", encoding="utf-8-sig") as f:
            rows = list(csv.DictReader(f))
    else:
        rows = [dict(row) for row in source]

    authors, seen = [], {}
    for raw in rows:
        row = {_norm_key(k): (v or "").strip() for k, v in raw.items() if k}
        name = (row.get("authorname") or row.get("name") or " ".join(
            part for part in (row.get("firstname"), row.get("lastname")) if part))
        if not name:
            continue
        affils = []
        for key in sorted(row):  # affiliation, affiliations, affiliation1, ...
            if key.startswith("affil"):
                affils += [a.strip() for a in row[key].split(";") if a.strip()]

        author = seen.get(name.lower())
        if author is None:
            author = {"name": _escape(name), "affils": [],
                      "orcid": "", "email": ""}
            seen[name.lower()] = author
            authors.append(author)
        for aff in affils:
            escaped = _escape(aff)
            if escaped not in author["affils"]:
                author["affils"].append(escaped)
        author["orcid"] = author["orcid"] or row.get("orcid", "")
        author["email"] = author["email"] or row.get("email", "")
    if not authors:
        raise ValueError(
            "No authors found — the CSV needs an 'Authorname' or 'name' "
            "column (or 'Firstname'/'Lastname') with at least one "
            "non-empty row.")
    return authors


def _affiliation_index(authors):
    """Ordered unique affiliations -> 1-based numbering."""
    index = {}
    for a in authors:
        for aff in a["affils"]:
            index.setdefault(aff, len(index) + 1)
    return index


def _short_authors(authors):
    """Running-head form: 'B. Bandi et al.', 'Doe & Roe', or 'B. Bandi'."""
    def surname(a):
        return a["name"].split()[-1]

    first = authors[0]["name"].split()
    initial = f"{first[0][0]}. {first[-1]}" if len(first) > 1 else first[0]
    if len(authors) == 1:
        return initial
    if len(authors) == 2:
        return f"{surname(authors[0])} \\& {surname(authors[1])}"
    return f"{initial} et al."


def _sup(author, index):
    nums = ",".join(str(n) for n in sorted(index[a] for a in author["affils"]))
    return f"$^{{{nums}}}$" if nums else ""


def _corresponding(authors):
    """First author with an email — the only one footnoted in the
    single-\\thanks formats (MNRAS, A&A)."""
    return next((a for a in authors if a["email"]), None)


def _fmt_mnras(authors, index):
    corr = _corresponding(authors)
    lines = [f"\\author[{_short_authors(authors)}]{{"]
    for i, a in enumerate(authors):
        thanks = f"\\thanks{{E-mail: {a['email']}}}" if a is corr else ""
        if len(authors) > 1 and i == len(authors) - 1:
            lines.append(f"and {a['name']}{_sup(a, index)}{thanks}")
        elif i >= len(authors) - 2:  # no comma before the final 'and'
            lines.append(f"{a['name']}{_sup(a, index)}{thanks}")
        else:
            lines.append(f"{a['name']},{_sup(a, index)}{thanks}")
    lines += ["\\\\", "% List of institutions"]
    insts = [f"$^{{{n}}}$" + aff for aff, n in index.items()]
    lines.append("\\\\\n".join(insts))
    lines.append("}")
    return "\n".join(lines)


def _fmt_aanda(authors, index):
    corr = _corresponding(authors)
    parts = []
    for a in authors:
        nums = ",".join(str(n) for n in sorted(index[x] for x in a["affils"]))
        inst = f"\\inst{{{nums}}}" if nums else ""
        thanks = f"\\thanks{{\\email{{{a['email']}}}}}" if a is corr else ""
        parts.append(f"{a['name']}{inst}{thanks}")
    author_block = "\\author{" + "\n        \\and ".join(parts) + "}"
    inst_block = ("\\institute{" +
                  "\n           \\and ".join(index) + "}")
    return author_block + "\n\n" + inst_block


def _fmt_aastex(authors, index):
    blocks = []
    corresponding = _corresponding(authors)
    if corresponding:
        blocks.append(f"\\correspondingauthor{{{corresponding['name']}}}\n"
                      f"\\email{{{corresponding['email']}}}")
    for a in authors:
        opt = f"[{a['orcid']}]" if a["orcid"] else ""
        lines = [f"\\author{opt}{{{a['name']}}}"]
        lines += [f"\\affiliation{{{aff}}}" for aff in a["affils"]]
        blocks.append("\n".join(lines))
    return "\n\n".join(blocks)


def _fmt_revtex(authors, index):
    blocks = []
    for a in authors:
        lines = [f"\\author{{{a['name']}}}"]
        if a["email"]:
            lines.append(f"\\email{{{a['email']}}}")
        lines += [f"\\affiliation{{{aff}}}" for aff in a["affils"]]
        blocks.append("\n".join(lines))
    return "\n\n".join(blocks)


def _fmt_jcap(authors, index):
    # jcappub labels affiliations with letters
    if len(index) > 26:
        raise ValueError("The JCAP format supports at most 26 affiliations.")
    letters = {aff: string.ascii_lowercase[n - 1] for aff, n in index.items()}
    lines = []
    for a in authors:
        labels = ",".join(sorted(letters[x] for x in a["affils"]))
        opt = f"[{labels}]" if labels else ""
        lines.append(f"\\author{opt}{{{a['name']}}}")
    lines.append("")
    lines += [f"\\affiliation[{letters[aff]}]{{{aff}}}" for aff in index]
    emails = [a["email"] for a in authors if a["email"]]
    if emails:
        lines.append("")
        lines += [f"\\emailAdd{{{e}}}" for e in emails]
    return "\n".join(lines)


def _fmt_generic(authors, index):
    names = [f"{a['name']}{_sup(a, index)}" for a in authors]
    if len(names) > 1:
        head = ", ".join(names[:-1]) + " and " + names[-1]
    else:
        head = names[0]
    insts = [f"$^{{{n}}}$" + aff for aff, n in index.items()]
    return head + "\n\n" + "\n".join(insts)


_RENDERERS = {"mnras": _fmt_mnras, "aanda": _fmt_aanda, "aastex": _fmt_aastex,
              "revtex": _fmt_revtex, "jcap": _fmt_jcap, "generic": _fmt_generic}


[docs] def authorlist(source, journal="mnras"): """LaTeX author/affiliation block for a journal, from a CSV file. Parameters ---------- source : str, Path, or list of dicts Path to a CSV file with columns ``name``, ``affiliations`` (";"-separated), ``orcid``, ``email`` — or an equivalent list of dicts. See the module docstring for the format. journal : str Any journal key/alias the package knows (``"mnras"``, ``"aanda"``, ``"apj"``, ``"oja"``, ``"prd"``, ``"jcap"``, ...) or ``"generic"`` for a plain numbered-superscript block. Returns ------- str : LaTeX source to paste into your manuscript. Examples -------- >>> print(pa.authorlist("authors.csv", journal="aanda")) """ from ._core import _resolve key = "generic" if str(journal).lower() == "generic" else _resolve(journal) fmt = _FORMATS[key] authors = _read_authors(source) index = _affiliation_index(authors) header = (f"% Author list generated by plotastro ({key} format)\n" f"% Check addresses/footnotes against the journal template.\n") return header + _RENDERERS[fmt](authors, index)
def main(argv=None): """Command-line entry point: ``plotastro-authors authors.csv -j mnras``.""" parser = argparse.ArgumentParser( prog="plotastro-authors", description="Generate a journal-ready LaTeX author/affiliation block " "from a CSV file (columns: Authorname or Firstname/" "Lastname, Affiliation, ORCID, Email; extra columns are " "ignored).") parser.add_argument("csv", help="path to the author CSV file") parser.add_argument("-j", "--journal", default="mnras", help="journal key, e.g. mnras, aanda, apj, oja, prd, " "jcap, or 'generic' (default: mnras)") parser.add_argument("-o", "--output", help="write to this .tex file instead of stdout") args = parser.parse_args(argv) try: tex = authorlist(args.csv, journal=args.journal) except (ValueError, OSError) as exc: parser.exit(1, f"error: {exc}\n") if args.output: Path(args.output).write_text(tex + "\n", encoding="utf-8") print(f"wrote {args.output}") else: print(tex) if __name__ == "__main__": main()