From 51ca8bf847938593cc742ccc17154eb4fee14516 Mon Sep 17 00:00:00 2001 From: Siraj saheb Date: Wed, 14 Oct 2026 19:33:38 +0530 Subject: [PATCH] Use ASCII case folding for :lang() and attribute values The document side of the :lang() comparison only folds A-Z through translate(), but the argument was folded with str.lower(), which maps U+212A (KELVIN SIGN) to "k" and U+0130 to "i" + U+0307. A :lang() argument containing U+212A therefore matched lang="K" while missing an actual U+212A language tag. Language-range matching is ASCII case-insensitive (RFC 4647), so fold the argument with ascii_lower(), and do the same for attribute values under the lower_case_attribute_values extension point, completing the name folding from #200. --- cssselect/xpath.py | 6 ++++-- tests/test_cssselect.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cssselect/xpath.py b/cssselect/xpath.py index 377fc28..eb7a8b8 100644 --- a/cssselect/xpath.py +++ b/cssselect/xpath.py @@ -440,7 +440,7 @@ def xpath_attrib(self, selector: Attrib) -> XPathExpr: if selector.value is None: value = None elif self.lower_case_attribute_values: - value = cast("str", selector.value.value).lower() + value = ascii_lower(cast("str", selector.value.value)) else: value = selector.value.value if selector.flag == "i" and value: @@ -927,7 +927,9 @@ def xpath_lang_function(self, xpath: XPathExpr, function: Function) -> XPathExpr ) value = function.arguments[0].value assert value - arg = self.xpath_literal(value.lower() + "-") + # Language-range matching is ASCII case-insensitive (RFC 4647), + # like the translate() on the document side of the comparison. + arg = self.xpath_literal(ascii_lower(value) + "-") return xpath.add_condition( "ancestor-or-self::*[@lang][1][starts-with(concat(" # XPath 1.0 has no lower-case function... diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py index 4a98eee..46b1742 100644 --- a/tests/test_cssselect.py +++ b/tests/test_cssselect.py @@ -1045,6 +1045,9 @@ class LowerValues(GenericTranslator): lower_case_attribute_values = True assert LowerValues().css_to_xpath("[Foo=BAR]", prefix="") == "*[@Foo = 'bar']" + # The folding is ASCII-only: str.lower() would turn U+212A + # (KELVIN SIGN) into ASCII "k". + assert LowerValues().css_to_xpath('[foo="K"]', prefix="") == "*[@foo = 'K']" # A member of an :is()/:where() selector list that translates to a # path (rather than a predicate) cannot be embedded in the outer @@ -1386,6 +1389,17 @@ def test_html_non_ascii_attribute_names(self) -> None: == "descendant-or-self::*[attribute::*[name() = 'data-ä'] = 'yes']" ) + def test_html_non_ascii_lang(self) -> None: + # Language-range matching is ASCII case-insensitive (RFC 4647); + # str.lower() folds U+212A (KELVIN SIGN) into ASCII "k". + xpath = HTMLTranslator().css_to_xpath + assert xpath(":lang(K)") == ( + "descendant-or-self::*[ancestor-or-self::*[@lang][1]" + "[starts-with(concat(translate(@lang, " + "'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), " + "'-'), 'K-')]]" + ) + def test_select(self) -> None: document = etree.fromstring(HTML_IDS) sort_key = {el: count for count, el in enumerate(document.iter())}.__getitem__