From 81cd69928fb0230a6ed5d70b4f1f9f5b33527780 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 26 Dec 2025 17:59:24 +0000 Subject: [PATCH 1/2] fix(html): handle escaped kbd separators --- sphinx/builders/html/transforms.py | 79 ++++++++++++++++++++++++------ tests/test_markup.py | 67 +++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 14 deletions(-) diff --git a/sphinx/builders/html/transforms.py b/sphinx/builders/html/transforms.py index c91da57e993..37ba679ac42 100644 --- a/sphinx/builders/html/transforms.py +++ b/sphinx/builders/html/transforms.py @@ -9,7 +9,7 @@ """ import re -from typing import Any, Dict +from typing import Any, Dict, List, Optional, Set, Tuple from docutils import nodes @@ -37,26 +37,77 @@ class KeyboardTransform(SphinxPostTransform): """ default_priority = 400 builders = ('html',) - pattern = re.compile(r'(-|\+|\^|\s+)') + pattern = re.compile(r'(?<=\S)(?:-|\+|\^|\s+)(?=\S)') def run(self, **kwargs: Any) -> None: matcher = NodeMatcher(nodes.literal, classes=["kbd"]) for node in self.document.traverse(matcher): # type: nodes.literal - parts = self.pattern.split(node[-1].astext()) + masked_text, placeholders = self._mask_escaped_separators( + node.astext(), getattr(node, 'rawsource', None)) + parts = self.pattern.split(masked_text) if len(parts) == 1: continue - node.pop() - while parts: - key = parts.pop(0) - node += nodes.literal('', key, classes=["kbd"]) - - try: - # key separator (ex. -, +, ^) - sep = parts.pop(0) - node += nodes.Text(sep) - except IndexError: - pass + separators = self.pattern.findall(masked_text) + + node[:] = [] + for index, key in enumerate(parts): + restored_key = self._restore_placeholders(key, placeholders) + node += nodes.literal('', restored_key, classes=["kbd"]) + + if index < len(separators): + separator = self._restore_placeholders(separators[index], placeholders) + node += nodes.Text(separator) + + def _mask_escaped_separators(self, text: str, rawsource: Optional[str]) -> Tuple[str, Dict[str, str]]: + placeholders: Dict[str, str] = {} + escaped_indices = self._find_escaped_indices(text, rawsource) + if not escaped_indices: + return text, placeholders + + masked_parts: List[str] = [] + for index, char in enumerate(text): + if index in escaped_indices and char in '-+^': + placeholder = f'@KBD_ESC_{len(placeholders)}@' + placeholders[placeholder] = char + masked_parts.append(placeholder) + else: + masked_parts.append(char) + + return ''.join(masked_parts), placeholders + + def _find_escaped_indices(self, text: str, rawsource: Optional[str]) -> Set[int]: + if not rawsource: + return set() + + start = rawsource.find('`') + end = rawsource.rfind('`') + if start == -1 or end <= start: + content = rawsource + else: + content = rawsource[start + 1:end] + + indices: Set[int] = set() + text_index = 0 + pos = 0 + + while pos < len(content) and text_index < len(text): + char = content[pos] + if char == '\\' and pos + 1 < len(content) and content[pos + 1] in '-+^': + indices.add(text_index) + pos += 2 + text_index += 1 + else: + pos += 1 + text_index += 1 + + return indices + + @staticmethod + def _restore_placeholders(text: str, placeholders: Dict[str, str]) -> str: + for placeholder, char in placeholders.items(): + text = text.replace(placeholder, char) + return text def setup(app: Sphinx) -> Dict[str, Any]: diff --git a/tests/test_markup.py b/tests/test_markup.py index a2bcb2dc1fe..8f2a9c40e0a 100644 --- a/tests/test_markup.py +++ b/tests/test_markup.py @@ -240,6 +240,27 @@ def get(name): '

space

', '\\sphinxkeyboard{\\sphinxupquote{space}}', ), + ( + # kbd role literal hyphen + 'verify', + ':kbd:`-`', + '

-

', + None, + ), + ( + # kbd role literal plus + 'verify', + ':kbd:`+`', + '

+

', + None, + ), + ( + # kbd role literal caret + 'verify', + ':kbd:`^`', + '

^

', + None, + ), ( # kbd role 'verify', @@ -266,6 +287,52 @@ def get(name): '

'), '\\sphinxkeyboard{\\sphinxupquote{M\\sphinxhyphen{}x M\\sphinxhyphen{}s}}', ), + ( + # kbd role compound with literal separator key + 'verify', + ':kbd:`Shift-+`', + ('

' + 'Shift' + '-' + '+' + '

'), + None, + ), + ( + # kbd role boundary hyphen prefix + 'verify', + ':kbd:`-X`', + '

-X

', + None, + ), + ( + # kbd role boundary hyphen suffix + 'verify', + ':kbd:`X-`', + '

X-

', + None, + ), + ( + # kbd role escaped hyphen separator + 'verify', + ':kbd:`C\\-x`', + '

C-x

', + None, + ), + ( + # kbd role escaped plus separator + 'verify', + ':kbd:`A\\+B`', + '

A+B

', + None, + ), + ( + # kbd role escaped caret separator + 'verify', + ':kbd:`^\\^`', + '

^^

', + None, + ), ( # non-interpolation of dashes in option role 'verify_re', From ee0c47d7365bfb2fac70cd3cf6d19fb5d4d38e5c Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 26 Dec 2025 18:15:16 +0000 Subject: [PATCH 2/2] fix(html): align escaped index tracking --- sphinx/builders/html/transforms.py | 21 +++++++++++++++------ tests/test_markup.py | 7 +++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/sphinx/builders/html/transforms.py b/sphinx/builders/html/transforms.py index 37ba679ac42..a2204c9b549 100644 --- a/sphinx/builders/html/transforms.py +++ b/sphinx/builders/html/transforms.py @@ -90,16 +90,25 @@ def _find_escaped_indices(self, text: str, rawsource: Optional[str]) -> Set[int] indices: Set[int] = set() text_index = 0 pos = 0 + text_len = len(text) - while pos < len(content) and text_index < len(text): + while pos < len(content) and text_index < text_len: char = content[pos] - if char == '\\' and pos + 1 < len(content) and content[pos + 1] in '-+^': - indices.add(text_index) + if char == '\\': + if pos + 1 >= len(content): + pos += 1 + continue + + escaped = content[pos + 1] + if escaped in '-+^': + indices.add(text_index) + pos += 2 text_index += 1 - else: - pos += 1 - text_index += 1 + continue + + pos += 1 + text_index += 1 return indices diff --git a/tests/test_markup.py b/tests/test_markup.py index 8f2a9c40e0a..4c6911cb4a7 100644 --- a/tests/test_markup.py +++ b/tests/test_markup.py @@ -333,6 +333,13 @@ def get(name): '

^^

', None, ), + ( + # kbd role escaped separator after other escapes + 'verify', + ':kbd:`\\|\\-\\|`', + '

|-|

', + None, + ), ( # non-interpolation of dashes in option role 'verify_re',