unknown
Claude Opus 5.5
Journal Formatter: footnotes, web references, books and reports, author marks, cleaner LaTeX
2eb3609
Raw History Blame Contribute Delete
47.9 kB
"""Reference parsing, reference-list styling and in-text citation conversion."""
from __future__ import annotations
import copy
import re
import unicodedata
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from . import model as M
from .model import Run
DOI_RE = re.compile(r"(?:https?://(?:dx\.)?doi\.org/|doi:\s*|DOI:\s*)?(10\.\d{4,9}/[^\s,;]+)", re.I)
URL_RE = re.compile(r"https?://\S+")
YEAR_RE = re.compile(r"\b((?:19|20)\d{2})([a-z])?\b")
LABEL_RE = re.compile(r"^\s*(?:\[\d{1,3}\]|\(\d{1,3}\)|\d{1,3}\.(?=\s)|\d{1,3}\)(?=\s))\s*")
NAME = r"[A-ZÀ-ſ][\wÀ-ſ'’\-]+"
PARTICLE = r"(?:(?:van|von|der|de|da|del|di|la|le|du|den|ter|bin|al|el)\s+)*"
@dataclass
class Ref:
raw: str
authors: List[Tuple[str, str]] = field(default_factory=list) # (family, initials "A.B.")
etal: bool = False
year: str = ""
suffix: str = ""
title: str = ""
container: str = ""
volume: str = ""
issue: str = ""
pages: str = ""
doi: str = ""
url: str = ""
publisher: str = ""
kind: str = "journal" # journal | conference | book | report | other
edition: str = "" # books: "7th"
place: str = "" # books / reports: "Hoboken, NJ, USA"
confident: bool = True
old_num: Optional[int] = None
@property
def first_family(self) -> str:
return self.authors[0][0] if self.authors else ""
def _fold(s: str) -> str:
return "".join(c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c)).lower()
# ------------------------------------------------------------------ author parsing
def _initials(given: str) -> str:
parts = re.split(r"[\s.]+", given.strip())
out = []
for p in parts:
if not p:
continue
if "-" in p:
out.append("-".join(x[:1].upper() + "." for x in p.split("-") if x))
elif p.isupper() and len(p) <= 3 and len(parts) == 1: # Vancouver "AB"
out.extend(ch + "." for ch in p)
else:
out.append(p[0].upper() + ".")
return "".join(out)
def parse_authors(s: str) -> Tuple[List[Tuple[str, str]], bool]:
s = s.strip().rstrip(".,:;")
etal = bool(re.search(r"\bet\s+al\.?", s))
s = re.sub(r",?\s*\bet\s+al\.?", "", s)
s = s.replace("&", ",").replace(" and ", ", ").replace(";", ",")
s = re.sub(r"\s+", " ", s).strip(" ,")
if not s:
return [], etal
if re.search(r"\b(?:organi[sz]ation|association|institute|society|council|ministry|agency|committee|"
r"commission|bureau|office|group|consortium|foundation|nations|union|board|authority|"
r"corporation|inc|ltd|university)\b", s, re.I) and not re.search(r",\s*[A-Z]\.", s):
return [(s, "")], etal
cands = []
# APA/Harvard: Smith, A. B.
apa = re.findall(r"(" + PARTICLE + NAME + r"(?:\s" + NAME + r")?),\s*((?:[A-Z][a-z]?\.?\s?-?\s?){1,4})(?=,|$)", s)
cands.append([(f, _initials(g)) for f, g in apa])
# IEEE/Elsevier: A. B. Smith
ieee = re.findall(r"((?:[A-Z][a-z]?\.\s?-?){1,4})\s*(" + PARTICLE + NAME + r"(?:\s" + NAME + r")?)(?=,|$)", s)
cands.append([(f, _initials(g)) for g, f in ieee])
# Vancouver/Springer: Smith AB
van = re.findall(r"(" + PARTICLE + NAME + r"(?:\s" + NAME + r")?)\s([A-Z]{1,4})(?=,|$)", s)
cands.append([(f, _initials(g)) for f, g in van])
# Full names: Ravi Kumar
parts = [p.strip() for p in s.split(",") if p.strip()]
full = []
for p in parts:
w = p.split()
if 2 <= len(w) <= 4 and all(re.match(r"[A-ZÀ-ſ]", x) for x in w):
full.append((w[-1], _initials(" ".join(w[:-1]))))
cands.append(full)
# Chicago / MLA: "Family, Given, Given Family, and Given Family"
chi = []
m = re.match(r"^(" + PARTICLE + NAME + r"),\s*((?:" + NAME + r"|[A-Z]\.)(?:\s(?:" + NAME + r"|[A-Z]\.))*)(?:,|$)", s)
if m:
chi.append((m.group(1), _initials(m.group(2))))
for part in [x.strip() for x in s[m.end():].split(",") if x.strip()]:
w = part.split()
if 2 <= len(w) <= 4 and all(re.match(r"[A-Z\u00C0-\u017F]", x) for x in w):
chi.append((w[-1], _initials(" ".join(w[:-1]))))
else:
chi = []
break
cands.append(chi)
best = max(cands, key=len)
# Single "Surname, Given Names" (APA-style with full given name)
if not best:
m = re.match(r"^(" + NAME + r"),\s*(" + NAME + r"(?:\s" + NAME + r")*)$", s)
if m:
best = [(m.group(1), _initials(m.group(2)))]
elif re.fullmatch(NAME + r"(?:\s" + NAME + r")*", s):
best = [(s, "")] # organisation as author
return best, etal
# ------------------------------------------------------------------ container parsing
def _parse_container(rest: str, ref: Ref):
r = rest.strip()
r = re.sub(r"\s*(?:Retrieved from|Available (?:at|online)).*$", "", r, flags=re.I)
m = re.search(r"\bvol(?:ume)?\.?\s*(\d+)", r, re.I)
if m:
ref.volume = m.group(1)
m2 = re.search(r"\b(?:no|issue|iss)\.?\s*(\d+[A-Za-z]?(?:[-–/]\d+)?)", r, re.I)
if m2:
ref.issue = m2.group(1)
m3 = re.search(r"\bpp?\.?\s*(e?\d+\s*[-–—]\s*e?\d+|e?\d+)", r)
if m3:
ref.pages = m3.group(1).replace(" ", "")
if not ref.pages:
mp = re.search(r"[,:]\s*(e?\d+\s*[-–—]\s*e?\d+)\s*\.?\s*$", r)
if mp:
ref.pages = mp.group(1).replace(" ", "")
if not ref.volume:
m = re.search(r"(?<![\d.])(\d{1,4})\s*\((\d+[A-Za-z]?(?:[-–/]\d+)?)\)\s*[:,]?\s*(e?\d+(?:\s*[-–—]\s*e?\d+)?)?", r)
if m:
ref.volume, ref.issue = m.group(1), m.group(2)
if m.group(3) and not ref.pages:
ref.pages = m.group(3).replace(" ", "")
else:
m = re.search(r"(?<![\d.])(\d{1,4})\s*:\s*(e?\d+(?:\s*[-–—]\s*e?\d+)?)", r)
if m:
ref.volume, ref.pages = m.group(1), ref.pages or m.group(2).replace(" ", "")
else:
m = re.search(r",\s*(\d{1,4})\s*,\s*(e?\d+(?:\s*[-–—]\s*e?\d+)?)\s*\.?\s*$", r)
if m:
ref.volume, ref.pages = m.group(1), ref.pages or m.group(2).replace(" ", "")
# container name: text up to first volume/number marker
cut = re.search(r",?\s*(?:\bvol\.|\bvolume\b|\bno\.|\bpp?\.|(?<![\w.])\d{1,4}\s*[(:,]|\d{4}\s*[;,]|(?<![\w.])\d{1,4}\s*$|(?<![\w.])e?\d+\s*[-–—]\s*e?\d+)", r, re.I)
name = r[: cut.start()] if cut else r
name = re.sub(r"^\s*(?:In:?|in)\s+", "", name).strip(" .,;:")
name = YEAR_RE.sub("", name).strip(" .,;:()")
ref.container = name
if re.search(r"proceedings|conference|symposium|workshop|\bconf\.|\bproc\.", name, re.I):
ref.kind = "conference"
if ref.pages:
ref.pages = re.sub(r"\s*[-–—]\s*", "–", ref.pages)
def _split_title(rest: str) -> Tuple[str, str]:
"""Title is the first sentence of `rest` (keep ?/! endings), or the quoted string."""
q = re.match(r"^\s*[\"“‘](.+?)[,.]?[\"”’]\s*[,.:]?\s*(.*)$", rest)
if q:
return q.group(1).strip(), q.group(2)
m = re.match(r"^\s*[\"“'‘]?(.+?)[\"”'’]?([.?!])[\"”'’]?\s+(.*)$", rest)
if not m:
return rest.strip(" ."), ""
title = m.group(1)
if m.group(2) in "?!":
title += m.group(2)
return title.strip(), m.group(3)
def _author_like(a: str) -> bool:
"""True when `a` is (almost) entirely author names."""
if re.search(r"\b(?:19|20)\d{2}\b|[“”\"]", a):
return False
auths, _ = parse_authors(a)
if not auths:
return False
fam_words = {w.lower() for f, _ in auths for w in f.split()}
words = re.findall(r"[A-Za-z\u00C0-\u017F'’\-]+", a)
unexplained = [w for w in words if len(w.rstrip(".")) > 2 and w.lower() not in fam_words
and w.lower() not in ("and", "et", "al", "eds", "ed")]
# given names written out in full are explained by the parsed initials
ini = {ch for _, i in auths for ch in i if ch.isalpha()}
unexplained = [w for w in unexplained if not (w[0].isupper() and w[0] in ini)]
return len(unexplained) <= max(1, len(words) // 5)
_EL_NAME = re.compile(r"^(?:and\s+)?((?:[A-Z][a-z]?\.\s?-?){1,4})\s*(" + PARTICLE + NAME + r"(?:[\s-]" + NAME + r")?)$")
_EL_TAIL = re.compile(r"^(?P<c>.+?)\s+(?P<v>\d{1,4})\s*(?:\((?P<i>[^()]{1,12})\)\s*)?\((?P<y>(?:19|20)\d{2})\)\s*"
r"(?P<p>[A-Za-z]?\d+(?:\s*[-–—]\s*[A-Za-z]?\d+)?)?\.?$")
def _elsevier_numbered(text: str):
"""(authors, title, journal, volume, issue, year, pages) for an entry written the Elsevier numbered way:
initials-first authors, then the title, then "Journal 104 (3) (2019) 367–378", all separated by commas.
None when the entry is not written that way (books, reports, web pages are left to the other readers)."""
parts = [p.strip() for p in text.split(", ")]
authors = []
for p in parts:
m = _EL_NAME.match(p)
if not m:
break
authors.append((m.group(2), _initials(m.group(1))))
if not authors or len(parts) < len(authors) + 2:
return None
rest = parts[len(authors):]
# the journal part is the last piece (journal names with a comma in them: try the last two as well)
for take in (1, 2):
if len(rest) <= take:
break
m = _EL_TAIL.match(", ".join(rest[-take:]))
if m and not re.search(r"\d", m.group("c")):
title = ", ".join(rest[:-take]).strip()
if len(title) < 8:
return None
return (authors, title, m.group("c").strip(), m.group("v"), m.group("i") or "", m.group("y"),
(m.group("p") or "").replace(" ", ""))
return None
def parse_reference(raw: str) -> Ref:
text = re.sub(r"\s+", " ", raw).strip()
ref = Ref(raw=text)
lm = LABEL_RE.match(text)
if lm:
n = re.search(r"\d+", lm.group(0))
ref.old_num = int(n.group(0)) if n else None
text = text[lm.end():]
dm = DOI_RE.search(text)
if dm:
ref.doi = dm.group(1).rstrip(".")
text = (text[: dm.start()] + text[dm.end():]).strip()
um = URL_RE.search(text)
if um:
ref.url = um.group(0).rstrip(".")
text = (text[: um.start()] + text[um.end():]).strip()
text = re.sub(r"\s*(?:\[?Online\]?\.?|Accessed:?.*$|\bdoi:?\s*$)", " ", text, flags=re.I).strip(" .,")
text = re.sub(r"\s*\b(?:Available(?:\s+(?:at|online|from))?|Retrieved(?:\s+\w+\s+\d{1,2},\s*\d{4},)?\s+from)\s*:?\s*$",
"", text, flags=re.I).strip(" .,")
bk = _ieee_book(text) or _org_report(text)
if bk:
for k, v in bk.items():
setattr(ref, k, v)
return _finish(ref)
# Elsevier numbered: A.B. Smith, C. Lee, Title of the paper, Journal Name 104 (3) (2019) 367–378.
el = _elsevier_numbered(text)
if el:
ref.authors, ref.title, ref.container, ref.volume, ref.issue, ref.year, ref.pages = el
ref.pages = re.sub(r"\s*[-–—]\s*", "–", ref.pages)
if re.search(r"proceedings|conference|symposium|workshop", ref.container, re.I):
ref.kind = "conference"
return _finish(ref)
# IEEE: Authors, “Title,” rest
m = re.match(r"^(?P<a>.+?),?\s*[\"“](?P<t>.+?)[,.]?[\"”],?\s*(?P<r>.*)$", text)
if m and len(m.group("a")) < 400 and _author_like(m.group("a")):
ref.authors, ref.etal = parse_authors(m.group("a"))
ref.title = m.group("t").strip(" ,.")
rest = re.sub(r"^\s*in\s+", "", m.group("r"))
y = YEAR_RE.findall(rest)
if y:
ref.year, ref.suffix = y[-1]
_parse_container(rest, ref)
return _finish(ref)
# Chicago author-date: Authors. 2017. “Title.” Journal 19 (3): 1–30.
m = re.match(r"^(?P<a>.+?)\.\s+(?P<y>(?:19|20)\d{2})(?P<s>[a-z])?\.\s+[\"“](?P<t>.+?)[.,]?[\"”]\s*(?P<r>.*)$", text)
if m and _author_like(m.group("a")):
ref.authors, ref.etal = parse_authors(m.group("a"))
ref.year, ref.suffix = m.group("y"), m.group("s") or ""
ref.title = m.group("t").strip(" .,")
rest = re.sub(r"^\s*In\s+", "", m.group("r"))
if not _as_book(ref, rest):
_parse_container(rest, ref)
return _finish(ref)
# LNCS: Authors: Title. Journal 9(2), 11–20 (2020)
m = re.match(r"^(?P<a>[^:]{3,300}?):\s+(?P<r>.+)$", text)
if m and _author_like(m.group("a")):
ref.authors, ref.etal = parse_authors(m.group("a"))
ref.title, rest = _split_title(m.group("r"))
y = YEAR_RE.findall(rest)
if y:
ref.year, ref.suffix = y[-1]
_parse_container(rest, ref)
return _finish(ref)
# APA/Harvard: Authors (2020). Title. rest | Springer: Authors (2020) Title. rest
m = re.match(r"^(?P<a>.+?)\s*\((?P<y>(?:19|20)\d{2})(?P<s>[a-z])?[^)]*\)[.,]?\s*(?P<r>.*)$", text)
if m and len(m.group("a")) < 500 and _author_like(m.group("a")):
ref.authors, ref.etal = parse_authors(m.group("a"))
ref.year, ref.suffix = m.group("y"), m.group("s") or ""
ref.title, rest = _split_title(m.group("r"))
if not _as_book(ref, rest):
_parse_container(rest, ref)
return _finish(ref)
# Harvard without parentheses: Smith, J. 2020. Title.
m = re.match(r"^(?P<a>.+?)[,.]?\s+(?P<y>(?:19|20)\d{2})(?P<s>[a-z])?[.,]\s+(?P<r>.*)$", text)
if m and len(m.group("a")) < 300 and _author_like(m.group("a")):
ref.authors, ref.etal = parse_authors(m.group("a"))
ref.year, ref.suffix = m.group("y"), m.group("s") or ""
ref.title, rest = _split_title(m.group("r"))
if not _as_book(ref, rest):
_parse_container(rest, ref)
return _finish(ref)
# Vancouver / MDPI: Authors. Title. Journal. 2020;9(2):11-20. | Smith, A.; Lee, B. Title. J. 2020, 9, 1.
m = re.match(r"^(?P<a>(?:[^.]|(?<=\b[A-Z])\.)+?)\.\s+(?P<r>.+)$", text)
if m:
a = m.group("a")
auths, etal = parse_authors(a)
if auths and _author_like(a):
ref.authors, ref.etal = auths, etal
ref.title, rest = _split_title(m.group("r"))
y = YEAR_RE.findall(rest)
if y:
ref.year, ref.suffix = y[0]
if not _as_book(ref, re.sub(r"[;,]?\s*(?:19|20)\d{2}[a-z]?\.?\s*$", "", rest)):
_parse_container(rest, ref)
return _finish(ref)
ref.confident = False
y = YEAR_RE.findall(text)
if y:
ref.year, ref.suffix = y[0]
ref.title = text
return ref
_NOT_CONTENT = {"and", "vol", "volume", "issue", "pages", "eds", "doi", "online", "accessed", "available",
"retrieved", "from", "http", "https", "www", "org"}
def _words(s: str) -> set:
return {w for w in re.findall(r"[a-zà-ſ]{3,}", _fold(s))} - _NOT_CONTENT
def lost_words(ref: Ref) -> set:
"""Words of the author's reference that are in none of the parsed fields. A parse that loses a surname or a
title word has misread the entry, and formatting from it would print a wrong reference."""
raw = LABEL_RE.sub("", ref.raw)
raw = URL_RE.sub(" ", DOI_RE.sub(" ", raw))
kept = " ".join([f for f, _ in ref.authors] + [ref.title, ref.container, ref.publisher])
return _words(raw) - _words(kept)
ED_RE = re.compile(r"\(?\s*(\d+(?:st|nd|rd|th)|[Ss]econd|[Tt]hird|[Ff]ourth|[Ff]ifth)\s+(?:edn?|edition)\.?\s*\)?", re.I)
PUBLISHER_RE = re.compile(
r"\b(?:Wiley|Springer|Elsevier|Press|Publish(?:ing|ers?)|McGraw|Pearson|CRC|Routledge|SAGE|Sage|Prentice|"
r"Addison|Cengage|Academic|Taylor|Francis|Palgrave|Macmillan|Norton|Penguin|Wolters|Kluwer|Thieme|IntechOpen|"
r"IGI|Butterworth|Heinemann|Dover|Artech|Morgan Kaufmann|O'Reilly|Packt|Apress|De Gruyter|Brill|Cambridge "
r"University|Oxford University|MIT|Princeton|Yale|Harvard University|World Scientific|Woodhead|Hanser)\b")
_IEEE_AUTH = r"(?:(?:[A-Z][a-z]?\.\s?-?){1,4}\s*" + PARTICLE + NAME + r"(?:\s" + NAME + r")?)"
_ORG_RE = re.compile(r"\b(?:Organi[sz]ation|Association|Institute|Society|Council|Ministry|Agency|Committee|"
r"Commission|Bureau|Office|Foundation|Board|Authority|Union|Nations|University|Department|"
r"Standards?|Centre|Center)\b")
def _edition(s: str):
"""("7th", text without the edition) — "(7th ed.)", "7th edn", "2nd edition"."""
m = ED_RE.search(s or "")
if not m:
return "", s
word = {"second": "2nd", "third": "3rd", "fourth": "4th", "fifth": "5th"}.get(m.group(1).lower(), m.group(1))
return word, (s[: m.start()] + s[m.end():]).strip(" .,;")
def _ieee_book(text: str):
"""T. L. Bergman, A. S. Lavine, and D. P. DeWitt, Fundamentals of Heat, 7th ed. Hoboken, NJ, USA: Wiley, 2011."""
m = re.match(r"^(?P<a>" + _IEEE_AUTH + r"(?:,\s+(?:and\s+)?" + _IEEE_AUTH + r")*(?:,?\s+and\s+" + _IEEE_AUTH +
r")?(?:,?\s+et\s+al\.)?),\s+(?P<t>[^“”\"]+?),\s+(?:(?P<ed>\d+(?:st|nd|rd|th)\s+ed\.)\s+)?"
r"(?P<pl>[^:]{2,80}):\s*(?P<pub>[^,:]{2,100}),\s*(?P<y>(?:19|20)\d{2})(?P<s>[a-z])?\.?$", text)
if not m:
return None
auths, etal = parse_authors(m.group("a"))
if not auths:
return None
return dict(authors=auths, etal=etal, title=m.group("t").strip(), edition=_edition(m.group("ed") or "")[0],
place=m.group("pl").strip(), publisher=m.group("pub").strip(), year=m.group("y"),
suffix=m.group("s") or "", kind="book")
def _org_report(text: str):
"""International Organization for Standardization, ISO 12405-4:2018 Title ..., Geneva, 2018."""
m = re.match(r"^(?P<o>[^,]{6,140}),\s+(?P<t>.{6,}?)(?:,\s*(?P<pl>[A-Z][A-Za-z .\-]{2,40}))?,\s*"
r"(?P<y>(?:19|20)\d{2})(?P<s>[a-z])?\.?$", text)
if not m or not _ORG_RE.search(m.group("o")) or re.search(r"\b[A-Z]\.\s?[A-Z]", m.group("o")) or \
re.search(r"[“”\"]", m.group("t")):
return None
return dict(authors=[(m.group("o").strip(), "")], title=m.group("t").strip(), place=(m.group("pl") or "").strip(),
year=m.group("y"), suffix=m.group("s") or "", kind="report")
def _as_book(ref: Ref, rest: str) -> bool:
"""APA / Harvard / Chicago / Vancouver books: an edition, or a publisher where a journal would be, and nothing
that only articles have (volume, issue, pages)."""
ed, title = _edition(ref.title)
r = (rest or "").strip(" .")
ed2, r = _edition(r) if not ed else (ed, r)
ed = ed or ed2
m = re.match(r"^(?:(?P<pl>[A-Z][^:;]{1,60}):\s*)?(?P<pub>[^.;:]{2,100}?)(?:;\s*(?P<y>(?:19|20)\d{2}))?\.?$", r)
if not m or re.search(r"\d", m.group("pub")) or re.search(r"\b(?:Journal|Review|Proceedings|Transactions|Letters|"
r"Bulletin|Annals|Conference|Quarterly)\b", r):
return False
if not (ed or PUBLISHER_RE.search(m.group("pub"))):
return False
ref.title, ref.edition, ref.kind = title.strip(" ."), ed, "book"
ref.publisher, ref.place = m.group("pub").strip(), (m.group("pl") or "").strip()
if m.group("y") and not ref.year:
ref.year = m.group("y")
return True
def _finish(ref: Ref) -> Ref:
if not ref.authors or not ref.title or not ref.year:
ref.confident = False
# misread entries: a surname with a full stop or a digit in it, a title of one or two letters, a word lost
if (any(re.search(r"\.\s|\d", f) for f, _ in ref.authors) or len(ref.title.strip()) < 4 or lost_words(ref)):
ref.confident = False
if ref.kind == "journal" and not ref.container and not ref.volume:
ref.kind = "other"
if ref.kind in ("book", "report") and ref.authors and ref.title and ref.year:
ref.confident = not (len(ref.title.strip()) < 4)
return ref
def enrich_crossref(ref: Ref, timeout=6.0) -> bool:
"""Fill/verify metadata from Crossref by DOI (or by title search). Returns True when updated."""
try:
import requests
hdr = {"User-Agent": "FormatMatch/1.0 (mailto:info@cssoftwaresolutions.tech)"}
if ref.doi:
r = requests.get(f"https://api.crossref.org/works/{ref.doi}", headers=hdr, timeout=timeout)
if r.status_code != 200:
return False
it = r.json()["message"]
else:
q = ref.title if ref.title and len(ref.title) > 20 else ref.raw
r = requests.get("https://api.crossref.org/works", params={"query.bibliographic": q[:300], "rows": 1},
headers=hdr, timeout=timeout)
items = r.json()["message"]["items"]
if not items:
return False
it = items[0]
import difflib
t = (it.get("title") or [""])[0]
if difflib.SequenceMatcher(None, _fold(t), _fold(ref.title or ref.raw[:len(t) + 40])).ratio() < 0.75:
return False
auth = []
for a in it.get("author", []):
if a.get("family"):
auth.append((a["family"], _initials(a.get("given", ""))))
elif a.get("name"):
auth.append((a["name"], ""))
if auth:
ref.authors, ref.etal = auth, False
ref.title = (it.get("title") or [ref.title])[0]
ref.container = (it.get("container-title") or [ref.container])[0]
ref.volume = it.get("volume", ref.volume) or ref.volume
ref.issue = it.get("issue", ref.issue) or ref.issue
ref.pages = (it.get("page") or it.get("article-number") or ref.pages or "").replace("-", "–")
dp = (it.get("published-print") or it.get("published-online") or it.get("issued") or {}).get("date-parts")
if dp and dp[0] and dp[0][0]:
ref.year = str(dp[0][0])
ref.doi = it.get("DOI", ref.doi)
t = it.get("type", "")
ref.kind = "conference" if "proceedings" in t else ("book" if "book" in t else "journal")
ref.confident = True
return True
except Exception:
return False
# ------------------------------------------------------------------ name formatting helpers
def _ini(i: str, sep="") -> str:
"""'A.B.' -> 'A.B.' / 'A. B.' / 'AB'."""
if sep == "none":
return i.replace(".", "").replace("-", "")
if sep == " ":
return re.sub(r"\.(?=[A-Z])", ". ", i)
return i
def _join(names: List[str], last_sep: str, sep=", ") -> str:
if len(names) <= 1:
return "".join(names)
return sep.join(names[:-1]) + last_sep + names[-1]
def _authors(ref: Ref, style: str) -> str:
A = ref.authors
if not A:
return ""
if style == "ieee":
names = [f"{_ini(i, ' ')} {f}".strip() for f, i in A]
if len(names) > 6 or ref.etal:
return names[0] + " et al."
return _join(names, ", and " if len(names) > 2 else " and ")
if style == "apa":
names = [f"{f}, {_ini(i, ' ')}".strip(", ") for f, i in A]
if len(names) > 20:
names = names[:19] + ["…"] + names[-1:]
s = _join(names, ", & " if len(names) > 2 else ", & ")
return s + (", et al." if ref.etal else "")
if style in ("harvard", "emerald-harvard"):
names = [f"{f}, {_ini(i)}".strip(", ") for f, i in A]
return _join(names, " and ") + (" et al." if ref.etal else "")
if style in ("vancouver", "ama", "springer"):
names = [f"{f} {_ini(i, 'none')}".strip() for f, i in A]
limit = 6 if style == "vancouver" else (6 if style == "ama" else 99)
if len(names) > limit or ref.etal:
keep = 3 if style == "ama" else 6
return ", ".join(names[:keep]) + ", et al"
return ", ".join(names)
if style == "chicago":
first = f"{A[0][0]}, {_ini(A[0][1], ' ')}"
rest = [f"{_ini(i, ' ')} {f}" for f, i in A[1:]]
return _join([first] + rest, ", and " if len(A) > 2 else " and ")
if style == "lncs":
names = [f"{f}, {_ini(i)}" for f, i in A]
return ", ".join(names) + (", et al." if ref.etal else "")
if style == "mdpi":
return "; ".join(f"{f}, {_ini(i)}" for f, i in A) + ("; et al." if ref.etal else "")
if style == "elsevier":
names = [f"{_ini(i)} {f}".strip() for f, i in A]
return ", ".join(names) + (", et al." if ref.etal else "")
if style == "mla":
if len(A) == 1:
return f"{A[0][0]}, {_ini(A[0][1], ' ')}"
if len(A) == 2:
return f"{A[0][0]}, {_ini(A[0][1], ' ')}, and {_ini(A[1][1], ' ')} {A[1][0]}"
return f"{A[0][0]}, {_ini(A[0][1], ' ')}, et al"
return ", ".join(f for f, _ in A)
def _end(s: str, ch=".") -> str:
s = s.rstrip()
return s if not s or s[-1] in ".?!" else s + ch
def format_reference(ref: Ref, style: str, n: int) -> List[Run]:
"""Return runs (with italics/bold) for one reference entry in `style`."""
R = Run
if not ref.confident: # not read reliably: the author's own wording is kept
body = LABEL_RE.sub("", ref.raw)
return _with_label([R(body)], style, n)
doi = f"https://doi.org/{ref.doi}" if ref.doi else (ref.url or "")
t, c, v, i, p, y = ref.title, ref.container, ref.volume, ref.issue, ref.pages, ref.year + ref.suffix
out: List[Run] = []
if ref.kind in ("book", "report"):
out = _format_book(ref, style, y, doi)
if out:
out = _tidy([r for r in out if r.text])
return _with_label(out, style, n)
if style == "ieee":
out.append(R(f"{_authors(ref, 'ieee')}, “{t},” "))
if c:
out.append(R(("in " if ref.kind == "conference" else "")))
out.append(R(c, italic=True))
bits = []
if v: bits.append(f"vol. {v}")
if i: bits.append(f"no. {i}")
if p: bits.append(("pp. " if "–" in p else "Art. no. " if p.startswith("e") or len(p) > 5 else "p. ") + p)
if y: bits.append(y)
out.append(R((", " if c else "") + ", ".join(bits)))
if ref.doi:
out.append(R(f", doi: {ref.doi}"))
out.append(R("."))
elif style == "apa":
out.append(R(f"{_end(_authors(ref, 'apa'))} ({y or 'n.d.'}). {_end(t)} ")) # "Battery University. (2023)."
if c:
out.append(R(c, italic=True))
if v:
out.append(R(", "))
out.append(R(v, italic=True))
if i:
out.append(R(f"({i})"))
if p:
out.append(R(f", {p}"))
out.append(R("."))
if doi:
out.append(R(f" {doi}"))
elif style == "harvard":
out.append(R(f"{_authors(ref, 'harvard')} ({y}) ‘{t}’" + (", " if c else "")))
if c:
out.append(R(c, italic=True))
bits = []
if v: bits.append(v + (f"({i})" if i else ""))
if p: bits.append(f"pp. {p}" if "–" in p else f"p. {p}")
out.append(R((", " if bits else "") + ", ".join(bits) + ".")) # (no journal: ‘Title’. not ‘Title’, .)
if ref.doi:
out.append(R(f" doi: {ref.doi}."))
elif style == "emerald-harvard":
out.append(R(f"{_authors(ref, 'emerald-harvard')} ({y}), “{t}”" + (", " if c else "")))
if c:
out.append(R(c, italic=True))
bits = []
if v: bits.append(f"Vol. {v}" + (f" No. {i}" if i else ""))
if p: bits.append(f"pp. {p.replace('–', '-')}")
if ref.doi: bits.append(f"doi: {ref.doi}")
out.append(R((", " if bits else "") + ", ".join(bits) + "."))
elif style in ("vancouver", "ama"):
out.append(R(f"{_authors(ref, style)}. {_end(t)} "))
if c:
out.append(R(_end(c), italic=(style == "ama")))
loc = f" {y}" + (f";{v}" if v else "") + (f"({i})" if i else "") + (f":{p}" if p else "") + "."
out.append(R(loc))
if ref.doi:
out.append(R(f" doi:{ref.doi}"))
elif style == "chicago":
out.append(R(f"{_end(_authors(ref, 'chicago'))} {y}. “{_end(t)}” "))
if c:
out.append(R(c, italic=True))
out.append(R((f" {v}" if v else "") + (f" ({i})" if i else "") + (f": {p}" if p else "") + "."))
if doi:
out.append(R(f" {doi}."))
elif style in ("springer-basic", "springer-vancouver"):
out.append(R(f"{_authors(ref, 'springer')} ({y}) {_end(t)} "))
if c:
out.append(R(c))
out.append(R((f" {v}" if v else "") + (f"({i})" if i else "") + (f":{p}" if p else "")))
if doi:
out.append(R(f". {doi}"))
elif style == "lncs":
out.append(R(f"{_authors(ref, 'lncs')}: {_end(t)} "))
if c:
out.append(R(c))
loc = (f" {v}" if v else "") + (f"({i})" if i else "") + (f", {p}" if p else "") + (f" ({y})" if y else "")
out.append(R(loc + "."))
if doi:
out.append(R(f" {doi}"))
elif style == "mdpi":
out.append(R(f"{_authors(ref, 'mdpi')} {_end(t)} "))
if c:
out.append(R(c, italic=True))
if y:
out.append(R(" "))
out.append(R(y, bold=True))
if v:
out.append(R(", "))
out.append(R(v, italic=True))
if p:
out.append(R(f", {p}"))
out.append(R("."))
if doi:
out.append(R(f" {doi}."))
elif style == "elsevier-numeric":
out.append(R(f"{_authors(ref, 'elsevier')}, {t}, "))
if c:
out.append(R(c))
out.append(R((f" {v}" if v else "") + (f" ({y})" if y else "") + (f" {p}" if p else "") + "."))
if doi:
out.append(R(f" {doi}."))
elif style == "mla":
out.append(R(f"{_end(_authors(ref, 'mla'))} “{_end(t)}” "))
if c:
out.append(R(c, italic=True))
bits = []
if v: bits.append(f"vol. {v}")
if i: bits.append(f"no. {i}")
if y: bits.append(y)
if p: bits.append(f"pp. {p}")
out.append(R(", " + ", ".join(bits) + "." if bits else "."))
else:
out.append(R(ref.raw))
out = [r for r in out if r.text]
if ref.url and not ref.doi and ref.url not in "".join(r.text for r in out):
# web sources: each style's own wording (a URL was dropped by the styles that print only DOIs)
lead = {"ieee": " [Online]. Available: ", "harvard": " Available at: ", "emerald-harvard": " available at: ",
"vancouver": " Available from: ", "elsevier-numeric": " "}.get(style, " ")
if style == "emerald-harvard" and out and out[-1].text.endswith("."):
out[-1].text, lead = out[-1].text[:-1], ", available at: " # “Title”, available at: URL.
out.append(R(lead + ref.url + ("" if style == "ieee" else ".")))
out = _tidy(out)
return _with_label(out, style, n)
def _format_book(ref: Ref, style: str, y: str, doi: str) -> List[Run]:
"""Books and reports in each style (title in italics where the style wants it; edition and place / publisher in
the style's own order and wording)."""
R = Run
t, ed, pl, pub = ref.title, ref.edition, ref.place, ref.publisher
where = (f"{pl}: {pub}" if pl and pub else pub or pl)
ed_ = lambda w: f"{ed} {w}" if ed else ""
out: List[Run] = []
if style == "ieee":
out += [R(f"{_authors(ref, 'ieee')}, "), R(t, italic=True)]
out.append(R((f", {ed_('ed.')}" if ed else "") + ". " + (f"{where}, " if where else "") + f"{y}."))
elif style == "apa":
out += [R(f"{_end(_authors(ref, 'apa'))} ({y or 'n.d.'}). "), R(t, italic=True)]
out.append(R((f" ({ed_('ed.')})" if ed else "") + "." + (f" {pub}." if pub else "") + (f" {doi}" if doi else "")))
elif style == "harvard":
out += [R(f"{_authors(ref, 'harvard')} ({y}) "), R(t, italic=True)]
out.append(R(". " + (f"{ed_('edn.')} " if ed else "") + (f"{where}." if where else "")))
elif style == "emerald-harvard":
out += [R(f"{_authors(ref, 'emerald-harvard')} ({y}), "), R(t, italic=True)]
out.append(R((f", {ed_('ed.')}" if ed else "") + (f", {pub}" if pub else "") + (f", {pl}" if pl else "") + "."))
elif style in ("vancouver", "ama"):
out.append(R(f"{_authors(ref, style)}. "))
out.append(R(_end(t), italic=(style == "ama")))
out.append(R(" " + (f"{ed_('ed.')} " if ed else "") + (f"{where}; " if where else "") + f"{y}."))
elif style == "chicago":
out += [R(f"{_end(_authors(ref, 'chicago'))} {y}. "), R(_end(t), italic=True)]
out.append(R(" " + (f"{ed_('ed.')} " if ed else "") + (f"{where}." if where else "")))
elif style in ("springer-basic", "springer-vancouver"):
tail = ", ".join(x for x in (pub, pl) if x)
out.append(R(f"{_authors(ref, 'springer')} ({y}) {t.rstrip('.')}" + (f", {ed_('edn')}" if ed else "") + "."
+ (f" {tail}" if tail else "")))
elif style == "lncs":
out.append(R(f"{_authors(ref, 'lncs')}: {_end(t)} " + (f"{ed_('edn.')} " if ed else "")
+ ", ".join(x for x in (pub, pl) if x) + f" ({y})"))
elif style == "mdpi":
out += [R(f"{_authors(ref, 'mdpi')} "), R(t, italic=True)]
out.append(R((f", {ed_('ed.')}" if ed else "") + "; " + (f"{pub}: " if pub else "") + (f"{pl}, " if pl else "")
+ f"{y}."))
elif style == "elsevier-numeric":
out.append(R(f"{_authors(ref, 'elsevier')}, {t}" + (f", {ed_('ed.')}" if ed else "")
+ "".join(f", {x}" for x in (pub, pl) if x) + f", {y}."))
elif style == "mla":
out += [R(f"{_end(_authors(ref, 'mla'))} "), R(_end(t), italic=True)]
out.append(R(" " + (f"{ed_('ed.')}, " if ed else "") + (f"{pub}, " if pub else "") + f"{y}."))
else:
return []
if doi and style not in ("apa",) and doi not in "".join(r.text for r in out):
out.append(R(f" {doi}" if style != "ieee" else f" [Online]. Available: {doi}"))
return out
def _tidy(runs: List[Run]) -> List[Run]:
for a, b in zip(runs, runs[1:]):
if a.text.rstrip().endswith(".") and b.text.lstrip().startswith(".") and not b.text.lstrip().startswith(".."):
b.text = b.text.lstrip()[1:] # "temperatures." + ". https" -> one full stop
if a.text[-1:].isspace() and b.text[:1].isspace():
b.text = b.text.lstrip()
for r in runs:
r.text = re.sub(r"\s{2,}", " ", r.text)
r.text = re.sub(r"\.\.(?!\.)", ".", r.text)
r.text = re.sub(r"\.\s+\.(?!\.)", ".", r.text)
if runs:
runs[-1].text = runs[-1].text.rstrip() # "temperatures. . https" -> "temperatures. https"
r.text = r.text.replace(" ,", ",").replace(",,", ",").replace(", .", ".")
return runs
NUMERIC_STYLES = {"ieee", "vancouver", "ama", "lncs", "mdpi", "elsevier-numeric", "springer-vancouver"}
def ref_label(style: str, n: int) -> str:
if style in ("ieee", "elsevier-numeric"):
return f"[{n}]"
if style in ("vancouver", "ama", "lncs", "mdpi", "springer-vancouver"):
return f"{n}."
return ""
def _with_label(runs: List[Run], style: str, n: int) -> List[Run]:
lab = ref_label(style, n)
if lab:
return [Run(lab + ("\t" if style in ("ieee", "elsevier-numeric") else " "))] + runs
return runs
# ------------------------------------------------------------------ in-text citations
BRACKET_RE = re.compile(r"\[(\s*\d{1,3}(?:\s*[-–—,]\s*\d{1,3})*\s*)\]")
PAREN_RE = re.compile(r"\(([^()]*?(?:19|20)\d{2}[a-z]?[^()]*?)\)")
NARR_RE = re.compile(
r"\b(" + PARTICLE + NAME + r")(?:\s+(?:et\s+al\.?|(?:and|&)\s+(" + PARTICLE + NAME + r")))?\s+\(((?:19|20)\d{2})([a-z])?(?:,\s*(?:p|pp)\.\s*[\d–-]+)?\)"
)
def _expand(spec: str) -> List[int]:
out = []
for part in re.split(r"\s*,\s*", spec.strip()):
m = re.match(r"(\d+)\s*[-–—]\s*(\d+)", part)
if m:
a, b = int(m.group(1)), int(m.group(2))
out.extend(range(a, b + 1) if b >= a and b - a < 50 else [a, b])
elif part.strip().isdigit():
out.append(int(part))
return out
def _compress(nums: List[int]) -> str:
nums = sorted(set(nums))
parts, i = [], 0
while i < len(nums):
j = i
while j + 1 < len(nums) and nums[j + 1] == nums[j] + 1:
j += 1
if j - i >= 2:
parts.append(f"{nums[i]}–{nums[j]}")
else:
parts.extend(str(x) for x in nums[i:j + 1])
i = j + 1
return ", ".join(parts)
def detect_citation_mode(blocks: List[M.Block]) -> str:
text = " ".join(b.text for b in blocks if b.role in (M.PARA, M.LIST, M.CAPTION))
nb = len(BRACKET_RE.findall(text))
na = len(PAREN_RE.findall(text)) + len(NARR_RE.findall(text))
sup = sum(1 for b in blocks if b.role == M.PARA for r in b.runs
if r.sup and re.fullmatch(r"\s*\d{1,3}(?:\s*[-–,]\s*\d{1,3})*\s*", r.text or ""))
if sup > max(nb, na):
return "superscript"
return "numeric" if nb >= na and nb > 0 else ("author-year" if na else "none")
def _match_key(ref: Ref):
return (_fold(ref.first_family), ref.year)
class CitationEngine:
def __init__(self, refs: List[Ref], target_mode: str, target_style: str):
self.refs = refs
self.mode = target_mode
self.style = target_style
self.order: List[int] = [] # indices into refs in citation order
self.unresolved: List[str] = []
self.by_old = {r.old_num: k for k, r in enumerate(refs) if r.old_num}
self.by_pos = {k + 1: k for k in range(len(refs))}
# ---- lookup helpers
def find_author_year(self, item: str) -> Optional[int]:
item = re.sub(r"^\s*(?:e\.g\.,?|see|cf\.|i\.e\.,?|also)\s+", "", item.strip(), flags=re.I)
m = re.search(r"((?:19|20)\d{2})([a-z])?", item)
if not m:
return None
year, suf = m.group(1), m.group(2) or ""
names = _fold(item[: m.start()])
names = re.sub(r"et al\.?|,|&|\band\b", " ", names)
words = [w for w in re.findall(r"[a-z][a-z'’\-]+", names) if len(w) > 1]
if not words:
return None
first = words[0]
hits = [k for k, r in enumerate(self.refs) if r.year == year and _fold(r.first_family).split()[-1:] and
(first in _fold(r.first_family).split() or _fold(r.first_family).endswith(first))]
if len(hits) > 1 and suf:
hits = [k for k in hits if self.refs[k].suffix == suf] or hits
if len(hits) > 1 and len(words) > 1:
hits = [k for k in hits if len(self.refs[k].authors) > 1 and words[1] in _fold(self.refs[k].authors[1][0])] or hits
if not hits:
# an organisation cited by its abbreviation, "(ISO, 2018)" for "International Organization for
# Standardization (2018)": its initials, or the cited name anywhere in exactly one reference of that year
def initials(s):
return "".join(w[0] for w in re.findall(r"[a-z][a-z'’\-]*", s)
if w not in ("of", "for", "the", "and", "on", "in", "de", "et"))
hits = [k for k, r in enumerate(self.refs) if r.year == year and len(first) >= 2 and
(initials(_fold(r.first_family or r.raw[:120])) == first or
initials(_fold(r.raw.split(year)[0] if year in r.raw else r.raw[:120])).startswith(first))]
if len(hits) != 1:
hits = [k for k, r in enumerate(self.refs) if r.year == year and
re.search(rf"\b{re.escape(first)}\b", _fold(r.raw))]
hits = hits if len(hits) == 1 else []
return hits[0] if hits else None
def find_number(self, n: int) -> Optional[int]:
return self.by_old.get(n, self.by_pos.get(n))
def _note(self, k: int):
if k not in self.order:
self.order.append(k)
# ---- output builders
def numeric_text(self, ks: List[int]) -> str:
for k in ks:
self._note(k)
return "PLACEHOLDER" # resolved later when numbers are final
def author_year_text(self, ks: List[int], narrative=False) -> str:
parts = []
for k in ks:
r = self.refs[k]
fam = [a[0] for a in r.authors] or [r.title[:30]]
amp = " & " if self.mode == "author-year" and self.style == "apa" else " and "
if len(fam) == 1:
name = fam[0]
elif len(fam) == 2:
name = fam[0] + amp + fam[1]
else:
name = fam[0] + " et al."
if r.etal and len(fam) <= 2:
name = fam[0] + " et al."
y = r.year + r.suffix if r.year else "n.d."
if narrative:
parts.append(f"{name} ({y})")
else:
parts.append(f"{name} {y}" if self.mode == "author-year-nocomma" else f"{name}, {y}")
if narrative:
return " and ".join(parts)
return "(" + "; ".join(parts) + ")"
# ---- paragraph conversion
def convert_runs(self, runs: List[Run], source_mode: str) -> List[Run]:
if source_mode == "none":
return runs
text_runs = runs
segs = _segments(text_runs)
full = "".join(s[1] for s in segs)
reps: List[Tuple[int, int, List[Run]]] = []
numeric_target = self.mode in ("numeric", "superscript")
if source_mode == "numeric":
for m in BRACKET_RE.finditer(full):
ks = [self.find_number(n) for n in _expand(m.group(1))]
if any(k is None for k in ks):
self.unresolved.append(m.group(0))
continue
if numeric_target:
for k in ks:
self._note(k)
reps.append((m.start(), m.end(), [Run("", cite=True, latex=None)]))
reps[-1][2][0].text = "\x00" + ",".join(map(str, ks)) + "\x00"
else:
for k in ks:
self._note(k)
reps.append((m.start(), m.end(), [Run(self.author_year_text(ks))]))
elif source_mode == "superscript":
pos = 0
for kind, t, r in segs:
if r is not None and r.sup and re.fullmatch(r"\s*\d{1,3}(?:\s*[-–,]\s*\d{1,3})*\s*", t):
ks = [self.find_number(n) for n in _expand(t.replace("–", "-"))]
if all(k is not None for k in ks):
for k in ks:
self._note(k)
if numeric_target:
reps.append((pos, pos + len(t), [Run("\x00" + ",".join(map(str, ks)) + "\x00", cite=True)]))
else:
reps.append((pos, pos + len(t), [Run(" " + self.author_year_text(ks))]))
pos += len(t)
else: # author-year source
taken = []
for m in NARR_RE.finditer(full):
ks = self.find_author_year(m.group(0).replace("(", " ").replace(")", " "))
if ks is None:
self.unresolved.append(m.group(0))
continue
self._note(ks)
lead = m.group(0)[: m.group(0).index("(")].rstrip()
if numeric_target:
reps.append((m.start(), m.end(), [Run(lead + " "), Run("\x00" + str(ks) + "\x00", cite=True)]))
else:
reps.append((m.start(), m.end(), [Run(self.author_year_text([ks], narrative=True))]))
taken.append((m.start(), m.end()))
for m in PAREN_RE.finditer(full):
if any(a <= m.start() < b for a, b in taken):
continue
items = [x for x in re.split(r";", m.group(1)) if x.strip()]
# "Smith, 2019, 2020" -> two items sharing author
exp = []
for it in items:
ys = re.findall(r"(?:19|20)\d{2}[a-z]?", it)
if len(ys) > 1:
head = it[: it.find(ys[0])]
exp += [head + y for y in ys]
else:
exp.append(it)
ks = [self.find_author_year(it) for it in exp]
if not ks or any(k is None for k in ks):
if any(k is not None for k in ks) or re.search(r"[A-Z][a-z]+", m.group(1)):
self.unresolved.append(m.group(0))
continue
for k in ks:
self._note(k)
if numeric_target:
reps.append((m.start(), m.end(), [Run("\x00" + ",".join(map(str, ks)) + "\x00", cite=True)]))
else:
reps.append((m.start(), m.end(), [Run(self.author_year_text(ks))]))
if not reps:
return runs
return _apply(segs, reps)
def finalize_numbers(self, blocks: List[M.Block]):
"""Replace placeholders with final numbers (citation order); append uncited refs."""
for k in range(len(self.refs)):
if k not in self.order:
self.order.append(k)
newnum = {k: i + 1 for i, k in enumerate(self.order)}
for b in blocks:
nr = []
for r in b.runs:
if r.cite and "\x00" in r.text:
ks = [int(x) for x in r.text.strip("\x00").split(",")]
nums = [newnum[k] for k in ks]
if self.mode == "superscript":
nr.append(Run(_compress(nums).replace(" ", ""), sup=True, cite=True))
else:
nr.append(Run("[" + _compress(nums) + "]", cite=True))
else:
nr.append(r)
b.runs = nr
if b.rows:
pass
return newnum
def _segments(runs: List[Run]):
"""[(kind, text, run)] where math runs have text '' (non-matchable)."""
out = []
for r in runs:
if r.is_math:
out.append(("math", "", r))
else:
out.append(("text", r.text, r))
return out
def _apply(segs, reps):
"""Rebuild runs replacing character spans (over concatenated text) with new runs."""
reps = sorted(reps, key=lambda x: x[0])
out: List[Run] = []
pos = 0
ri = 0
for kind, t, r in segs:
if kind == "math":
out.append(r)
continue
start = pos
end = pos + len(t)
cur = start
while ri < len(reps) and reps[ri][0] < end:
a, b, new = reps[ri]
if a > cur:
piece = copy.copy(r); piece.text = t[cur - start: a - start]; out.append(piece)
if a >= cur:
for nr in new:
nr = copy.copy(nr)
nr.bold, nr.italic = (r.bold, r.italic) if not nr.cite else (r.bold, False)
out.append(nr)
if b <= end:
cur = b
ri += 1
else:
cur = end
# replacement continues into next run; skip remaining chars there
reps[ri] = (end, b, [])
break
if cur < end:
piece = copy.copy(r); piece.text = t[cur - start:]; out.append(piece)
pos = end
return [x for x in out if x.text or x.is_math]
def sort_author_year(refs: List[Ref]) -> List[int]:
return sorted(range(len(refs)), key=lambda k: (_fold(refs[k].first_family or refs[k].title),
_fold(" ".join(a[0] for a in refs[k].authors[1:])),
refs[k].year, refs[k].suffix))