opensheaf-py the tools of OpenSheaf, a literate program chunks · source

Importing from arXiv: the metadata

The description of an OpenSheaf document version is a datacite.json (section 8 of the specification). For a paper imported from arXiv it is made from arXiv's OAI record and from the abstract page of each version, both downloaded as described in the previous chapter. This chapter reads the record and converts its fields: the names of the authors, the title and the abstract, written by the authors in TeX, and the licences. The next chapter puts the pieces together into the files of each version.

Reading the record

The record is XML in two namespaces: that of OAI-PMH, for the envelope, and that of the arXivRaw format, for the metadata.

NS = {"o": "http://www.openarchives.org/OAI/2.0/", "r": "http://arxiv.org/OAI/arXivRaw/"}

record returns the fields we use as a dictionary. Text fields are returned with their white space normalised, since arXiv folds long lines. The abstract is the exception: it keeps its line breaks, which separate paragraphs, but loses the spaces around them. The dates of the versions are written as in e-mail (Mon, 3 Apr 2023 17:59:58 GMT) and become ISO 8601 in UTC. The authors are one string, A. Author, B. Author and C. Author, split at the commas and at the word "and". The field doi holds the DOIs of the published article, if the authors gave any, separated by spaces; journal-ref is the reference of that article as text.

def record(folder):
    root = ET.parse(folder / "record.xml").getroot()
    r = root.find(".//r:arXivRaw", NS)
    if r is None:
        sys.exit(f"{folder}: no arXivRaw record")

    def text(tag):
        e = r.find(f"r:{tag}", NS)
        return " ".join(e.text.split()) if e is not None and e.text else None

    versions = []
    for v in r.findall("r:version", NS):
        date = email.utils.parsedate_to_datetime(v.find("r:date", NS).text)
        versions.append({"v": v.get("version"), "date": date.strftime("%Y-%m-%dT%H:%M:%SZ"),
                         "source_type": (v.findtext("r:source_type", default="", namespaces=NS) or "").strip()})
    authors = re.split(r",\s*|\s+and\s+", text("authors"))
    abstract = r.find("r:abstract", NS).text.strip()
    return {"id": text("id"), "title": text("title"), "authors": [detex(a) for a in authors if a.strip()],
            "abstract": re.sub(r"[ \t]*\n[ \t]*", "\n", abstract), "license": text("license"),
            "doi": text("doi"), "journal": text("journal-ref"), "categories": (text("categories") or "").split(),
            "comments": text("comments"), "versions": versions}

Names

arXiv keeps the names of authors as the authors typed them, often in TeX: G\"unter, Ga\'{e}tan, \v{S}imon. DataCite wants Unicode. An accent command becomes the corresponding combining character after the letter, and Unicode normalisation (NFC) merges the two into one character where one exists.

ACCENTS = {"'": "\u0301", "`": "\u0300", '"': "\u0308", "^": "\u0302", "~": "\u0303",
           "c": "\u0327", "v": "\u030c", "u": "\u0306", "H": "\u030b", "=": "\u0304"}

