Skip to content

Commit 770ed0a

Browse files
authored
Merge pull request #276 from apoorva-01/fix/issue-266-bidi-control-chars
Strip invisible bidi control characters during preprocessing
2 parents 90b3c32 + 9a01153 commit 770ed0a

5 files changed

Lines changed: 66 additions & 1 deletion

File tree

docs/customize.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,24 @@ You can turn this off by setting the ``emoji`` regex to ``False``.
501501
>>> str(hn)
502502
'Sam 😊 Smith'
503503

504+
Don't Remove Bidi Control Characters
505+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
506+
507+
By default, invisible bidirectional control characters (the left-to-right and
508+
right-to-left marks and friends, common in copy-pasted right-to-left names) are
509+
removed from the input string before the name is parsed. You can turn this off
510+
by setting the ``bidi`` regex to ``False``.
511+
512+
.. doctest::
513+
514+
>>> from nameparser import HumanName
515+
>>> from nameparser.config import Constants
516+
>>> constants = Constants()
517+
>>> constants.regexes.bidi = False
518+
>>> hn = HumanName("\u200fJohn\u200f Smith", constants=constants)
519+
>>> hn.first == "\u200fJohn\u200f"
520+
True
521+
504522
Config Changes May Need Parse Refresh
505523
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
506524

docs/release_log.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
Release Log
22
===========
3+
* 1.3.1 - Unreleased
4+
5+
- Fix invisible Unicode bidirectional control characters (LRM/RLM/ALM, the embedding/override marks, and the isolates U+2066–U+2069) surviving parsing and sticking to ``first``/``last``/etc., so a copy-pasted right-to-left name silently failed equality and dedup. They are now stripped in preprocessing like emoji; disable via ``CONSTANTS.regexes.bidi = False`` (closes #266)
6+
37
* 1.3.0 - July 5, 2026
48

59
**Breaking Changes & Deprecations**

nameparser/config/regexes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
'\U0001F680-\U0001F6FF'
77
'\u2600-\u26FF\u2700-\u27BF]+')
88

9+
# Invisible bidirectional formatting characters: ALM, LRM, RLM, the
10+
# embedding/override marks (LRE/RLE/PDF/LRO/RLO) and the isolates
11+
# (LRI/RLI/FSI/PDI). Copy-pasted RTL names often carry these; they render
12+
# as nothing but stick to name parts and break equality/lookups.
13+
re_bidi = re.compile('[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+')
14+
915
EMPTY_REGEX = re.compile('')
1016

1117
REGEXES = {
@@ -20,6 +26,7 @@
2026
"no_vowels": re.compile(r'^[^aeyiuo]+$', re.I),
2127
"period_not_at_end": re.compile(r'.*\..+$', re.I),
2228
"emoji": re_emoji,
29+
"bidi": re_bidi,
2330
"phd": re.compile(r'\s(ph\.?\s+d\.?)', re.I),
2431
"space_before_comma": re.compile(r'\s+,'),
2532
"east_slavic_patronymic": re.compile(

nameparser/parser.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -928,9 +928,11 @@ def pre_process(self) -> None:
928928
This method happens at the beginning of the :py:func:`parse_full_name`
929929
before any other processing of the string aside from unicode
930930
normalization, so it's a good place to do any custom handling in a
931-
subclass. Runs :py:func:`parse_nicknames` and :py:func:`squash_emoji`.
931+
subclass. Runs :py:func:`squash_bidi`, :py:func:`parse_nicknames` and
932+
:py:func:`squash_emoji`.
932933
933934
"""
935+
self.squash_bidi()
934936
self.fix_phd()
935937
self.parse_nicknames()
936938
self.squash_emoji()
@@ -1133,6 +1135,16 @@ def squash_emoji(self) -> None:
11331135
if re_emoji and re_emoji.search(self._full_name):
11341136
self._full_name = re_emoji.sub('', self._full_name)
11351137

1138+
def squash_bidi(self) -> None:
1139+
"""
1140+
Remove invisible bidirectional control characters from the input
1141+
string. They carry no name content but stick to the parts they
1142+
surround, so parsed attributes stop comparing equal to the clean name.
1143+
"""
1144+
re_bidi = self.C.regexes.bidi
1145+
if re_bidi and re_bidi.search(self._full_name):
1146+
self._full_name = re_bidi.sub('', self._full_name)
1147+
11361148
def handle_firstnames(self) -> None:
11371149
"""
11381150
If there are only two parts and one is a title, assume it's a last name

tests/test_output_format.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,27 @@ def test_keep_emojis(self) -> None:
136136
self.m(hn.last, "Smith😊", hn)
137137
self.assertEqual(str(hn), "∫≜⩕ Smith😊")
138138
# test cleanup
139+
140+
def test_remove_bidi_control_chars(self) -> None:
141+
# LRM/RLM and friends ride along with copy-pasted names and stick to
142+
# the parts they surround. Covers every character in the bidi set.
143+
for mark in ("\u200e", "\u200f", "\u061c", "\u202a", "\u202b",
144+
"\u202c", "\u202d", "\u202e", "\u2066", "\u2067",
145+
"\u2068", "\u2069"):
146+
hn = HumanName(mark + "John" + mark + " Smith")
147+
self.m(hn.first, "John", hn)
148+
self.m(hn.last, "Smith", hn)
149+
150+
def test_bidi_stripped_name_compares_equal(self) -> None:
151+
# The reported symptom: an invisible RLM around an RTL name makes the
152+
# parsed part fail equality against the clean string (issue #266).
153+
hn = HumanName("\u200fمحمد بن سلمان\u200f")
154+
self.assertEqual(hn.first, "محمد")
155+
156+
def test_keep_bidi_control_chars(self) -> None:
157+
from nameparser.config import Constants
158+
constants = Constants()
159+
constants.regexes.bidi = False # type: ignore[assignment]
160+
hn = HumanName("\u200fJohn\u200f Smith", constants)
161+
self.m(hn.first, "\u200fJohn\u200f", hn)
162+
self.m(hn.last, "Smith", hn)

0 commit comments

Comments
 (0)