From 68b3e837d17020cbe0f0a90c4a516987846ddc9e Mon Sep 17 00:00:00 2001 From: Parth Kandharkar Date: Sat, 15 Nov 2025 10:23:26 -0800 Subject: [PATCH 1/4] Bug: Strings in Excel number fomat do not preserve case(fixes #63101) --- pandas/io/formats/css.py | 10 ++++++++-- pandas/tests/io/excel/test_style.py | 1 + pandas/tests/io/formats/test_css.py | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/pandas/io/formats/css.py b/pandas/io/formats/css.py index 0f1a7302aa808..fb452a2adeeab 100644 --- a/pandas/io/formats/css.py +++ b/pandas/io/formats/css.py @@ -391,7 +391,10 @@ def _error() -> str: def atomize(self, declarations: Iterable) -> Generator[tuple[str, str]]: for prop, value in declarations: prop = prop.lower() - value = value.lower() + if prop == "number-format": + value = value.strip() + else: + value = value.lower() if prop in self.CSS_EXPANSIONS: expand = self.CSS_EXPANSIONS[prop] yield from expand(self, prop, value) @@ -414,7 +417,10 @@ def parse(self, declarations_str: str) -> Iterator[tuple[str, str]]: prop, sep, val = decl.partition(":") prop = prop.strip().lower() # TODO: don't lowercase case sensitive parts of values (strings) - val = val.strip().lower() + if prop == "number-format": + val = val.strip() + else: + val = val.strip().lower() if sep: yield prop, val else: diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 12f14589365ff..e7b508fd7200a 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -96,6 +96,7 @@ def test_styler_to_excel_unstyled(engine, tmp_excel): ("font-style: italic;", ["font", "i"], True), ("text-decoration: underline;", ["font", "u"], "single"), ("number-format: $??,???.00;", ["number_format"], "$??,???.00"), + ("number-format: #,,"'"M";', ["number_format"], '#,,"M"'), ("text-align: left;", ["alignment", "horizontal"], "left"), ( "vertical-align: bottom;", diff --git a/pandas/tests/io/formats/test_css.py b/pandas/tests/io/formats/test_css.py index 642a562704344..4fdd8dde096ca 100644 --- a/pandas/tests/io/formats/test_css.py +++ b/pandas/tests/io/formats/test_css.py @@ -286,3 +286,21 @@ def test_css_relative_font_size(size, relative_to, resolved): else: inherited = {"font-size": relative_to} assert_resolves(f"font-size: {size}", {"font-size": resolved}, inherited=inherited) + +def test_css_atomize_preserves_number_format_case(): + resolver = CSSResolver() + declarations = [("NUMBER-FORMAT", '#,,"M"')] + + props = dict(resolver.atomize(declarations)) + + assert "number-format" in props + assert props["number-format"] == '#,,"M"' + +def test_css_parse_preserves_number_format_case(): + resolver = CSSResolver() + + css = 'NUMBER-FORMAT: #,,"M"; COLOR: Red' + result = dict(resolver.parse(css)) + + assert result["number-format"] == '#,,"M"' + assert result["color"] == "red" From 5a2f9fc9b28941bf3c69ecf5e44e033955f71433 Mon Sep 17 00:00:00 2001 From: Parth Kandharkar Date: Sun, 16 Nov 2025 02:48:55 -0800 Subject: [PATCH 2/4] Changed the code for failing test cases --- pandas/io/formats/css.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/pandas/io/formats/css.py b/pandas/io/formats/css.py index fb452a2adeeab..d36abd9e725fd 100644 --- a/pandas/io/formats/css.py +++ b/pandas/io/formats/css.py @@ -19,6 +19,28 @@ Iterator, ) +def _normalize_number_format_value(value: str) -> str: + """ + Lowercase number-format value except for text inside double quotes. + + This preserves case for string literals (e.g. "M") while still + normalizing things like [Red] -> [red]. + """ + value = value.strip() + out: list[str] = [] + in_string = False + + for ch in value: + if ch == '"': + out.append(ch) + in_string = not in_string + else: + if in_string: + out.append(ch) # preserve case inside string literals + else: + out.append(ch.lower()) # normalize outside literals + return "".join(out) + def _side_expander(prop_fmt: str) -> Callable: """ @@ -392,7 +414,7 @@ def atomize(self, declarations: Iterable) -> Generator[tuple[str, str]]: for prop, value in declarations: prop = prop.lower() if prop == "number-format": - value = value.strip() + value = _normalize_number_format_value(value) else: value = value.lower() if prop in self.CSS_EXPANSIONS: @@ -418,7 +440,7 @@ def parse(self, declarations_str: str) -> Iterator[tuple[str, str]]: prop = prop.strip().lower() # TODO: don't lowercase case sensitive parts of values (strings) if prop == "number-format": - val = val.strip() + val = _normalize_number_format_value(val) else: val = val.strip().lower() if sep: From 3d1abe7009d1ee8dc83387e030acc992b863b1dc Mon Sep 17 00:00:00 2001 From: Parth Kandharkar Date: Sun, 16 Nov 2025 04:02:08 -0800 Subject: [PATCH 3/4] Pre-commit-check --- pandas/io/formats/css.py | 14 ++++---------- pandas/tests/io/excel/test_style.py | 2 +- pandas/tests/io/formats/test_css.py | 2 ++ 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/pandas/io/formats/css.py b/pandas/io/formats/css.py index d36abd9e725fd..8ebc7479fe9df 100644 --- a/pandas/io/formats/css.py +++ b/pandas/io/formats/css.py @@ -19,13 +19,8 @@ Iterator, ) -def _normalize_number_format_value(value: str) -> str: - """ - Lowercase number-format value except for text inside double quotes. - This preserves case for string literals (e.g. "M") while still - normalizing things like [Red] -> [red]. - """ +def _normalize_number_format_value(value: str) -> str: value = value.strip() out: list[str] = [] in_string = False @@ -34,11 +29,10 @@ def _normalize_number_format_value(value: str) -> str: if ch == '"': out.append(ch) in_string = not in_string + elif in_string: + out.append(ch) # preserve case inside string literals else: - if in_string: - out.append(ch) # preserve case inside string literals - else: - out.append(ch.lower()) # normalize outside literals + out.append(ch.lower()) # normalize outside literals return "".join(out) diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index e7b508fd7200a..cab85b1c0fa1e 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -96,7 +96,7 @@ def test_styler_to_excel_unstyled(engine, tmp_excel): ("font-style: italic;", ["font", "i"], True), ("text-decoration: underline;", ["font", "u"], "single"), ("number-format: $??,???.00;", ["number_format"], "$??,???.00"), - ("number-format: #,,"'"M";', ["number_format"], '#,,"M"'), + ('number-format: #,,"M";', ["number_format"], '#,,"M"'), ("text-align: left;", ["alignment", "horizontal"], "left"), ( "vertical-align: bottom;", diff --git a/pandas/tests/io/formats/test_css.py b/pandas/tests/io/formats/test_css.py index 4fdd8dde096ca..47252a7d587e9 100644 --- a/pandas/tests/io/formats/test_css.py +++ b/pandas/tests/io/formats/test_css.py @@ -287,6 +287,7 @@ def test_css_relative_font_size(size, relative_to, resolved): inherited = {"font-size": relative_to} assert_resolves(f"font-size: {size}", {"font-size": resolved}, inherited=inherited) + def test_css_atomize_preserves_number_format_case(): resolver = CSSResolver() declarations = [("NUMBER-FORMAT", '#,,"M"')] @@ -296,6 +297,7 @@ def test_css_atomize_preserves_number_format_case(): assert "number-format" in props assert props["number-format"] == '#,,"M"' + def test_css_parse_preserves_number_format_case(): resolver = CSSResolver() From daf33a91210f9e8dfdabca5176b70f7c52a2b7f4 Mon Sep 17 00:00:00 2001 From: Parth Kandharkar Date: Sun, 16 Nov 2025 12:08:11 -0800 Subject: [PATCH 4/4] Added single quotes for number format --- pandas/io/formats/css.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pandas/io/formats/css.py b/pandas/io/formats/css.py index 8ebc7479fe9df..64795296f30f0 100644 --- a/pandas/io/formats/css.py +++ b/pandas/io/formats/css.py @@ -21,18 +21,21 @@ def _normalize_number_format_value(value: str) -> str: - value = value.strip() - out: list[str] = [] - in_string = False + out = [] + in_single = False + in_double = False - for ch in value: - if ch == '"': + for ch in value.strip(): + if ch == "'" and not in_double: + in_single = not in_single + out.append(ch) + elif ch == '"' and not in_single: + in_double = not in_double + out.append(ch) + elif in_single or in_double: out.append(ch) - in_string = not in_string - elif in_string: - out.append(ch) # preserve case inside string literals else: - out.append(ch.lower()) # normalize outside literals + out.append(ch.lower()) return "".join(out)