Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 26 additions & 21 deletions bibtexparser/middlewares/names.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,24 @@ def parse_single_name_into_parts(name: str, strict: bool = True) -> NameParts:
case = -1 # Case of the current word.
level = 0 # Current brace level.
bracestart = False # Will the next character be the first within a brace?
controlseq = True # Are we currently processing a control sequence?
specialchar = None # Are we currently processing a special character?
controlseq = "" # Control sequence within a special character.
specialchar = False # Are we currently processing a special character?

# Using an iterator allows us to deal with escapes in a simple manner.
nameiter = iter(name)
for char in nameiter:
if controlseq:
if char.isalpha():
controlseq += char
word.append(char)
continue
if case == -1:
if controlseq in ("L", "O", "AA", "AE", "OE"):
case = 1
elif controlseq in ("l", "o", "aa", "ae", "oe", "i", "j", "ss"):
case = 0
controlseq = ""

# An escape.
if char == "\\":
try:
Expand All @@ -300,11 +312,11 @@ def parse_single_name_into_parts(name: str, strict: bool = True) -> NameParts:
# Is this the first character in a brace?
if bracestart:
bracestart = False
controlseq = escaped.isalpha()
controlseq = escaped if escaped.isalpha() else ""
specialchar = True

# Can we use it to determine the case?
elif (case == -1) and escaped.isalpha():
elif (case == -1) and (not level or specialchar) and escaped.isalpha():
if escaped.isupper():
case = 1
else:
Expand All @@ -324,9 +336,9 @@ def parse_single_name_into_parts(name: str, strict: bool = True) -> NameParts:
if char == "{":
level += 1
word.append(char)
bracestart = True
controlseq = False
specialchar = False
# Only a top-level group beginning with a backslash is special.
# Nested braces do not end an existing special character.
bracestart = level == 1
continue

# All the below cases imply this (and don't test its previous value).
Expand All @@ -343,20 +355,18 @@ def parse_single_name_into_parts(name: str, strict: bool = True) -> NameParts:
word.insert(0, "{")

# Update the state, append the character, and move on.
controlseq = False
specialchar = False
if not level:
if specialchar and case == -1:
# BibTeX treats a caseless special character as non-von.
case = 1
specialchar = False
word.append(char)
continue

# Inside a braced expression.
if level:
# Is this the end of a control sequence?
if controlseq:
if not char.isalpha():
controlseq = False

# If it's a special character, can we use it for a case?
elif specialchar:
if specialchar:
if (case == -1) and char.isalpha():
if char.isupper():
case = 1
Expand All @@ -376,7 +386,7 @@ def parse_single_name_into_parts(name: str, strict: bool = True) -> NameParts:
word = []
cases[-1].append(case)
case = -1
controlseq = False
controlseq = ""
specialchar = False

# End of a section.
Expand Down Expand Up @@ -432,11 +442,6 @@ def parse_single_name_into_parts(name: str, strict: bool = True) -> NameParts:
if len(p0) == 1:
parts.last = p0

# Two words: must be first and last.
elif len(p0) == 2:
parts.first = p0[:1]
parts.last = p0[1:]

# Need to use the cases to figure it out.
else:
cases = cases[0]
Expand Down
95 changes: 95 additions & 0 deletions tests/middleware_tests/test_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,62 @@ def test_name_splitting_commas_at_higher_brace_level(strict: bool):


