"""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"(? 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.+?)\s+(?P\d{1,4})\s*(?:\((?P[^()]{1,12})\)\s*)?\((?P(?:19|20)\d{2})\)\s*" r"(?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.+?),?\s*[\"“](?P.+?)[,.]?[\"”],?\s*(?P.*)$", 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.+?)\.\s+(?P(?:19|20)\d{2})(?P[a-z])?\.\s+[\"“](?P.+?)[.,]?[\"”]\s*(?P.*)$", 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[^:]{3,300}?):\s+(?P.+)$", 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.+?)\s*\((?P(?:19|20)\d{2})(?P[a-z])?[^)]*\)[.,]?\s*(?P.*)$", 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.+?)[,.]?\s+(?P(?:19|20)\d{2})(?P[a-z])?[.,]\s+(?P.*)$", 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(?:[^.]|(?<=\b[A-Z])\.)+?)\.\s+(?P.+)$", 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" + _IEEE_AUTH + r"(?:,\s+(?:and\s+)?" + _IEEE_AUTH + r")*(?:,?\s+and\s+" + _IEEE_AUTH + r")?(?:,?\s+et\s+al\.)?),\s+(?P[^“”\"]+?),\s+(?:(?P\d+(?:st|nd|rd|th)\s+ed\.)\s+)?" r"(?P[^:]{2,80}):\s*(?P[^,:]{2,100}),\s*(?P(?:19|20)\d{2})(?P[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[^,]{6,140}),\s+(?P.{6,}?)(?:,\s*(?P[A-Z][A-Za-z .\-]{2,40}))?,\s*" r"(?P(?:19|20)\d{2})(?P[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[A-Z][^:;]{1,60}):\s*)?(?P[^.;:]{2,100}?)(?:;\s*(?P(?: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))