diff --git a/docs/intro.rst b/docs/intro.rst index 6784fb3f..ce1735e5 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -642,6 +642,8 @@ languages: ======= History ======= +0.8.4 *unreleased* ('master' branch, only) + * **Bugfix** Support ITU T.416 colon-format SGR color parameters, `PR #239`_. 0.8.3 *2026-08-28* * **Bugfix** Do not hang on `wrap()`_ calls of width 1 with text containing OSC8 hyperlinks and @@ -909,6 +911,7 @@ https://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c:: .. _`PR #230`: https://github.com/jquast/wcwidth/pull/230 .. _`PR #231`: https://github.com/jquast/wcwidth/pull/231 .. _`PR #235`: https://github.com/jquast/wcwidth/pull/235 +.. _`PR #239`: https://github.com/jquast/wcwidth/pull/239 .. _`Issue #101`: https://github.com/jquast/wcwidth/issues/101 .. _`Issue #155`: https://github.com/jquast/wcwidth/issues/155 .. _`Issue #190`: https://github.com/jquast/wcwidth/issues/190 diff --git a/tests/test_sgr_state.py b/tests/test_sgr_state.py index ecba402f..639178fa 100644 --- a/tests/test_sgr_state.py +++ b/tests/test_sgr_state.py @@ -148,6 +148,26 @@ def test_sgr_state_parse_colors_colon_format(): assert state.foreground == (38, 2, 0, 255, 0, 0) +def test_propagate_sgr_colon_format_color_is_restored(): + r""" + A restored ITU T.416 colon-format color denotes the color it was parsed from. + + ``38:2::R:G:B`` has a colour space element that the legacy + ``38;2;R;G;B`` form has no slot for, so restoring it with ';' separators shifts + R, G and B by one position and leaves a trailing parameter. + """ + for original in ('\x1b[38:2::255:0:0m', '\x1b[38:2:1:255:0:0m', '\x1b[48:2::0:0:255m'): + expected = _sgr_state_update(_SGR_STATE_DEFAULT, original) + restored = _sgr_state_to_sequence(expected) + assert _sgr_state_update(_SGR_STATE_DEFAULT, restored) == expected + + # and end-to-end, the style restored on the continuation line is the same color + continuation = wrap('\x1b[38:2::255:0:0mred text', width=4)[1] + restored_prefix = re.match(r'\x1b\[[\d;:]*m', continuation).group() + assert (_sgr_state_update(_SGR_STATE_DEFAULT, restored_prefix).foreground == + (38, 2, 0, 255, 0, 0)) + + def test_sgr_state_color_override(): """Newer color replaces older regardless of format.""" state = _sgr_state_update(_SGR_STATE_DEFAULT, '\x1b[38;5;208m') diff --git a/wcwidth/sgr_state.py b/wcwidth/sgr_state.py index 8e6e5ccf..dce06d87 100644 --- a/wcwidth/sgr_state.py +++ b/wcwidth/sgr_state.py @@ -121,6 +121,22 @@ def _sgr_state_is_active(state: _SGRState) -> bool: or state.foreground is not None or state.background is not None) +def _color_params_to_str(color: tuple[int, ...]) -> str: + """ + Join color parameters, preserving the ITU T.416 colon form where it was used. + + ``38:2::R:G:B`` carries a colour space element that the legacy + ``38;2;R;G;B`` form has no slot for, so joining it with ``;`` would shift R, G and B + one position and leave a trailing parameter. + + :param color: Color parameters as parsed by :func:`_parse_sgr_params`. + :returns: Parameter string for embedding in an SGR sequence. + """ + if len(color) > 5 and color[1] == 2: + return ':'.join(str(p) for p in color) + return ';'.join(str(p) for p in color) + + def _sgr_state_to_sequence(state: _SGRState) -> str: """ Generate minimal SGR sequence to restore this state from reset. @@ -142,9 +158,9 @@ def _sgr_state_to_sequence(state: _SGRState) -> str: # Add color params (already formatted as tuples) if state.foreground is not None: - params.append(';'.join(str(p) for p in state.foreground)) + params.append(_color_params_to_str(state.foreground)) if state.background is not None: - params.append(';'.join(str(p) for p in state.background)) + params.append(_color_params_to_str(state.background)) return f'\x1b[{";".join(params)}m'