diff --git a/sphinx/builders/html/transforms.py b/sphinx/builders/html/transforms.py index c91da57e993..a2204c9b549 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,86 @@ 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 + text_len = len(text) + + while pos < len(content) and text_index < text_len: + char = content[pos] + 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 + continue + + 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..4c6911cb4a7 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,59 @@ 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, + ), + ( + # kbd role escaped separator after other escapes + 'verify', + ':kbd:`\\|\\-\\|`', + '

|-|

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