Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions pandas/io/formats/css.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@
)


def _normalize_number_format_value(value: str) -> str:
out = []
in_single = False
in_double = False

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)
else:
out.append(ch.lower())
return "".join(out)


def _side_expander(prop_fmt: str) -> Callable:
"""
Wrapper to expand shorthand property into top, right, bottom, left properties
Expand Down Expand Up @@ -391,7 +410,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 = _normalize_number_format_value(value)
else:
value = value.lower()
if prop in self.CSS_EXPANSIONS:
expand = self.CSS_EXPANSIONS[prop]
yield from expand(self, prop, value)
Expand All @@ -414,7 +436,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 = _normalize_number_format_value(val)
else:
val = val.strip().lower()
Comment on lines 438 to +442
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One question I have is why are we doing .lower() at all? What breaks if we remove this?

Copy link
Author

@parthkandharkar parthkandharkar Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We lowercase CSS properties and values because CSS is defined as case-insensitive for all keywords and the entire Styler formatting engine is built on that assumption. If we remove .lower(), we immediately break core parts of the parser because the implementation expects all tokens to be normalized before comparison. Everything from font weights, border styles, colors and unit names is matched against internal tables that contain only lowercase entries. Without .lower(), inputs like "BOLD", "SOLID", "Red", "PX", "None", or "THIN" stop matching and fall through the logic.

if sep:
yield prop, val
else:
Expand Down
1 change: 1 addition & 0 deletions pandas/tests/io/excel/test_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;",
Expand Down
20 changes: 20 additions & 0 deletions pandas/tests/io/formats/test_css.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,23 @@ 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"
Loading