The accents written with a symbol (\'e, \"{u}) may omit the braces; those written with a letter (\c{c}, \v{s}) need them, or \v s would be confused with a command. Letters that are commands of their own, such as \o, \l or \ss, are converted by tex_text of the core, which does the same for titles of documents deposited from TeX. What is left of TeX grouping disappears, and a tie ~ is a space.

def detex(s):
    """Plain text of a name written with TeX accents (G\\"unter -> Günter)."""
    s = re.sub(r"\\([\'`\"^~=])\s*\{?([A-Za-z])\}?", lambda m: unicodedata.normalize(
        "NFC", m.group(2) + ACCENTS[m.group(1)]), s)
    s = re.sub(r"\\([cvuH])\s*\{([A-Za-z])\}", lambda m: unicodedata.normalize(
        "NFC", m.group(2) + ACCENTS[m.group(1)]), s)
    s = P.tex_text(s)  # letters such as \\l, \\o, {\\ss}
    s = s.replace("{", "").replace("}", "").replace("~", " ")
    return " ".join(s.split())

DataCite wants each creator's family and given names apart. arXiv gives only the whole name, in the usual order, so we take the last word as the family name, together with the particles before it (de, van, von, di and so on), and the rest as the given names. Massimiliano Gubinelli and Johannes van der Waals come out right; a double family name without particle, as in Maria Gómez García, does not. A wrong split shows only in how the name is sorted and cited, and can be corrected by a description- only version.

def creator(name):
    words = name.split()
    # a family name with a particle (de, van, von, di, ...) keeps it
    i = len(words) - 1
    while i > 0 and words[i - 1].lower() in ("de", "van", "von", "der", "di", "da", "del", "la", "le"):
        i -= 1
    family, given = " ".join(words[i:]), " ".join(words[:i])
    c = {"name": f"{family}, {given}" if given else family, "nameType": "Personal", "familyName": family}
    if given:
        c["givenName"] = given
    return c

Titles and abstracts

Titles and abstracts on arXiv are TeX with a lot of freedom: mathematics between $...$, $$...$$, \(...\) or \[...\]; accents and quotes written in TeX; and, in the mathematics, the authors' own macros, such as \R for the real numbers or \eps for epsilon, which arXiv displays unexpanded. Section 8 of the specification asks for plain text with mathematics in LaTeX between single dollar signs, using only the standard commands, so that a renderer such as MathJax displays it without the document's preamble. It also asks importers to replace the authors' macros by their definitions when these are known, from the preamble of the imported sources, and never to invent definitions.

So clean_text first brings all mathematics between single dollars, then treats text and mathematics separately. In the text, accents become Unicode, TeX quotes become typographic quotes, \& becomes &, and braces around plain words are dropped. In the mathematics, each command that is a macro of the authors, defined in the preamble and not a standard command, is replaced by its definition, in braces so that \R^2 stays {\mathbb{R}}^2. Definitions may use other macros, so the replacement is repeated, at most four times, which also stops a macro defined in terms of itself.

def clean_text(s, macros=None):
    """Plain text with mathematics in $...$ (section 8): TeX accents and quotes
    outside mathematics become Unicode; inside mathematics, the authors'
    macros without arguments are replaced by their definitions when known."""
    # mathematics is written between single dollar signs only (section 8)
    s = re.sub(r"\$\$(.+?)\$\$", r"$\1$", s, flags=re.S)
    s = re.sub(r"\\\[(.+?)\\\]", lambda m: "$" + m.group(1).strip() + "$", s, flags=re.S)
    s = re.sub(r"\\\((.+?)\\\)", lambda m: "$" + m.group(1).strip() + "$", s, flags=re.S)
    out = []
    for is_math, part in split_math(s):
        if not is_math:
            part = P.tex_text(part).replace("``", "\u201c").replace("''", "\u201d").replace("\\&", "&")
            part = re.sub(r"\{([^{}\\$]*)\}", r"\1", part)
        elif macros:
            for _ in range(4):
                new = re.sub(r"\\([A-Za-z]+)(?![A-Za-z])",
                             lambda m: ("{" + macros[m.group(1)] + "}") if m.group(1) in macros
                             and m.group(1) not in STANDARD else m.group(0), part)
                if new == part:
                    break
                part = new
        out.append(("$" + part + "$") if is_math else part)
    return "".join(out)

Text and mathematics alternate at the dollar signs not preceded by a backslash: in the list of parts, the odd ones are mathematics.

def split_math(s):
    """Text and mathematics of a title or abstract: [(is_math, part)]."""
    parts = re.split(r"(?<!\\)\$", s)
    return [(i % 2 == 1, p) for i, p in enumerate(parts)]

The definitions come from the preamble of the main TeX file of the same arXiv version (the next chapter finds it). We read only definitions without arguments, the common case in titles and abstracts: those of \newcommand and \renewcommand, starred or not, with or without braces around the name, and those of \def. A \newcommand with a number of arguments is skipped; if a name is defined both ways, the \newcommand wins.

def preamble_macros(tex):
    """Definitions without arguments, \\newcommand{\\R}{\\mathbb{R}} or
    \\def\\R{...}, from the preamble of a LaTeX source."""
    pre = tex.split("\\begin{document}", 1)[0]
    out = {}
    for m in re.finditer(r"\\(?:re)?newcommand\*?\s*\{?\\([A-Za-z]+)\}?\s*(\[\d\])?\s*\{", pre):
        if m.group(2):
            continue
        body = balanced(pre, m.end() - 1)
        if body is not None:
            out[m.group(1)] = body
    for m in re.finditer(r"\\def\s*\\([A-Za-z]+)\s*\{", pre):
        body = balanced(pre, m.end() - 1)
        if body is not None:
            out.setdefault(m.group(1), body)
    return out

A definition is the text between a brace and the brace that closes it, counting the braces not escaped by a backslash. A definition longer than 2000 characters is not something to put in an abstract, and we give up.

def balanced(s, i):
    """The contents of the braces opening at s[i]."""
    depth = 0
    for j in range(i, min(len(s), i + 2000)):
        if s[j] == "{" and (j == 0 or s[j - 1] != "\\"):
            depth += 1
        elif s[j] == "}" and s[j - 1] != "\\":
            depth -= 1
            if depth == 0:
                return s[i + 1:j]
    return None

Some authors define macros with the names of standard commands, for instance \renewcommand{\phi}{\varphi}. Expanding those would change what every reader of the abstract sees; we leave them alone, and keep a list of the commands that MathJax and KaTeX know anyway. The list is not complete: it only has to contain the commands that authors are likely to redefine, and those that should not be reported as unknown.

STANDARD = set("""alpha beta gamma delta epsilon varepsilon zeta eta theta vartheta iota kappa lambda mu nu xi
pi varpi rho varrho sigma varsigma tau upsilon phi varphi chi psi omega Gamma Delta Theta Lambda Xi Pi Sigma
Upsilon Phi Psi Omega mathbb mathcal mathfrak mathrm mathbf mathit mathsf mathtt operatorname text textrm
frac dfrac tfrac sqrt sum prod int oint iint lim limsup liminf sup inf max min exp log ln sin cos tan sinh cosh
partial nabla infty cdot cdots ldots dots times otimes oplus leq geq le ge neq approx sim simeq equiv to mapsto
in notin subset subseteq supset cup cap setminus emptyset forall exists left right big Big bigg Bigg langle rangle
lfloor rfloor lceil rceil hat tilde bar vec dot ddot overline underline widehat widetilde quad qquad prime ast
star circ bullet pm mp div mid parallel perp lesssim gtrsim ll gg ell hbar Re Im wedge vee neg det dim ker deg
varnothing alpha_ operatorname* boldsymbol displaystyle textstyle mathscr llbracket rrbracket
rightarrow leftarrow Rightarrow Leftarrow longrightarrow leftrightarrow hookrightarrow uparrow downarrow
geqslant leqslant bot top vartriangle triangle square diamond angle backslash colon vert Vert lvert rvert
lVert rVert binom choose mathring check breve acute grave over not neq ne propto cong asymp nearrow searrow""".split())

The commands left in the mathematics that are not standard are macros whose definition was not found: in a PDF-only version, in a file included by the main one, or defined with arguments. They are kept, as the specification asks, and reported, so that the depositor can correct the abstract by hand if needed.

def unknown_macros(s):
    return sorted({m for is_math, part in split_math(s) if is_math
                   for m in re.findall(r"\\([A-Za-z]+)", part) if m not in STANDARD})

Licences

Every arXiv version carries a licence, chosen by the authors when they submit it, and named by a URI: one of arXiv's own licences or a Creative Commons licence. Section 8 of the specification records a licence with an SPDX identifier by that identifier, in lower case, and the URL; a licence without one, such as arXiv's, by its name and the URI arXiv uses for it, so that all archives record the same licence the same way. The table below holds the licences arXiv offers today, and the one it assumes for old submissions; the URIs are written as arXiv writes them, with http.

LICENCES = {
    "http://arxiv.org/licenses/assumed-1991-2003/":
        {"rights": "arXiv.org non-exclusive license to distribute, assumed for submissions of 1991-2003; "
                   "all other rights reserved to the authors"},
    "http://arxiv.org/licenses/nonexclusive-distrib/1.0/":
        {"rights": "arXiv.org perpetual, non-exclusive license to distribute; "
                   "all other rights reserved to the authors"},
    "http://creativecommons.org/licenses/by/4.0/":
        {"rights": "Creative Commons Attribution 4.0 International", "rightsIdentifier": "cc-by-4.0"},
    "http://creativecommons.org/licenses/by-sa/4.0/":
        {"rights": "Creative Commons Attribution Share Alike 4.0 International",
         "rightsIdentifier": "cc-by-sa-4.0"},
    "http://creativecommons.org/licenses/by-nc-sa/4.0/":
        {"rights": "Creative Commons Attribution Non Commercial Share Alike 4.0 International",
         "rightsIdentifier": "cc-by-nc-sa-4.0"},
    "http://creativecommons.org/licenses/by-nc-nd/4.0/":
        {"rights": "Creative Commons Attribution Non Commercial No Derivatives 4.0 International",
         "rightsIdentifier": "cc-by-nc-nd-4.0"},
    "http://creativecommons.org/publicdomain/zero/1.0/":
        {"rights": "Creative Commons Zero v1.0 Universal", "rightsIdentifier": "cc0-1.0"},
}

A version without a licence gets arXiv's default, the non-exclusive licence to distribute. A licence not in the table is recorded under its URI, which then serves as its name too.

def rights_of(uri):
    """The DataCite rights entry of a licence URI."""
    uri = uri or "http://arxiv.org/licenses/nonexclusive-distrib/1.0/"
    r = dict(LICENCES.get(uri, {"rights": uri}), rightsUri=uri)
    if "rightsIdentifier" in r:
        r["rightsIdentifierScheme"] = "SPDX"
    return r