Repository: streamlit/streamlit

Issue:
Multipage UTF8 file icons shown, but page icon also shown
### Summary

Some of the UTF8 characters in filenames seems to not be recognized, as the page icon is still shown to the left of the utf8 icon. Resulting in two icons next to each other.

### Steps to reproduce

Give a page in the pages folder a name including e.g. 1️⃣ like this
01_1️⃣_Apox.py

Run the main script.

**Expected behavior:**
I would expect the line in the page list to show:
![image](https://user-images.githubusercontent.com/4332517/176850763-17565528-ee88-4196-b129-c235ee5a7312.png)

**Actual behavior:**
But I get this:
![image](https://user-images.githubusercontent.com/4332517/176850834-7e3f7405-4795-4934-973d-972e5c705575.png)

### Is this a regression?

Multipage is new, so no it has never worked

### Debug info

- Streamlit version: 1.10.0
- Python version: 3.10.3
- OS version: Windows 11
- Browser version: Vivaldi 5.3.2679.61/ chrome 103.0.5060.66/ Edge 103.0.1264.37

### Additional information

I have mentioned the bug in this thread: https://discuss.streamlit.io/t/introducing-multipage-apps/25986/47



Discussion:
Noting here that there's also a related issue with Chinese characters, see [this comment on the forum](https://discuss.streamlit.io/t/introducing-multipage-apps/25986/39?u=randyzwitch). 

Resolving patch:
diff --git a/lib/streamlit/source_util.py b/lib/streamlit/source_util.py
--- a/lib/streamlit/source_util.py
+++ b/lib/streamlit/source_util.py
@@ -20,6 +20,7 @@
 from blinker import Signal
 
 from streamlit.logger import get_logger
+from streamlit.string_util import extract_leading_emoji
 from streamlit.util import calc_md5
 
 LOGGER = get_logger(__name__)
@@ -44,23 +45,6 @@ def open_python_file(filename):
 
 
 PAGE_FILENAME_REGEX = re.compile(r"([0-9]*)[_ -]*(.*)\.py")
-# Regex pattern to extract emoji taken from https://gist.github.com/Alex-Just/e86110836f3f93fe7932290526529cd1#gistcomment-3208085
-# We may eventually want to swap this out for https://pypi.org/project/emoji,
-# but I want to avoid adding a dependency if possible.
-PAGE_ICON_REGEX = re.compile(
-    "(^[\U0001F1E0-\U0001F1FF"
-    "\U0001F300-\U0001F5FF"
-    "\U0001F600-\U0001F64F"
-    "\U0001F680-\U0001F6FF"
-    "\U0001F700-\U0001F77F"
-    "\U0001F780-\U0001F7FF"
-    "\U0001F800-\U0001F8FF"
-    "\U0001F900-\U0001F9FF"
-    "\U0001FA00-\U0001FA6F"
-    "\U0001FA70-\U0001FAFF"
-    "\U00002702-\U000027B0"
-    "\U000024C2-\U0001F251])[_-]*"
-)
 
 
 def page_sort_key(script_path: Path) -> Tuple[float, str]:
@@ -79,8 +63,8 @@ def page_sort_key(script_path: Path) -> Tuple[float, str]:
     return (float(number), label)
 
 
-def page_name_and_icon(script_path: Path) -> Tuple[str, str]:
-    """Compute the name of a page from its script path.
+def page_icon_and_name(script_path: Path) -> Tuple[str, str]:
+    """Compute the icon and name of a page from its script path.
 
     This is *almost* the page name displayed in the nav UI, but it has
     underscores instead of spaces. The reason we do this is because having
@@ -97,18 +81,11 @@ def page_name_and_icon(script_path: Path) -> Tuple[str, str]:
     # as an attempt to index into re.Match instead of as a type annotation.
     extraction: re.Match[str] = cast(Any, extraction)
 
-    name = re.sub(r"[_ ]+", "_", extraction.group(2)).strip()
-    if not name:
-        name = extraction.group(1)
+    icon_and_name = re.sub(
+        r"[_ ]+", "_", extraction.group(2)
+    ).strip() or extraction.group(1)
 
-    extracted_icon = re.search(PAGE_ICON_REGEX, name)
-    if extracted_icon is not None:
-        icon = str(extracted_icon.group(1))
-        name = re.sub(PAGE_ICON_REGEX, "", name)
-    else:
-        icon = ""
-
-    return str(name), icon
+    return extract_leading_emoji(icon_and_name)
 
 
 _pages_cache_lock = threading.RLock()
@@ -141,7 +118,7 @@ def get_pages(main_script_path_str: str) -> Dict[str, Dict[str, str]]:
             return _cached_pages
 
         main_script_path = Path(main_script_path_str)
-        main_page_name, main_page_icon = page_name_and_icon(main_script_path)
+        main_page_icon, main_page_name = page_icon_and_name(main_script_path)
         main_page_script_hash = calc_md5(main_script_path_str)
 
         # NOTE: We include the page_script_hash in the dict even though it is
@@ -164,7 +141,7 @@ def get_pages(main_script_path_str: str) -> Dict[str, Dict[str, str]]:
 
         for script_path in page_scripts:
             script_path_str = str(script_path.resolve())
-            pn, pi = page_name_and_icon(script_path)
+            pi, pn = page_icon_and_name(script_path)
             psh = calc_md5(script_path_str)
 
             pages[psh] = {
diff --git a/lib/streamlit/string_util.py b/lib/streamlit/string_util.py
--- a/lib/streamlit/string_util.py
+++ b/lib/streamlit/string_util.py
@@ -14,7 +14,7 @@
 
 import re
 import textwrap
-from typing import TYPE_CHECKING
+from typing import Any, cast, Tuple, TYPE_CHECKING
 
 from datetime import datetime
 from streamlit.errors import StreamlitAPIException
@@ -24,6 +24,13 @@
     from streamlit.type_util import SupportsStr
 
 
+# The ESCAPED_EMOJI list is sorted in descending order to make that longer emoji appear
+# first in the regex compiled below. This ensures that we grab the full emoji in a
+# multi-character emoji sequence that starts with a shorter emoji (emoji are weird...).
+ESCAPED_EMOJI = [re.escape(e) for e in sorted(ALL_EMOJIS, reverse=True)]
+EMOJI_EXTRACTION_REGEX = re.compile(f"^({'|'.join(ESCAPED_EMOJI)})[_ -]*(.*)")
+
+
 def decode_ascii(string):
     """Decodes a string as ascii."""
     return string.decode("ascii")
@@ -39,6 +46,21 @@ def is_emoji(text: str) -> bool:
     return text in ALL_EMOJIS
 
 
+def extract_leading_emoji(text: str) -> Tuple[str, str]:
+    """Return a tuple containing the first emoji found in the given string and
+    the rest of the string (minus an optional separator between the two).
+    """
+    re_match = re.search(EMOJI_EXTRACTION_REGEX, text)
+    if re_match is None:
+        return "", text
+
+    # This cast to Any+type annotation weirdness is done because
+    # cast(re.Match[str], ...) explodes at runtime since Python interprets it
+    # as an attempt to index into re.Match instead of as a type annotation.
+    re_match: re.Match[str] = cast(Any, re_match)
+    return re_match.group(1), re_match.group(2)
+
+
 def escape_markdown(raw_string: str) -> str:
     """Returns a new string which escapes all markdown metacharacters.
 


Test patch:
diff --git a/lib/tests/streamlit/source_util_test.py b/lib/tests/streamlit/source_util_test.py
--- a/lib/tests/streamlit/source_util_test.py
+++ b/lib/tests/streamlit/source_util_test.py
@@ -52,46 +52,46 @@ def test_page_sort_key_error(self):
     @parameterized.expand(
         [
             # Test that the page number is removed as expected.
-            ("/foo/01_bar.py", ("bar", "")),
-            ("/foo/02-bar.py", ("bar", "")),
-            ("/foo/03 bar.py", ("bar", "")),
-            ("/foo/04 bar baz.py", ("bar_baz", "")),
-            ("/foo/05 -_- bar.py", ("bar", "")),
-            ("/foo/06 -_- 🎉bar.py", ("bar", "🎉")),
-            ("/foo/07 -_- 🎉-_bar.py", ("bar", "🎉")),
-            ("/foo/08 -_- 🎉 _ bar.py", ("bar", "🎉")),
+            ("/foo/01_bar.py", ("", "bar")),
+            ("/foo/02-bar.py", ("", "bar")),
+            ("/foo/03 bar.py", ("", "bar")),
+            ("/foo/04 bar baz.py", ("", "bar_baz")),
+            ("/foo/05 -_- bar.py", ("", "bar")),
+            ("/foo/06 -_- 🎉bar.py", ("🎉", "bar")),
+            ("/foo/07 -_- 🎉-_bar.py", ("🎉", "bar")),
+            ("/foo/08 -_- 🎉 _ bar.py", ("🎉", "bar")),
             # Test cases with no page number.
-            ("/foo/bar.py", ("bar", "")),
-            ("/foo/bar baz.py", ("bar_baz", "")),
-            ("/foo/😐bar baz.py", ("bar_baz", "😐")),
-            ("/foo/😐_bar baz.py", ("bar_baz", "😐")),
+            ("/foo/bar.py", ("", "bar")),
+            ("/foo/bar baz.py", ("", "bar_baz")),
+            ("/foo/😐bar baz.py", ("😐", "bar_baz")),
+            ("/foo/😐_bar baz.py", ("😐", "bar_baz")),
             # Test that separator characters in the page name are removed as
             # as expected.
-            ("/foo/1 - first page.py", ("first_page", "")),
-            ("/foo/123_hairy_koala.py", ("hairy_koala", "")),
+            ("/foo/1 - first page.py", ("", "first_page")),
+            ("/foo/123_hairy_koala.py", ("", "hairy_koala")),
             (
                 "/foo/123 wow_this_has a _lot_ _of  _ ___ separators.py",
-                ("wow_this_has_a_lot_of_separators", ""),
+                ("", "wow_this_has_a_lot_of_separators"),
             ),
             (
                 "/foo/1-dashes in page-name stay.py",
-                ("dashes_in_page-name_stay", ""),
+                ("", "dashes_in_page-name_stay"),
             ),
-            ("/foo/2 - 🙃second page.py", ("second_page", "🙃")),
+            ("/foo/2 - 🙃second page.py", ("🙃", "second_page")),
             # Test other weirdness that might happen with numbers.
-            ("12 monkeys.py", ("monkeys", "")),
-            ("12 😰monkeys.py", ("monkeys", "😰")),
-            ("_12 monkeys.py", ("12_monkeys", "")),
-            ("_12 😰monkeys.py", ("12_😰monkeys", "")),
-            ("_😰12 monkeys.py", ("12_monkeys", "😰")),
-            ("123.py", ("123", "")),
-            ("😰123.py", ("123", "😰")),
+            ("12 monkeys.py", ("", "monkeys")),
+            ("12 😰monkeys.py", ("😰", "monkeys")),
+            ("_12 monkeys.py", ("", "12_monkeys")),
+            ("_12 😰monkeys.py", ("", "12_😰monkeys")),
+            ("_😰12 monkeys.py", ("😰", "12_monkeys")),
+            ("123.py", ("", "123")),
+            ("😰123.py", ("😰", "123")),
             # Test the default case for non-Python files.
             ("not_a_python_script.rs", ("", "")),
         ]
     )
-    def test_page_name_and_icon(self, path_str, expected):
-        assert source_util.page_name_and_icon(Path(path_str)) == expected
+    def test_page_icon_and_name(self, path_str, expected):
+        assert source_util.page_icon_and_name(Path(path_str)) == expected
 
     @patch("streamlit.source_util._on_pages_changed", new=MagicMock())
     @patch("streamlit.source_util._cached_pages", new="Some pages")
diff --git a/lib/tests/streamlit/string_util_test.py b/lib/tests/streamlit/string_util_test.py
--- a/lib/tests/streamlit/string_util_test.py
+++ b/lib/tests/streamlit/string_util_test.py
@@ -39,6 +39,29 @@ def test_is_emoji(self, text: str, expected: bool):
         """Test streamlit.string_util.is_emoji."""
         self.assertEqual(string_util.is_emoji(text), expected)
 
+    @parameterized.expand(
+        [
+            ("", ("", "")),
+            ("A", ("", "A")),
+            ("%", ("", "%")),
+            ("😃", ("😃", "")),
+            ("😃 page name", ("😃", "page name")),
+            ("😃-page name", ("😃", "page name")),
+            ("😃_page name", ("😃", "page name")),
+            ("😃 _- page name", ("😃", "page name")),
+            # Test that multi-character emoji are fully extracted.
+            ("👨‍👨‍👧‍👦_page name", ("👨‍👨‍👧‍👦", "page name")),
+            ("😃😃", ("😃", "😃")),
+            ("1️⃣X", ("1️⃣", "X")),
+            ("X😃", ("", "X😃")),
+            # Test that certain non-emoji unicode characters don't get
+            # incorrectly detected as emoji.
+            ("何_is_this", ("", "何_is_this")),
+        ]
+    )
+    def test_extract_leading_emoji(self, text, expected):
+        self.assertEqual(string_util.extract_leading_emoji(text), expected)
+
     def test_snake_case_to_camel_case(self):
         """Test streamlit.string_util.snake_case_to_camel_case."""
         self.assertEqual(