From 019fd6835f6aee47a00ecbde3defb819fc05252e Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:11:17 -0400 Subject: [PATCH] Support uppercase hexadecimal references in modern slugs --- README.md | 2 +- slugify/slugify.py | 3 ++- test_release.py | 24 ++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9c0efb5..d213089 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ slugify( - `algorithm`: `'legacy'` is the permanent default, preserving the historical output pipeline. `'modern'` explicitly opts into the changes below. Unknown values raise `ValueError`. - `text`: `str`, or UTF-8 `bytes`/`bytearray` (invalid bytes ignored). Other objects raise `TypeError`. -- `entities`, `decimal`, `hexadecimal`: independently decode named HTML entities, decimal references, and lowercase-`x` hexadecimal references. Legacy decodes after transliteration and numeric substitutions are all-or-nothing per reference kind. Modern decodes before transliteration and handles invalid references independently. +- `entities`, `decimal`, `hexadecimal`: independently decode named HTML entities, decimal references, and hexadecimal references. Legacy accepts lowercase `x`; modern accepts both `x` and `X` as specified by HTML. Legacy decodes after transliteration and numeric substitutions are all-or-nothing per reference kind. Modern decodes before transliteration and handles invalid references independently. - `max_length`: legacy budgets internal dashes before separator mapping, so wide separators can exceed the limit. Modern budgets final Python characters, including emitted delimiters. Nonpositive means unlimited for slugify; this is not a byte or grapheme limit. - `word_boundary`: prefer whole words; shorter later words can fill the budget. If none fit, use a hard cut. - `save_order`: with word boundaries, stop at the first oversized word instead of skipping it. diff --git a/slugify/slugify.py b/slugify/slugify.py index f4e5faf..c98d9ce 100644 --- a/slugify/slugify.py +++ b/slugify/slugify.py @@ -13,6 +13,7 @@ CHAR_ENTITY_PATTERN = re.compile(r'&(%s);' % '|'.join(name2codepoint)) DECIMAL_PATTERN = re.compile(r'&#(\d+);') HEX_PATTERN = re.compile(r'&#x([\da-fA-F]+);') +MODERN_HEX_PATTERN = re.compile(r'&#[xX]([\da-fA-F]+);') QUOTE_PATTERN = re.compile(r"[']+") DISALLOWED_CHARS_PATTERN = re.compile(r'[^-a-zA-Z0-9]+') DISALLOWED_UNICODE_CHARS_PATTERN = re.compile(r'[\W_]+') @@ -50,7 +51,7 @@ def _decode_entities(text: str, entities: bool, decimal: bool, hexadecimal: bool pass if hexadecimal: if algorithm == 'modern': - text = HEX_PATTERN.sub(lambda m: _numeric_reference(m, 16), text) + text = MODERN_HEX_PATTERN.sub(lambda m: _numeric_reference(m, 16), text) else: try: text = HEX_PATTERN.sub(lambda m: chr(int(m.group(1), 16)), text) diff --git a/test_release.py b/test_release.py index 0978362..e23c8ab 100644 --- a/test_release.py +++ b/test_release.py @@ -271,3 +271,27 @@ def test_cli_forwarding_and_real_module(self): if __name__ == '__main__': unittest.main() + + +class UppercaseHexReferenceTests(unittest.TestCase): + def test_modern_accepts_both_hexadecimal_prefixes(self): + for prefix in ('x', 'X'): + with self.subTest(prefix=prefix): + self.assertEqual(slugify(f'&#{prefix}41; &#{prefix}e9;', allow_unicode=True), 'a-é') + self.assertEqual(slugify(f'&#{prefix}41;', lowercase=False), 'A') + + def test_hexadecimal_opt_out_preserves_reference_filtering(self): + self.assertEqual(slugify('A', hexadecimal=False), 'x41') + + def test_legacy_uppercase_reference_output_is_unchanged(self): + self.assertEqual(public_slugify('A'), 'x41') + self.assertEqual(public_slugify('A', algorithm='legacy'), 'x41') + + def test_invalid_uppercase_reference_does_not_block_valid_neighbor(self): + self.assertEqual(slugify('� A'), 'x110000-a') + self.assertEqual(slugify('� A'), 'xd800-a') + + def test_uppercase_reference_through_cli(self): + output = subprocess.check_output( + [sys.executable, '-m', 'slugify', '--algorithm', 'modern', 'A'], text=True) + self.assertEqual(output, 'a\n')