REGULAR_NAME_PARTS_PARSING_TEST_CASES = (
("van Gogh", {"first": [], "von": ["van"], "last": ["Gogh"], "jr": []}),
("de Gaulle", {"first": [], "von": ["de"], "last": ["Gaulle"], "jr": []}),
("von Neumann", {"first": [], "von": ["von"], "last": ["Neumann"], "jr": []}),
("aa bb", {"first": [], "von": ["aa"], "last": ["bb"], "jr": []}),
(r"{\'e}douard Lucas", {"first": [], "von": [r"{\'e}douard"], "last": ["Lucas"], "jr": []}),
(r"{\'E}douard Lucas", {"first": [r"{\'E}douard"], "von": [], "last": ["Lucas"], "jr": []}),
("{van} Gogh", {"first": ["{van}"], "von": [], "last": ["Gogh"], "jr": []}),
# Expected parts verified with BibTeX's format.name$.
(
r"{\L}ukasz Chmielewski",
{"first": [r"{\L}ukasz"], "von": [], "last": ["Chmielewski"], "jr": []},
),
(
r"{\O}yvind Ytrehus",
{"first": [r"{\O}yvind"], "von": [], "last": ["Ytrehus"], "jr": []},
),
(
r"{\'{E}}ric Brier",
{"first": [r"{\'{E}}ric"], "von": [], "last": ["Brier"], "jr": []},
),
(
r"{Jean-Fran\c{c}ois} Biasse",
{"first": [r"{Jean-Fran\c{c}ois}"], "von": [], "last": ["Biasse"], "jr": []},
),
*(
(f"{first} Last", {"first": [first], "von": [], "last": ["Last"], "jr": []})
for first in (
r"{\AA}ke",
r"{\AE}gir",
r"{\OE}dipus",
r"{\v{Z}}denek",
r"{\'{\'{E}}}ric",
r"{jean-Fran\c{c}ois}",
r"{\relax {\O}}yvind",
r"{\Lfoo}ukasz",
r"{\relax}abc",
)
),
*(
(f"{von} Last", {"first": [], "von": [von], "last": ["Last"], "jr": []})
for von in (
r"{\l}UKASZ",
r"{\o}YVIND",
r"{\aa}KE",
r"{\ae}GIR",
r"{\oe}DIPUS",
r"{\i}AN",
r"{\j}AN",
r"{\ss}AN",
r"{\'{e}}RIC",
r"{\Lfoo abc}",
r"{{\L}}ukasz",
r"{\relax\v{Z}}denek",
r"{\relax\foo ABC}",
)
),
(
r"Per Brinch Hansen",
{"first": ["Per", "Brinch"], "von": [], "last": ["Hansen"], "jr": []},
Expand Down Expand Up @@ -1177,3 +1233,42 @@ def test_unenclosed_name_fields_roundtrip(bibtex):
],
)
assert bibtexparser.write_string(library).strip() == bibtex.strip()


def test_two_word_name_particles_survive_last_name_first_roundtrip():
"""Name particles must not become first names when middleware rewrites a bibliography."""
library = bibtexparser.parse_string(
"@book{k, author = {de Gaulle and van Gogh}}",
append_middleware=[
SeparateCoAuthors(),
SplitNameParts(),
MergeNameParts(style="last"),
MergeCoAuthors(),
],
)
roundtripped = bibtexparser.parse_string(bibtexparser.write_string(library))
assert roundtripped.entries[0].fields_dict["author"].value == "de Gaulle and van Gogh"


@pytest.mark.parametrize(
"name, rewritten",
[
(r"{\L}ukasz Chmielewski", r"Chmielewski, {\L}ukasz"),
(r"{\O}yvind Ytrehus", r"Ytrehus, {\O}yvind"),
(r"{\'{E}}ric Brier", r"Brier, {\'{E}}ric"),
(r"{Jean-Fran\c{c}ois} Biasse", r"Biasse, {Jean-Fran\c{c}ois}"),
],
)
def test_braced_given_names_survive_last_name_first_roundtrip(name, rewritten):
"""Rewriting author fields must preserve the given names and their LaTeX spelling."""
library = bibtexparser.parse_string(
f"@book{{k, author = {{{name}}}}}",
append_middleware=[
SeparateCoAuthors(),
SplitNameParts(),
MergeNameParts(style="last"),
MergeCoAuthors(),
],
)
roundtripped = bibtexparser.parse_string(bibtexparser.write_string(library))
assert roundtripped.entries[0].fields_dict["author"].value == rewritten