From 357f1ef7c523e8f68b92078a2d8b0abc0f8aa3d6 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Tue, 1 Sep 2026 22:02:12 +0300 Subject: [PATCH 1/3] bugfix: restore ITU T.416 colon-format colors with colon separators _sgr_state_to_sequence() joined the parsed color parameters with ';'. A colon-format color carries the T.416 colour space element (38:2::R:G:B) that the legacy 38;2;R;G;B form has no slot for, so the restored sequence shifted R, G and B one position and left a trailing parameter, which reads as SGR 0. >>> wcwidth.wrap('\x1b[38:2::255:0:0mred text', width=4)[1] '\x1b[38;2;0;255;0;0mtext\x1b[0m' Feeding that prefix back to _sgr_state_update() yields foreground=None: the color is lost, and the trailing 0 also clears any bold/underline in the same restoration sequence. Co-authored-by: Claude --- tests/test_sgr_state.py | 19 +++++++++++++++++++ wcwidth/sgr_state.py | 20 ++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/test_sgr_state.py b/tests/test_sgr_state.py index ecba402f..4ac67596 100644 --- a/tests/test_sgr_state.py +++ b/tests/test_sgr_state.py @@ -148,6 +148,25 @@ 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' From 88d8f5f227d951869b108457e35730e8d1cb89e6 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Mon, 14 Sep 2026 15:22:57 -0400 Subject: [PATCH 2/3] small docfix --- tests/test_sgr_state.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_sgr_state.py b/tests/test_sgr_state.py index 4ac67596..639178fa 100644 --- a/tests/test_sgr_state.py +++ b/tests/test_sgr_state.py @@ -149,7 +149,8 @@ def test_sgr_state_parse_colors_colon_format(): 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. + 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 From 093a232d80bda7f99d9100ed0332ce1cf76851f7 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Mon, 14 Sep 2026 15:25:07 -0400 Subject: [PATCH 3/3] add to changelog --- docs/intro.rst | 3 +++ 1 file changed, 3 insertions(+) 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