From 43fe99930732cf39e8aba959ee13199c6519ffa9 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Mon, 6 Jul 2026 19:42:07 +0200 Subject: [PATCH] Fix DimensionToken serialization for scientific-notation-like units DimensionToken._serialize_to escaped a unit's leading 'e'/'E' to stop it merging with the number into a scientific-notation number token on re-parse, but the guard was both incomplete and case-corrupting: - It hard-coded the escape '\65 ' (lowercase 'e'), so a unit 'E' round-tripped to 'e' (case flipped). - It only triggered for 'e'/'E' and 'e-'/'E-', missing 'e'/'E' followed by a digit (e3, E3, e05, ...). Those units merged into the exponent, so the dimension silently re-parsed as a number (1 + unit 'e3' -> '1e3' -> 1000.0). Escape the leading letter using its own code point (preserving case) and widen the trigger to any unit whose first char is 'e'/'E' followed by end-of-unit, a sign, or a digit. Common units (em, ex, EX, Q, ...) are unaffected. --- tests/test_tinycss2.py | 34 ++++++++++++++++++++++++++++++++++ tinycss2/ast.py | 14 +++++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/tests/test_tinycss2.py b/tests/test_tinycss2.py index fb1130a..d393f91 100644 --- a/tests/test_tinycss2.py +++ b/tests/test_tinycss2.py @@ -703,3 +703,37 @@ def test_escape_in_function_name(): function, = parse_component_value_list('\\dddf()') assert function.type == 'function' assert function.name == '\udddf' + + +def test_serialize_dimension_scientific_notation(): + # A dimension whose unit begins with 'e'/'E' followed by an optional sign + # and a digit must not be serialized in a way that merges back into a + # scientific-notation number-token, and the unit's case must be preserved. + # e.g. the dimension "1" + unit "e3" must not serialize to "1e3" (== 1000), + # and the dimension "1" + unit "E" must not become unit "e". + cases = [ + (r'1\65 ', 1, 'e'), # unit 'e' + (r'1\45 ', 1, 'E'), # unit 'E': case must survive round-trip + (r'1\65 3', 1, 'e3'), # must not merge into number 1e3 == 1000 + (r'1\45 3', 1, 'E3'), + (r'1\65 05', 1, 'e05'), # must not merge into number 1e05 == 100000 + (r'1\65 -3', 1, 'e-3'), # must not merge into number 1e-3 == 0.001 + (r'1\45 -3', 1, 'E-3'), + ] + for source, int_value, unit in cases: + token, = parse_component_value_list(source) + assert token.type == 'dimension' + assert token.int_value == int_value + assert token.unit == unit + reparsed, = parse_component_value_list(token.serialize()) + assert reparsed.type == 'dimension', ( + f'{source!r} serialized to {token.serialize()!r} which re-parsed ' + f'as a {reparsed.type}-token') + assert reparsed.value == token.value + assert reparsed.int_value == int_value + assert reparsed.unit == unit + + # Units that cannot form scientific notation stay unescaped (no over-escape). + assert parse_component_value_list('1em')[0].serialize() == '1em' + assert parse_component_value_list('1ex')[0].serialize() == '1ex' + assert parse_component_value_list('5EX')[0].serialize() == '5EX' diff --git a/tinycss2/ast.py b/tinycss2/ast.py index 2831abc..0e34430 100644 --- a/tinycss2/ast.py +++ b/tinycss2/ast.py @@ -566,10 +566,18 @@ def __init__(self, line, column, value, int_value, representation, unit): def _serialize_to(self, write): write(self.representation) - # Disambiguate with scientific notation + # Disambiguate with scientific notation. + # A unit starting with 'e' or 'E' followed by an optional sign and a + # digit would otherwise merge with the number into a single + # scientific-notation number-token when re-parsed + # (e.g. '1' + 'e3' -> '1e3', or '1' + 'E-3' -> '1E-3'). + # Escaping the leading letter prevents this; the escape must use the + # letter's own code point so that its case is preserved. unit = self.unit - if unit in ('e', 'E') or unit.startswith(('e-', 'E-')): - write('\\65 ') + first = unit[0] + if first in ('e', 'E') and ( + len(unit) == 1 or unit[1] in '+-0123456789'): + write('\\%X ' % ord(first)) write(serialize_name(unit[1:])) else: write(serialize_identifier(unit))