diff --git a/bibtexparser/middlewares/names.py b/bibtexparser/middlewares/names.py index 7169bd4..7fa8769 100644 --- a/bibtexparser/middlewares/names.py +++ b/bibtexparser/middlewares/names.py @@ -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: @@ -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: @@ -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). @@ -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 @@ -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. @@ -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] diff --git a/tests/middleware_tests/test_names.py b/tests/middleware_tests/test_names.py index 7d809f9..3f19b08 100644 --- a/tests/middleware_tests/test_names.py +++ b/tests/middleware_tests/test_names.py @@ -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": []}, @@ -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