From f541c9f4b21553267f29bf08fb7e8d8498432dad Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Sat, 10 May 2025 04:24:32 -0400 Subject: [PATCH 01/25] Working protype! * Code highlight directives accept two new options, style-light and style-dark; these override the default Pygments style with a block-specific one, but only for the block in question * Classes amended were CodeBlock, LiteralInclude, and Code; this means directives code-block, sourcecode, literalinclude, and code * A theme may support light and/or dark styles; as already done, light and dark styles must be tracked separately. For this overhaul, a data structure is created inside the HtmlBuilder; this tracks the relevant styles, their associated PygmentsBridge highlighter, and the code blocks they're associated with. * Association with code blocks is handled by using the docutil's node hash; this is used as a CSS selector. * As the Html5Translator visits elements of the docutils tree, it discovers their light and dark styles, if any. It populates the HtmlBuilder's data structure. * To finalize, the CSS sheets are created. Since the Pygments style is now a "generated" file rather than static (it depends on what style overrides document contains), the function call is moved down: the HtmlBuilder's create_pygments_style_file got moved from copy_static_files() into a task under the finish() method. * CSS selectors are grouped to supply different code blocks with the same override style, if the user so chooses (Pygments handles this internally if presented the selectors). This minimizes the length of the CSS files. They're also annotated with a comment for their override style. --- doc/usage/restructuredtext/directives.rst | 26 +++++++++-- sphinx/builders/html/__init__.py | 54 +++++++++++++++++++++-- sphinx/directives/code.py | 8 ++++ sphinx/directives/patches.py | 5 +++ sphinx/highlighting.py | 8 +++- sphinx/writers/html5.py | 53 ++++++++++++++++++---- 6 files changed, 137 insertions(+), 17 deletions(-) diff --git a/doc/usage/restructuredtext/directives.rst b/doc/usage/restructuredtext/directives.rst index 33269b522a6..2be5eb20af5 100644 --- a/doc/usage/restructuredtext/directives.rst +++ b/doc/usage/restructuredtext/directives.rst @@ -728,13 +728,15 @@ values are supported: * ... and any other `lexer alias that Pygments supports`__ If highlighting with the selected language fails (i.e. Pygments emits an -"Error" token), the block is not highlighted in any way. +"Error" token), the block is not highlighted in any way. Per-block highlighting +styles can be specified for directives :rst:dir:`code-block`, +:rst:dir:`sourcecode`, :rst:dir:`literalinclude`, and :rst:dir:`code`. .. important:: - The list of lexer aliases supported is tied to the Pygment version. If you - want to ensure consistent highlighting, you should fix your version of - Pygments. + The list of lexer and style aliases supported is tied to the Pygment + version. If you want to ensure consistent highlighting, you should fix your + version of Pygments. __ https://pygments.org/docs/lexers @@ -902,6 +904,22 @@ __ https://pygments.org/docs/lexers .. versionadded:: 1.3 .. versionchanged:: 3.5 Support automatic dedent. + + .. rst:directive:option:: style-light: style name + style-dark: style name + :type: the name of a style to use + + Pygments includes `various highlighting styles + `_, and supports `custom ones + `_ installed as + plugins. This option accepts any valid style name and will apply it to + this code block, overriding any default in :confval:`pygments_style` + config value. Some builder and theme configurations (e.g. + :ref:`HTML ` & `furo `_) will + accept both `light` and `dark` options, and switch appropriately; others + may support only one style (e.g. PDF), in which case `style-light` takes + precedence. + .. rst:directive:: .. literalinclude:: filename diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 1ba026a61d0..2d624835791 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -67,7 +67,7 @@ if TYPE_CHECKING: from collections.abc import Iterator, Set - from typing import Any, TypeAlias + from typing import Any, TypeAlias, TypedDict from docutils.nodes import Node from docutils.readers import Reader @@ -263,6 +263,15 @@ def init_highlighter(self) -> None: else: self.dark_highlighter = None + # Maps a code block's identifier to requested light and dark styles. + # This is populated by the writer / translator as it invokes + # the visit_literal_block method. + # The information is also used in the selectors of the CSS file(s). + if TYPE_CHECKING: + spec_highlighter = TypedDict('spec_highlighter', {'bridge': PygmentsBridge, 'ids': List[int]}) + self.specialized_dark_lighters: Dict[str, spec_highlighter] = {} + self.specialized_light_lighters: Dict[str, spec_highlighter] = {} + @property def css_files(self) -> list[_CascadingStyleSheet]: _deprecation_warning( @@ -685,6 +694,7 @@ def write_doc_serialized(self, docname: str, doctree: nodes.document) -> None: self.index_page(docname, doctree, title) def finish(self) -> None: + self.finish_tasks.add_task(self.create_pygments_style_file) self.finish_tasks.add_task(self.gen_indices) self.finish_tasks.add_task(self.gen_pages_from_extensions) self.finish_tasks.add_task(self.gen_additional_pages) @@ -821,16 +831,55 @@ def to_relpath(f: str) -> str: err, ) + def add_block_dark_style(self, style: str, id: int): + """Add a code-block id to the tracker of dark highlighting styles.""" + if style in self.specialized_dark_lighters: + self.specialized_dark_lighters[style]['ids'].append(id) + else: + pb = PygmentsBridge(dest='html', stylename=style) + self.specialized_dark_lighters[style] = {'bridge': pb, 'ids': [id]} + + def add_block_light_style(self, style: str, id: int): + """Add a code-block id to the tracker of light highlighting styles.""" + if style in self.specialized_light_lighters: + self.specialized_light_lighters[style]['ids'].append(id) + else: + pb = PygmentsBridge(dest='html', stylename=style) + self.specialized_light_lighters[style] = {'bridge': pb, 'ids': [id]} + + def get_bridge_for_style(self, style: str) -> PygmentsBridge | None: + """Returns the PygmentsBridge associated with a style, if any. + Searches the dark list first, then the light list, then the default dark + and light styles.""" + if style in self.specialized_dark_lighters: + return self.specialized_dark_lighters[style]['bridge'] + elif style in self.specialized_light_lighters: + return self.specialized_light_lighters[style]['bridge'] + elif self.dark_highlighter and (style == self.dark_highlighter.get_style()): + return self.dark_highlighter + elif style == self.highlighter.get_style(): + return self.highlighter + else: + return None + def create_pygments_style_file(self) -> None: - """Create a style file for pygments.""" + """Create style file(s) for Pygments.""" pyg_path = self._static_dir / 'pygments.css' with open(pyg_path, 'w', encoding='utf-8') as f: f.write(self.highlighter.get_stylesheet()) + if self.specialized_light_lighters: + for style_name, item in self.specialized_light_lighters.items(): + f.write('\n\n/* CSS for style: {} */\n'.format(style_name)) + f.write(item['bridge'].get_stylesheet(item['ids'])) if self.dark_highlighter: dark_path = self._static_dir / 'pygments_dark.css' with open(dark_path, 'w', encoding='utf-8') as f: f.write(self.dark_highlighter.get_stylesheet()) + if self.specialized_dark_lighters: + for item in self.specialized_dark_lighters.values(): + f.write('/* CSS for style: {} */'.format(item['bridge'].formatter.style)) + f.write(item['bridge'].get_stylesheet(item['ids'])) def copy_translation_js(self) -> None: """Copy a JavaScript file for translations.""" @@ -924,7 +973,6 @@ def copy_static_files(self) -> None: if self.indexer is not None: context.update(self.indexer.context_for_searchtool()) - self.create_pygments_style_file() self.copy_translation_js() self.copy_stemmer_js() self.copy_theme_static_files(context) diff --git a/sphinx/directives/code.py b/sphinx/directives/code.py index e94b18a18f0..cd99d3877df 100644 --- a/sphinx/directives/code.py +++ b/sphinx/directives/code.py @@ -114,6 +114,8 @@ class CodeBlock(SphinxDirective): 'caption': directives.unchanged_required, 'class': directives.class_option, 'name': directives.unchanged, + 'style-light': directives.unchanged, + 'style-dark': directives.unchanged, } def run(self) -> list[Node]: @@ -162,6 +164,8 @@ def run(self) -> list[Node]: self.env.current_document.highlight_language or self.config.highlight_language ) + literal['style-light'] = self.options.get('style-light') + literal['style-dark'] = self.options.get('style-dark') extra_args = literal['highlight_args'] = {} if hl_lines is not None: extra_args['hl_lines'] = hl_lines @@ -425,6 +429,8 @@ class LiteralInclude(SphinxDirective): 'lineno-match': directives.flag, 'tab-width': int, 'language': directives.unchanged_required, + 'style-light': directives.unchanged, + 'style-dark': directives.unchanged, 'force': directives.flag, 'encoding': directives.encoding, 'pyobject': directives.unchanged_required, @@ -468,6 +474,8 @@ def run(self) -> list[Node]: retnode['language'] = 'udiff' elif 'language' in self.options: retnode['language'] = self.options['language'] + retnode['style-light'] = self.options.get('style-light') + retnode['style-dark'] = self.options.get('style-dark') if ( 'linenos' in self.options or 'lineno-start' in self.options diff --git a/sphinx/directives/patches.py b/sphinx/directives/patches.py index 94184de502c..f1b694d706d 100644 --- a/sphinx/directives/patches.py +++ b/sphinx/directives/patches.py @@ -93,6 +93,8 @@ class Code(SphinxDirective): 'class': directives.class_option, 'force': directives.flag, 'name': directives.unchanged, + 'style-light': directives.unchanged, + 'style-dark': directives.unchanged, 'number-lines': optional_int, } has_content = True @@ -124,6 +126,9 @@ def run(self) -> list[Node]: or self.config.highlight_language ) + node['style-light'] = self.options.get('style-light') + node['style-dark'] = self.options.get('style-dark') + if 'number-lines' in self.options: node['linenos'] = True diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index 29cf9d26e8c..b71e5111630 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -229,9 +229,13 @@ def highlight_block( # MEMO: this is done to escape Unicode chars with non-Unicode engines return texescape.hlescape(hlsource, self.latex_engine) - def get_stylesheet(self) -> str: + def get_stylesheet(self, selectors: Optional[List[int]] = None) -> str: formatter = self.get_formatter() if self.dest == 'html': - return formatter.get_style_defs('.highlight') + if selectors: + sel = ['.c{}'.format(item) for item in selectors] + else: + sel = '.highlight' + return formatter.get_style_defs(sel) else: return formatter.get_style_defs() + _LATEX_ADD_STYLES diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index b39b463d6db..8a8282b25c2 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -600,14 +600,51 @@ def visit_literal_block(self, node: Element) -> None: if linenos and self.config.html_codeblock_linenos_style: linenos = self.config.html_codeblock_linenos_style - highlighted = self.highlighter.highlight_block( - node.rawsource, - lang, - opts=opts, - linenos=linenos, - location=node, - **highlight_args, - ) + # As blocks are processed, we discover specified styles. + block_id = hash(node) + if node.get('style-dark'): + dark_style = node.get('style-dark') + self.builder.add_block_dark_style(style=dark_style, id=block_id) + else: + dark_style = None + + if node.get('style-light'): + light_style = node.get('style-light') + self.builder.add_block_light_style(style=light_style, id=block_id) + else: + light_style = None + + # If either dark or style were requested, use their specialized + # highlighter. If neither, use the default highlighter. + if dark_style: + highlighted = self.builder.get_bridge_for_style(dark_style).highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + cssclass='highlight c{}'.format(block_id), # option for Pygment's HTML formatter, sets selector + **highlight_args, + ) + if light_style: + highlighted = self.builder.get_bridge_for_style(light_style).highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + cssclass='highlight c{}'.format(block_id), # option for Pygment's HTML formatter, sets selector + **highlight_args, + ) + if not (dark_style or light_style): + highlighted = self.highlighter.highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + **highlight_args, + ) starttag = self.starttag( node, 'div', suffix='', CLASS='highlight-%s notranslate' % lang ) From 95ef08b11125830c04a4da34778ab30852673ac2 Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Mon, 12 May 2025 23:57:14 -0400 Subject: [PATCH 02/25] Media queries for light or dark style * There is a CSS media feature that is set by the user agent: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme ; we add those specifiers to the HTML builder's light and dark Pygments style sheet writers --- sphinx/builders/html/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 2d624835791..fb38a04e6a3 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -866,20 +866,22 @@ def create_pygments_style_file(self) -> None: """Create style file(s) for Pygments.""" pyg_path = self._static_dir / 'pygments.css' with open(pyg_path, 'w', encoding='utf-8') as f: - f.write(self.highlighter.get_stylesheet()) + light_style_sheet = self.highlighter.get_stylesheet() if self.specialized_light_lighters: for style_name, item in self.specialized_light_lighters.items(): - f.write('\n\n/* CSS for style: {} */\n'.format(style_name)) - f.write(item['bridge'].get_stylesheet(item['ids'])) + light_style_sheet += '\n\n/* CSS for style: {} */\n'.format(style_name) + light_style_sheet += item['bridge'].get_stylesheet(item['ids']) + f.write('@media (prefers-color-scheme: light) {{\n\t{}\n}}'.format(light_style_sheet.replace('\n', '\n\t'))) if self.dark_highlighter: dark_path = self._static_dir / 'pygments_dark.css' with open(dark_path, 'w', encoding='utf-8') as f: - f.write(self.dark_highlighter.get_stylesheet()) + dark_style_sheet = self.dark_highlighter.get_stylesheet() if self.specialized_dark_lighters: for item in self.specialized_dark_lighters.values(): - f.write('/* CSS for style: {} */'.format(item['bridge'].formatter.style)) - f.write(item['bridge'].get_stylesheet(item['ids'])) + dark_style_sheet += ('\n\n/* CSS for style: {} */\n'.format(style_name)) + dark_style_sheet += (item['bridge'].get_stylesheet(item['ids'])) + f.write('@media (prefers-color-scheme: dark) {{\n\t{}\n}}'.format(dark_style_sheet.replace('\n', '\n\t'))) def copy_translation_js(self) -> None: """Copy a JavaScript file for translations.""" From b5c34d21dcec89d0e1caaef79300f952fddf95ab Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Thu, 29 May 2025 00:49:21 -0400 Subject: [PATCH 03/25] Unify pygments.css and pygments_dark.css * Use media queries and scopes to include light and dark styles in a single CSS file * `app.registry.css_files` apparently only tracked "extra" files, but not the default `pygments.css` file, so the `test_theming.test_dark_style` block removed that check * Added a `stylename` attribute to `PygmentsBridge` objects, so they can track the "pretty" style name used throughout Pygments, rather than relying on the classname of the associated style, which is not necessarily what is declared in the Pygments style entry-point installation --- sphinx/builders/html/__init__.py | 47 ++++++++++++++++++------------ sphinx/highlighting.py | 1 + tests/test_theming/test_theming.py | 19 ++++-------- 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index fb38a04e6a3..8a028c94339 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -255,11 +255,6 @@ def init_highlighter(self) -> None: self.dark_highlighter: PygmentsBridge | None if dark_style is not None: self.dark_highlighter = PygmentsBridge('html', dark_style) - self.app.add_css_file( - 'pygments_dark.css', - media='(prefers-color-scheme: dark)', - id='pygments_dark_css', - ) else: self.dark_highlighter = None @@ -863,25 +858,39 @@ def get_bridge_for_style(self, style: str) -> PygmentsBridge | None: return None def create_pygments_style_file(self) -> None: - """Create style file(s) for Pygments.""" + """Create style file for Pygments.""" pyg_path = self._static_dir / 'pygments.css' + root_property = '' + dark_sheet = '' with open(pyg_path, 'w', encoding='utf-8') as f: - light_style_sheet = self.highlighter.get_stylesheet() + # Process light (or the only specified) highlighting + light_sheets = [] + default_style_sheet = '/* CSS for style: {} */\n'.format(self.highlighter.stylename) + default_style_sheet += self.highlighter.get_stylesheet() + light_sheets.append(default_style_sheet) if self.specialized_light_lighters: for style_name, item in self.specialized_light_lighters.items(): - light_style_sheet += '\n\n/* CSS for style: {} */\n'.format(style_name) - light_style_sheet += item['bridge'].get_stylesheet(item['ids']) - f.write('@media (prefers-color-scheme: light) {{\n\t{}\n}}'.format(light_style_sheet.replace('\n', '\n\t'))) - - if self.dark_highlighter: - dark_path = self._static_dir / 'pygments_dark.css' - with open(dark_path, 'w', encoding='utf-8') as f: - dark_style_sheet = self.dark_highlighter.get_stylesheet() + some_sheet = '/* CSS for style: {} */\n'.format(style_name) + some_sheet += item['bridge'].get_stylesheet(item['ids']) + light_sheets.append(some_sheet) + light_sheet = '\n\n'.join(light_sheets) + # If there is a dark option, adjust the light sheet, then process the darkness + if self.dark_highlighter or self.specialized_dark_lighters: + root_property = ':root {\n\tcolor-scheme: light dark;\n}\n\n' + light_sheet = '@media (prefers-color-scheme: light) {{\n\t{}\n}}\n'.format(light_sheet.replace('\n', '\n\t')) + dark_sheets = [] + if self.dark_highlighter: + dark_style_sheet = '/* CSS for style: {} */\n'.format(self.dark_highlighter.stylename) + dark_style_sheet += self.dark_highlighter.get_stylesheet() + dark_sheets.append(dark_style_sheet) if self.specialized_dark_lighters: - for item in self.specialized_dark_lighters.values(): - dark_style_sheet += ('\n\n/* CSS for style: {} */\n'.format(style_name)) - dark_style_sheet += (item['bridge'].get_stylesheet(item['ids'])) - f.write('@media (prefers-color-scheme: dark) {{\n\t{}\n}}'.format(dark_style_sheet.replace('\n', '\n\t'))) + for style_name, item in self.specialized_dark_lighters.items(): + some_sheet = '/* CSS for style: {} */\n'.format(style_name) + some_sheet += item['bridge'].get_stylesheet(item['ids']) + dark_sheets.append(some_sheet) + dark_sheet = '\n\n'.join(dark_sheets) + dark_sheet = '@media (prefers-color-scheme: dark) {{\n\t{}\n}}'.format(dark_sheet.replace('\n', '\n\t')) + f.write(root_property + light_sheet + dark_sheet) def copy_translation_js(self) -> None: """Copy a JavaScript file for translations.""" diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index b71e5111630..d652028c9fd 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -111,6 +111,7 @@ def __init__( self.latex_engine = latex_engine style = self.get_style(stylename) + self.stylename = stylename self.formatter_args: dict[str, Any] = {'style': style} if dest == 'html': self.formatter: type[Formatter[str]] = self.html_formatter diff --git a/tests/test_theming/test_theming.py b/tests/test_theming/test_theming.py index 173e0c9c64b..74131c64854 100644 --- a/tests/test_theming/test_theming.py +++ b/tests/test_theming/test_theming.py @@ -153,32 +153,23 @@ def test_staticfiles(app: SphinxTestApp) -> None: def test_dark_style(app, monkeypatch): monkeypatch.setattr(sphinx.builders.html, '_file_checksum', lambda o, f: '') - style = app.builder.dark_highlighter.formatter_args.get('style') - assert style.__name__ == 'MonokaiStyle' + assert app.builder.dark_highlighter.stylename == 'monokai' app.build() - assert (app.outdir / '_static' / 'pygments_dark.css').exists() + assert (app.outdir / '_static' / 'pygments.css').exists() - css_file, properties = app.registry.css_files[0] - assert css_file == 'pygments_dark.css' - assert 'media' in properties - assert properties['media'] == '(prefers-color-scheme: dark)' + result = (app.outdir / '_static' / 'pygments.css').read_text(encoding='utf8') + assert '@media (prefers-color-scheme: dark)' in result assert sorted(f.filename for f in app.builder._css_files) == [ '_static/classic.css', - '_static/pygments.css', - '_static/pygments_dark.css', + '_static/pygments.css' ] result = (app.outdir / 'index.html').read_text(encoding='utf8') assert ( '' ) in result - assert ( - '' - ) in result @pytest.mark.sphinx('html', testroot='theming') From 33a2183877569cda374cefa05e6e15de92dd821a Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Thu, 29 May 2025 18:09:29 -0400 Subject: [PATCH 04/25] Revert "Unify pygments.css and pygments_dark.css" This reverts commit b5c34d21dcec89d0e1caaef79300f952fddf95ab. --- sphinx/builders/html/__init__.py | 47 ++++++++++++------------------ sphinx/highlighting.py | 1 - tests/test_theming/test_theming.py | 19 ++++++++---- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 8a028c94339..fb38a04e6a3 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -255,6 +255,11 @@ def init_highlighter(self) -> None: self.dark_highlighter: PygmentsBridge | None if dark_style is not None: self.dark_highlighter = PygmentsBridge('html', dark_style) + self.app.add_css_file( + 'pygments_dark.css', + media='(prefers-color-scheme: dark)', + id='pygments_dark_css', + ) else: self.dark_highlighter = None @@ -858,39 +863,25 @@ def get_bridge_for_style(self, style: str) -> PygmentsBridge | None: return None def create_pygments_style_file(self) -> None: - """Create style file for Pygments.""" + """Create style file(s) for Pygments.""" pyg_path = self._static_dir / 'pygments.css' - root_property = '' - dark_sheet = '' with open(pyg_path, 'w', encoding='utf-8') as f: - # Process light (or the only specified) highlighting - light_sheets = [] - default_style_sheet = '/* CSS for style: {} */\n'.format(self.highlighter.stylename) - default_style_sheet += self.highlighter.get_stylesheet() - light_sheets.append(default_style_sheet) + light_style_sheet = self.highlighter.get_stylesheet() if self.specialized_light_lighters: for style_name, item in self.specialized_light_lighters.items(): - some_sheet = '/* CSS for style: {} */\n'.format(style_name) - some_sheet += item['bridge'].get_stylesheet(item['ids']) - light_sheets.append(some_sheet) - light_sheet = '\n\n'.join(light_sheets) - # If there is a dark option, adjust the light sheet, then process the darkness - if self.dark_highlighter or self.specialized_dark_lighters: - root_property = ':root {\n\tcolor-scheme: light dark;\n}\n\n' - light_sheet = '@media (prefers-color-scheme: light) {{\n\t{}\n}}\n'.format(light_sheet.replace('\n', '\n\t')) - dark_sheets = [] - if self.dark_highlighter: - dark_style_sheet = '/* CSS for style: {} */\n'.format(self.dark_highlighter.stylename) - dark_style_sheet += self.dark_highlighter.get_stylesheet() - dark_sheets.append(dark_style_sheet) + light_style_sheet += '\n\n/* CSS for style: {} */\n'.format(style_name) + light_style_sheet += item['bridge'].get_stylesheet(item['ids']) + f.write('@media (prefers-color-scheme: light) {{\n\t{}\n}}'.format(light_style_sheet.replace('\n', '\n\t'))) + + if self.dark_highlighter: + dark_path = self._static_dir / 'pygments_dark.css' + with open(dark_path, 'w', encoding='utf-8') as f: + dark_style_sheet = self.dark_highlighter.get_stylesheet() if self.specialized_dark_lighters: - for style_name, item in self.specialized_dark_lighters.items(): - some_sheet = '/* CSS for style: {} */\n'.format(style_name) - some_sheet += item['bridge'].get_stylesheet(item['ids']) - dark_sheets.append(some_sheet) - dark_sheet = '\n\n'.join(dark_sheets) - dark_sheet = '@media (prefers-color-scheme: dark) {{\n\t{}\n}}'.format(dark_sheet.replace('\n', '\n\t')) - f.write(root_property + light_sheet + dark_sheet) + for item in self.specialized_dark_lighters.values(): + dark_style_sheet += ('\n\n/* CSS for style: {} */\n'.format(style_name)) + dark_style_sheet += (item['bridge'].get_stylesheet(item['ids'])) + f.write('@media (prefers-color-scheme: dark) {{\n\t{}\n}}'.format(dark_style_sheet.replace('\n', '\n\t'))) def copy_translation_js(self) -> None: """Copy a JavaScript file for translations.""" diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index d652028c9fd..b71e5111630 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -111,7 +111,6 @@ def __init__( self.latex_engine = latex_engine style = self.get_style(stylename) - self.stylename = stylename self.formatter_args: dict[str, Any] = {'style': style} if dest == 'html': self.formatter: type[Formatter[str]] = self.html_formatter diff --git a/tests/test_theming/test_theming.py b/tests/test_theming/test_theming.py index 74131c64854..173e0c9c64b 100644 --- a/tests/test_theming/test_theming.py +++ b/tests/test_theming/test_theming.py @@ -153,23 +153,32 @@ def test_staticfiles(app: SphinxTestApp) -> None: def test_dark_style(app, monkeypatch): monkeypatch.setattr(sphinx.builders.html, '_file_checksum', lambda o, f: '') - assert app.builder.dark_highlighter.stylename == 'monokai' + style = app.builder.dark_highlighter.formatter_args.get('style') + assert style.__name__ == 'MonokaiStyle' app.build() - assert (app.outdir / '_static' / 'pygments.css').exists() + assert (app.outdir / '_static' / 'pygments_dark.css').exists() - result = (app.outdir / '_static' / 'pygments.css').read_text(encoding='utf8') - assert '@media (prefers-color-scheme: dark)' in result + css_file, properties = app.registry.css_files[0] + assert css_file == 'pygments_dark.css' + assert 'media' in properties + assert properties['media'] == '(prefers-color-scheme: dark)' assert sorted(f.filename for f in app.builder._css_files) == [ '_static/classic.css', - '_static/pygments.css' + '_static/pygments.css', + '_static/pygments_dark.css', ] result = (app.outdir / 'index.html').read_text(encoding='utf8') assert ( '' ) in result + assert ( + '' + ) in result @pytest.mark.sphinx('html', testroot='theming') From cac180824185ef3baed30aa4474b8c520028ea33 Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Thu, 29 May 2025 18:39:40 -0400 Subject: [PATCH 05/25] Use separate style sheets; leave media queries out * `python_docs_theme` uses separate CSS files for light and dark modes, with a simple javascript function to toggle what is shown. It doesn't decorate either CSS file with a `@media` query. This seems advantageous, allowing the user to select their preferred view, and aligns with the philosophy that "the theme is not part of the document, but just a view of it" (paraphrased from MDN) * bugfix: specialized dark iterator used wrong value for CSS sheet building --- sphinx/builders/html/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index fb38a04e6a3..35bd7e1fba0 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -866,22 +866,24 @@ def create_pygments_style_file(self) -> None: """Create style file(s) for Pygments.""" pyg_path = self._static_dir / 'pygments.css' with open(pyg_path, 'w', encoding='utf-8') as f: - light_style_sheet = self.highlighter.get_stylesheet() + light_style_sheet = '/* CSS for style: {} */\n'.format(self.highlighter.formatter_args.get('style').name) + light_style_sheet += self.highlighter.get_stylesheet() if self.specialized_light_lighters: for style_name, item in self.specialized_light_lighters.items(): light_style_sheet += '\n\n/* CSS for style: {} */\n'.format(style_name) light_style_sheet += item['bridge'].get_stylesheet(item['ids']) - f.write('@media (prefers-color-scheme: light) {{\n\t{}\n}}'.format(light_style_sheet.replace('\n', '\n\t'))) + f.write(light_style_sheet) if self.dark_highlighter: dark_path = self._static_dir / 'pygments_dark.css' with open(dark_path, 'w', encoding='utf-8') as f: - dark_style_sheet = self.dark_highlighter.get_stylesheet() + dark_style_sheet = '/* CSS for style: {} */\n'.format(self.dark_highlighter.formatter_args.get('style').name) + dark_style_sheet += self.dark_highlighter.get_stylesheet() if self.specialized_dark_lighters: - for item in self.specialized_dark_lighters.values(): + for style_name, item in self.specialized_dark_lighters.items(): dark_style_sheet += ('\n\n/* CSS for style: {} */\n'.format(style_name)) dark_style_sheet += (item['bridge'].get_stylesheet(item['ids'])) - f.write('@media (prefers-color-scheme: dark) {{\n\t{}\n}}'.format(dark_style_sheet.replace('\n', '\n\t'))) + f.write(dark_style_sheet) def copy_translation_js(self) -> None: """Copy a JavaScript file for translations.""" From a96870fce543528280fde91e17deb78309a3cebd Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Fri, 30 May 2025 16:20:53 -0400 Subject: [PATCH 06/25] Fix `singlehtml` builder --- sphinx/builders/singlehtml.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sphinx/builders/singlehtml.py b/sphinx/builders/singlehtml.py index c95603927ce..b018ed122d1 100644 --- a/sphinx/builders/singlehtml.py +++ b/sphinx/builders/singlehtml.py @@ -182,6 +182,7 @@ def finish(self) -> None: self.copy_image_files() self.copy_download_files() self.copy_static_files() + self.create_pygments_style_file() self.copy_extra_files() self.write_buildinfo() self.dump_inventory() From 3b2d3c9276136c40cc442b68a94fe350acf17868 Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Fri, 30 May 2025 19:20:50 -0400 Subject: [PATCH 07/25] Remove unnecessary parenthesis --- sphinx/builders/html/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 35bd7e1fba0..6ecb0d7a770 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -881,8 +881,8 @@ def create_pygments_style_file(self) -> None: dark_style_sheet += self.dark_highlighter.get_stylesheet() if self.specialized_dark_lighters: for style_name, item in self.specialized_dark_lighters.items(): - dark_style_sheet += ('\n\n/* CSS for style: {} */\n'.format(style_name)) - dark_style_sheet += (item['bridge'].get_stylesheet(item['ids'])) + dark_style_sheet += '\n\n/* CSS for style: {} */\n'.format(style_name) + dark_style_sheet += item['bridge'].get_stylesheet(item['ids']) f.write(dark_style_sheet) def copy_translation_js(self) -> None: From 6eebcf96ef375ca0eafdb9c8d2ca5bec4e52544b Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Fri, 30 May 2025 19:22:56 -0400 Subject: [PATCH 08/25] Add LaTeX builder * As for the HTML builder, the building of the style sheet is moved from the `prepare_writing` method to the `finish` one; overriding styles are discovered on-the-fly, so the `.sty` file can only be finalized after the document's nodes have been visited --- sphinx/builders/latex/__init__.py | 26 +++++++++++++++++++++++++- sphinx/writers/latex.py | 29 +++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index 5aeafca8bfd..dda072f98ce 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -133,6 +133,7 @@ def init(self) -> None: self.docnames: Iterable[str] = {} self.document_data: list[tuple[str, str, str, str, str, bool]] = [] self.themes = ThemeFactory(self.app) + self.specialized_highlighters: Dict[str, highlighting.PygmentsBridge] = {} texescape.init() self.init_context() @@ -275,6 +276,22 @@ def init_multilingual(self) -> None: self.context['multilingual'] = f'{self.context["polyglossia"]}\n{language}' + def add_block_style(self, style: str): + """Add a styler to the tracker of highlighting styles.""" + if style not in self.specialized_highlighters: + pb = highlighting.PygmentsBridge(dest='latex', stylename=style) + pb.formatter_args['commandprefix'] = 'PYG' + style + self.specialized_highlighters[style] = pb + + def get_bridge_for_style(self, style: str) -> highlighting.PygmentsBridge | None: + """Returns the PygmentsBridge associated with a style, if any. + Since the default highlighter is initialized and discarded in self.write_stylesheet(), + it is not supported in this search.""" + if style in self.specialized_highlighters: + return self.specialized_highlighters[style] + else: + return None + def write_stylesheet(self) -> None: highlighter = highlighting.PygmentsBridge('latex', self.config.pygments_style) stylesheet = self.outdir / 'sphinxhighlight.sty' @@ -288,10 +305,16 @@ def write_stylesheet(self) -> None: '% Its contents depend on pygments_style configuration variable.\n\n' ) f.write(highlighter.get_stylesheet()) + if self.specialized_highlighters: + specialized_styles = [] + for style_name, pyg_bridge in self.specialized_highlighters.items(): + specialized_style = '\n% Stylesheet for style {}'.format(style_name) + specialized_style += pyg_bridge.get_stylesheet() + specialized_styles.append(specialized_style) + f.write('\n'.join(specialized_styles)) def prepare_writing(self, docnames: Set[str]) -> None: self.init_document_data() - self.write_stylesheet() def copy_assets(self) -> None: self.copy_support_files() @@ -422,6 +445,7 @@ def assemble_doctree( return largetree def finish(self) -> None: + self.write_stylesheet() self.copy_image_files() self.write_message_catalog() diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 5d9bb9bef9c..08eeaa80a3d 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -2210,14 +2210,27 @@ def visit_literal_block(self, node: Element) -> None: highlight_args['force'] = node.get('force', False) opts = self.config.highlight_options.get(lang, {}) - hlcode = self.highlighter.highlight_block( - node.rawsource, - lang, - opts=opts, - linenos=linenos, - location=node, - **highlight_args, - ) + # As blocks are processed, we discover specified styles. + if node.get('style-light'): + code_style = node.get('style-light') + self.builder.add_block_style(style=code_style) + hlcode = self.builder.get_bridge_for_style(code_style).highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + **highlight_args, + ) + else: + hlcode = self.highlighter.highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + **highlight_args, + ) if self.in_footnote: self.body.append(CR + r'\sphinxSetupCodeBlockInFootnote') hlcode = hlcode.replace(r'\begin{Verbatim}', r'\begin{sphinxVerbatim}') From 5c2d244d2f5e35c7b3165df6a2f8a580c2e74152 Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Mon, 2 Jun 2025 18:14:39 -0400 Subject: [PATCH 09/25] Lint & style with Ruff --- CHANGES.rst | 5 +++++ sphinx/builders/html/__init__.py | 25 +++++++++++++++---------- sphinx/builders/latex/__init__.py | 9 +++++---- sphinx/highlighting.py | 2 +- sphinx/writers/html5.py | 4 ++-- 5 files changed, 28 insertions(+), 17 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index f575efab7c7..5fe58ecdc38 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -27,6 +27,11 @@ Features added * #13704: autodoc: Detect :py:func:`typing_extensions.overload ` and :py:func:`~typing.final` decorators. Patch by Spencer Brown. +* Allow `Pygments style `_ overriding on a per-block + basis via new options (:rst:dir:`code-block:style-light` and + :rst:dir:`code-block:style-dark`) for the :rst:dir:`code-block`, + :rst:dir:`sourcecode`, :rst:dir:`literalinclude` and :rst:dir:`code`. + Patch by Héctor Medina. Bugs fixed ---------- diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 6ecb0d7a770..4788e98a425 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -268,9 +268,11 @@ def init_highlighter(self) -> None: # the visit_literal_block method. # The information is also used in the selectors of the CSS file(s). if TYPE_CHECKING: - spec_highlighter = TypedDict('spec_highlighter', {'bridge': PygmentsBridge, 'ids': List[int]}) - self.specialized_dark_lighters: Dict[str, spec_highlighter] = {} - self.specialized_light_lighters: Dict[str, spec_highlighter] = {} + class spec_highlighter(TypedDict): + bridge: PygmentsBridge + ids: list[int] + self.specialized_dark_lighters: dict[str, spec_highlighter] = {} + self.specialized_light_lighters: dict[str, spec_highlighter] = {} @property def css_files(self) -> list[_CascadingStyleSheet]: @@ -831,26 +833,27 @@ def to_relpath(f: str) -> str: err, ) - def add_block_dark_style(self, style: str, id: int): + def add_block_dark_style(self, style: str, id: int) -> None: """Add a code-block id to the tracker of dark highlighting styles.""" if style in self.specialized_dark_lighters: self.specialized_dark_lighters[style]['ids'].append(id) else: pb = PygmentsBridge(dest='html', stylename=style) self.specialized_dark_lighters[style] = {'bridge': pb, 'ids': [id]} - - def add_block_light_style(self, style: str, id: int): + + def add_block_light_style(self, style: str, id: int) -> None: """Add a code-block id to the tracker of light highlighting styles.""" if style in self.specialized_light_lighters: self.specialized_light_lighters[style]['ids'].append(id) else: pb = PygmentsBridge(dest='html', stylename=style) self.specialized_light_lighters[style] = {'bridge': pb, 'ids': [id]} - + def get_bridge_for_style(self, style: str) -> PygmentsBridge | None: """Returns the PygmentsBridge associated with a style, if any. Searches the dark list first, then the light list, then the default dark - and light styles.""" + and light styles. + """ if style in self.specialized_dark_lighters: return self.specialized_dark_lighters[style]['bridge'] elif style in self.specialized_light_lighters: @@ -866,7 +869,8 @@ def create_pygments_style_file(self) -> None: """Create style file(s) for Pygments.""" pyg_path = self._static_dir / 'pygments.css' with open(pyg_path, 'w', encoding='utf-8') as f: - light_style_sheet = '/* CSS for style: {} */\n'.format(self.highlighter.formatter_args.get('style').name) + light_style_name = self.highlighter.formatter_args.get('style').name + light_style_sheet = '/* CSS for style: {} */\n'.format(light_style_name) light_style_sheet += self.highlighter.get_stylesheet() if self.specialized_light_lighters: for style_name, item in self.specialized_light_lighters.items(): @@ -877,7 +881,8 @@ def create_pygments_style_file(self) -> None: if self.dark_highlighter: dark_path = self._static_dir / 'pygments_dark.css' with open(dark_path, 'w', encoding='utf-8') as f: - dark_style_sheet = '/* CSS for style: {} */\n'.format(self.dark_highlighter.formatter_args.get('style').name) + dark_style_name = self.dark_highlighter.formatter_args.get('style').name + dark_style_sheet = '/* CSS for style: {} */\n'.format(dark_style_name) dark_style_sheet += self.dark_highlighter.get_stylesheet() if self.specialized_dark_lighters: for style_name, item in self.specialized_dark_lighters.items(): diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index dda072f98ce..ade6c038759 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -133,7 +133,7 @@ def init(self) -> None: self.docnames: Iterable[str] = {} self.document_data: list[tuple[str, str, str, str, str, bool]] = [] self.themes = ThemeFactory(self.app) - self.specialized_highlighters: Dict[str, highlighting.PygmentsBridge] = {} + self.specialized_highlighters: dict[str, highlighting.PygmentsBridge] = {} texescape.init() self.init_context() @@ -276,17 +276,18 @@ def init_multilingual(self) -> None: self.context['multilingual'] = f'{self.context["polyglossia"]}\n{language}' - def add_block_style(self, style: str): + def add_block_style(self, style: str) -> None: """Add a styler to the tracker of highlighting styles.""" if style not in self.specialized_highlighters: pb = highlighting.PygmentsBridge(dest='latex', stylename=style) pb.formatter_args['commandprefix'] = 'PYG' + style self.specialized_highlighters[style] = pb - + def get_bridge_for_style(self, style: str) -> highlighting.PygmentsBridge | None: """Returns the PygmentsBridge associated with a style, if any. Since the default highlighter is initialized and discarded in self.write_stylesheet(), - it is not supported in this search.""" + it is not supported in this search. + """ if style in self.specialized_highlighters: return self.specialized_highlighters[style] else: diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index b71e5111630..4cf13379575 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -229,7 +229,7 @@ def highlight_block( # MEMO: this is done to escape Unicode chars with non-Unicode engines return texescape.hlescape(hlsource, self.latex_engine) - def get_stylesheet(self, selectors: Optional[List[int]] = None) -> str: + def get_stylesheet(self, selectors: list[int] | None = None) -> str: formatter = self.get_formatter() if self.dest == 'html': if selectors: diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index 8a8282b25c2..28b128b9fa9 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -623,7 +623,7 @@ def visit_literal_block(self, node: Element) -> None: opts=opts, linenos=linenos, location=node, - cssclass='highlight c{}'.format(block_id), # option for Pygment's HTML formatter, sets selector + cssclass='highlight c{}'.format(block_id), **highlight_args, ) if light_style: @@ -633,7 +633,7 @@ def visit_literal_block(self, node: Element) -> None: opts=opts, linenos=linenos, location=node, - cssclass='highlight c{}'.format(block_id), # option for Pygment's HTML formatter, sets selector + cssclass='highlight c{}'.format(block_id), **highlight_args, ) if not (dark_style or light_style): From 37a018fd3221ef6d490a8bef52c9a365fa042fb8 Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Mon, 2 Jun 2025 18:53:26 -0400 Subject: [PATCH 10/25] Provide usage examples in docs for new directive options --- doc/usage/restructuredtext/directives.rst | 33 +++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/doc/usage/restructuredtext/directives.rst b/doc/usage/restructuredtext/directives.rst index 2be5eb20af5..1500a339a08 100644 --- a/doc/usage/restructuredtext/directives.rst +++ b/doc/usage/restructuredtext/directives.rst @@ -915,10 +915,39 @@ __ https://pygments.org/docs/lexers plugins. This option accepts any valid style name and will apply it to this code block, overriding any default in :confval:`pygments_style` config value. Some builder and theme configurations (e.g. - :ref:`HTML ` & `furo `_) will + :ref:`HTML ` & `Python Docs Theme `_) will accept both `light` and `dark` options, and switch appropriately; others may support only one style (e.g. PDF), in which case `style-light` takes - precedence. + precedence. For example:: + + .. code-block:: python + + print('Code with default styling') + + + Renders as: + + .. code-block:: python + + print('Code with default styling') + + + While this code:: + + .. code-block:: python + :style-light: tango + + print('Code with a style override') + + + Renders as: + + .. code-block:: python + :style-light: tango + + print('Code with a style override') + + .. versionadded:: 8.3 .. rst:directive:: .. literalinclude:: filename From a26d133cd0288c3c9131470944d6e90eac5c0e62 Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Mon, 2 Jun 2025 19:01:45 -0400 Subject: [PATCH 11/25] Add name to contributor list --- AUTHORS.rst | 1 + CHANGES.rst | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 43a8da3469d..dc1f87b6529 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -58,6 +58,7 @@ Contributors * Filip Vavera -- napoleon todo directive * Glenn Matthews -- python domain signature improvements * Gregory Szorc -- performance improvements +* Héctor Medina Abarca -- per-code-block highlighting style overrides * Henrique Bastos -- SVG support for graphviz extension * Hernan Grecco -- search improvements * Hong Xu -- svg support in imgmath extension and various bug fixes diff --git a/CHANGES.rst b/CHANGES.rst index 5fe58ecdc38..c1a7f1ac008 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -31,7 +31,7 @@ Features added basis via new options (:rst:dir:`code-block:style-light` and :rst:dir:`code-block:style-dark`) for the :rst:dir:`code-block`, :rst:dir:`sourcecode`, :rst:dir:`literalinclude` and :rst:dir:`code`. - Patch by Héctor Medina. + Patch by Héctor Medina Abarca. Bugs fixed ---------- From 349e051ef339a54f7842aa5227f846923772a88f Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Mon, 2 Jun 2025 20:26:07 -0400 Subject: [PATCH 12/25] Sphinx-lint; remove trailing whitespaces --- CHANGES.rst | 4 ++-- doc/usage/restructuredtext/directives.rst | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index c1a7f1ac008..a36cd9a33ad 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -27,8 +27,8 @@ Features added * #13704: autodoc: Detect :py:func:`typing_extensions.overload ` and :py:func:`~typing.final` decorators. Patch by Spencer Brown. -* Allow `Pygments style `_ overriding on a per-block - basis via new options (:rst:dir:`code-block:style-light` and +* Allow `Pygments style `_ overriding on a per-block + basis via new options (:rst:dir:`code-block:style-light` and :rst:dir:`code-block:style-dark`) for the :rst:dir:`code-block`, :rst:dir:`sourcecode`, :rst:dir:`literalinclude` and :rst:dir:`code`. Patch by Héctor Medina Abarca. diff --git a/doc/usage/restructuredtext/directives.rst b/doc/usage/restructuredtext/directives.rst index 1500a339a08..532c2f9fd65 100644 --- a/doc/usage/restructuredtext/directives.rst +++ b/doc/usage/restructuredtext/directives.rst @@ -904,7 +904,7 @@ __ https://pygments.org/docs/lexers .. versionadded:: 1.3 .. versionchanged:: 3.5 Support automatic dedent. - + .. rst:directive:option:: style-light: style name style-dark: style name :type: the name of a style to use @@ -923,7 +923,7 @@ __ https://pygments.org/docs/lexers .. code-block:: python print('Code with default styling') - + Renders as: From e1a7929d5f90030f5c250ac3ce714a6ace3e9dcf Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Mon, 2 Jun 2025 20:41:20 -0400 Subject: [PATCH 13/25] Bugfix: fetcher of PygmentsBridge objects from string had wrong parameter --- sphinx/builders/html/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 4788e98a425..0166aae96aa 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -858,9 +858,9 @@ def get_bridge_for_style(self, style: str) -> PygmentsBridge | None: return self.specialized_dark_lighters[style]['bridge'] elif style in self.specialized_light_lighters: return self.specialized_light_lighters[style]['bridge'] - elif self.dark_highlighter and (style == self.dark_highlighter.get_style()): + elif self.dark_highlighter and (style == self.dark_highlighter.get_style(style).name): return self.dark_highlighter - elif style == self.highlighter.get_style(): + elif style == self.highlighter.get_style(style).name: return self.highlighter else: return None From 5bc8dbfcc1c72a97d6ae904c9c5994616c47f8ca Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Mon, 2 Jun 2025 23:12:31 -0400 Subject: [PATCH 14/25] Properly catch Nones; pass `mypy` --- sphinx/builders/html/__init__.py | 46 +++++++++++++++++++------------- sphinx/highlighting.py | 5 ++-- sphinx/writers/html5.py | 46 +++++++++++++++++++------------- sphinx/writers/latex.py | 20 ++++++++------ 4 files changed, 69 insertions(+), 48 deletions(-) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 0166aae96aa..f254e412c1f 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -868,27 +868,35 @@ def get_bridge_for_style(self, style: str) -> PygmentsBridge | None: def create_pygments_style_file(self) -> None: """Create style file(s) for Pygments.""" pyg_path = self._static_dir / 'pygments.css' - with open(pyg_path, 'w', encoding='utf-8') as f: - light_style_name = self.highlighter.formatter_args.get('style').name - light_style_sheet = '/* CSS for style: {} */\n'.format(light_style_name) - light_style_sheet += self.highlighter.get_stylesheet() - if self.specialized_light_lighters: - for style_name, item in self.specialized_light_lighters.items(): - light_style_sheet += '\n\n/* CSS for style: {} */\n'.format(style_name) - light_style_sheet += item['bridge'].get_stylesheet(item['ids']) - f.write(light_style_sheet) + light_style = self.highlighter.formatter_args.get('style') + if light_style is None: + logger.warning(__('Default highlighter has no set style')) + else: + with open(pyg_path, 'w', encoding='utf-8') as f: + light_style_name = light_style.name + light_style_sheet = '/* CSS for style: {} */\n'.format(light_style_name) + light_style_sheet += self.highlighter.get_stylesheet() + if self.specialized_light_lighters: + for s_name, item in self.specialized_light_lighters.items(): + light_style_sheet += '\n\n/* CSS for style: {} */\n'.format(s_name) + light_style_sheet += item['bridge'].get_stylesheet(item['ids']) + f.write(light_style_sheet) if self.dark_highlighter: - dark_path = self._static_dir / 'pygments_dark.css' - with open(dark_path, 'w', encoding='utf-8') as f: - dark_style_name = self.dark_highlighter.formatter_args.get('style').name - dark_style_sheet = '/* CSS for style: {} */\n'.format(dark_style_name) - dark_style_sheet += self.dark_highlighter.get_stylesheet() - if self.specialized_dark_lighters: - for style_name, item in self.specialized_dark_lighters.items(): - dark_style_sheet += '\n\n/* CSS for style: {} */\n'.format(style_name) - dark_style_sheet += item['bridge'].get_stylesheet(item['ids']) - f.write(dark_style_sheet) + dark_style = self.dark_highlighter.formatter_args.get('style') + if dark_style is None: + logger.warning(__('Default dark highlighter has no set style')) + else: + dark_path = self._static_dir / 'pygments_dark.css' + with open(dark_path, 'w', encoding='utf-8') as f: + dark_style_name = dark_style.name + dark_style_sheet = '/* CSS for style: {} */\n'.format(dark_style_name) + dark_style_sheet += self.dark_highlighter.get_stylesheet() + if self.specialized_dark_lighters: + for s_name, item in self.specialized_dark_lighters.items(): + dark_style_sheet += '\n\n/* CSS for style: {} */\n'.format(s_name) + dark_style_sheet += item['bridge'].get_stylesheet(item['ids']) + f.write(dark_style_sheet) def copy_translation_js(self) -> None: """Copy a JavaScript file for translations.""" diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index 4cf13379575..52ef1d3de6e 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -233,9 +233,8 @@ def get_stylesheet(self, selectors: list[int] | None = None) -> str: formatter = self.get_formatter() if self.dest == 'html': if selectors: - sel = ['.c{}'.format(item) for item in selectors] + return formatter.get_style_defs(['.c{}'.format(s) for s in selectors]) else: - sel = '.highlight' - return formatter.get_style_defs(sel) + return formatter.get_style_defs('.highlight') else: return formatter.get_style_defs() + _LATEX_ADD_STYLES diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index 28b128b9fa9..471d23b6699 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -617,25 +617,35 @@ def visit_literal_block(self, node: Element) -> None: # If either dark or style were requested, use their specialized # highlighter. If neither, use the default highlighter. if dark_style: - highlighted = self.builder.get_bridge_for_style(dark_style).highlight_block( - node.rawsource, - lang, - opts=opts, - linenos=linenos, - location=node, - cssclass='highlight c{}'.format(block_id), - **highlight_args, - ) + pb = self.builder.get_bridge_for_style(dark_style) + if pb is None: + logger.warning( + __("PygmentsBridge for style {} not found".format(dark_style))) + else: + highlighted = pb.highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + cssclass='highlight c{}'.format(block_id), + **highlight_args, + ) if light_style: - highlighted = self.builder.get_bridge_for_style(light_style).highlight_block( - node.rawsource, - lang, - opts=opts, - linenos=linenos, - location=node, - cssclass='highlight c{}'.format(block_id), - **highlight_args, - ) + pb = self.builder.get_bridge_for_style(light_style) + if pb is None: + logger.warning( + __("PygmentsBridge for style {} not found".format(light_style))) + else: + highlighted = pb.highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + cssclass='highlight c{}'.format(block_id), + **highlight_args, + ) if not (dark_style or light_style): highlighted = self.highlighter.highlight_block( node.rawsource, diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 08eeaa80a3d..bbee4530432 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -2214,14 +2214,18 @@ def visit_literal_block(self, node: Element) -> None: if node.get('style-light'): code_style = node.get('style-light') self.builder.add_block_style(style=code_style) - hlcode = self.builder.get_bridge_for_style(code_style).highlight_block( - node.rawsource, - lang, - opts=opts, - linenos=linenos, - location=node, - **highlight_args, - ) + pb = self.builder.get_bridge_for_style(code_style) + if pb is None: + logger.warning(__("PygmentsBridge for style {} not found".format(code_style))) + else: + hlcode = pb.highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + **highlight_args, + ) else: hlcode = self.highlighter.highlight_block( node.rawsource, From ae6290887b8331653204140f8df22f00f90b61ca Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Mon, 2 Jun 2025 23:16:41 -0400 Subject: [PATCH 15/25] Ruff, round 2 --- sphinx/writers/html5.py | 4 ++-- sphinx/writers/latex.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index 471d23b6699..906e65b46a5 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -620,7 +620,7 @@ def visit_literal_block(self, node: Element) -> None: pb = self.builder.get_bridge_for_style(dark_style) if pb is None: logger.warning( - __("PygmentsBridge for style {} not found".format(dark_style))) + __('PygmentsBridge for style {} not found'.format(dark_style))) else: highlighted = pb.highlight_block( node.rawsource, @@ -635,7 +635,7 @@ def visit_literal_block(self, node: Element) -> None: pb = self.builder.get_bridge_for_style(light_style) if pb is None: logger.warning( - __("PygmentsBridge for style {} not found".format(light_style))) + __('PygmentsBridge for style {} not found'.format(light_style))) else: highlighted = pb.highlight_block( node.rawsource, diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index bbee4530432..b285fd31cd9 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -2216,7 +2216,8 @@ def visit_literal_block(self, node: Element) -> None: self.builder.add_block_style(style=code_style) pb = self.builder.get_bridge_for_style(code_style) if pb is None: - logger.warning(__("PygmentsBridge for style {} not found".format(code_style))) + logger.warning( + __('PygmentsBridge for style {} not found'.format(code_style))) else: hlcode = pb.highlight_block( node.rawsource, From a8ac1287ba74b8a3d4dd3b9d7fc9bfee671c7242 Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Mon, 2 Jun 2025 23:26:42 -0400 Subject: [PATCH 16/25] Ruff, formatting --- sphinx/builders/html/__init__.py | 22 +++++++++++++++++----- sphinx/writers/html5.py | 6 ++++-- sphinx/writers/latex.py | 3 ++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index f254e412c1f..6881cf9fc63 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -268,9 +268,11 @@ def init_highlighter(self) -> None: # the visit_literal_block method. # The information is also used in the selectors of the CSS file(s). if TYPE_CHECKING: + class spec_highlighter(TypedDict): bridge: PygmentsBridge ids: list[int] + self.specialized_dark_lighters: dict[str, spec_highlighter] = {} self.specialized_light_lighters: dict[str, spec_highlighter] = {} @@ -858,7 +860,9 @@ def get_bridge_for_style(self, style: str) -> PygmentsBridge | None: return self.specialized_dark_lighters[style]['bridge'] elif style in self.specialized_light_lighters: return self.specialized_light_lighters[style]['bridge'] - elif self.dark_highlighter and (style == self.dark_highlighter.get_style(style).name): + elif self.dark_highlighter and ( + style == self.dark_highlighter.get_style(style).name + ): return self.dark_highlighter elif style == self.highlighter.get_style(style).name: return self.highlighter @@ -878,7 +882,9 @@ def create_pygments_style_file(self) -> None: light_style_sheet += self.highlighter.get_stylesheet() if self.specialized_light_lighters: for s_name, item in self.specialized_light_lighters.items(): - light_style_sheet += '\n\n/* CSS for style: {} */\n'.format(s_name) + light_style_sheet += '\n\n/* CSS for style: {} */\n'.format( + s_name + ) light_style_sheet += item['bridge'].get_stylesheet(item['ids']) f.write(light_style_sheet) @@ -890,12 +896,18 @@ def create_pygments_style_file(self) -> None: dark_path = self._static_dir / 'pygments_dark.css' with open(dark_path, 'w', encoding='utf-8') as f: dark_style_name = dark_style.name - dark_style_sheet = '/* CSS for style: {} */\n'.format(dark_style_name) + dark_style_sheet = '/* CSS for style: {} */\n'.format( + dark_style_name + ) dark_style_sheet += self.dark_highlighter.get_stylesheet() if self.specialized_dark_lighters: for s_name, item in self.specialized_dark_lighters.items(): - dark_style_sheet += '\n\n/* CSS for style: {} */\n'.format(s_name) - dark_style_sheet += item['bridge'].get_stylesheet(item['ids']) + dark_style_sheet += '\n\n/* CSS for style: {} */\n'.format( + s_name + ) + dark_style_sheet += item['bridge'].get_stylesheet( + item['ids'] + ) f.write(dark_style_sheet) def copy_translation_js(self) -> None: diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index 906e65b46a5..4930b5ed3cc 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -620,7 +620,8 @@ def visit_literal_block(self, node: Element) -> None: pb = self.builder.get_bridge_for_style(dark_style) if pb is None: logger.warning( - __('PygmentsBridge for style {} not found'.format(dark_style))) + __('PygmentsBridge for style {} not found'.format(dark_style)) + ) else: highlighted = pb.highlight_block( node.rawsource, @@ -635,7 +636,8 @@ def visit_literal_block(self, node: Element) -> None: pb = self.builder.get_bridge_for_style(light_style) if pb is None: logger.warning( - __('PygmentsBridge for style {} not found'.format(light_style))) + __('PygmentsBridge for style {} not found'.format(light_style)) + ) else: highlighted = pb.highlight_block( node.rawsource, diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index b285fd31cd9..6d381f15bbd 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -2217,7 +2217,8 @@ def visit_literal_block(self, node: Element) -> None: pb = self.builder.get_bridge_for_style(code_style) if pb is None: logger.warning( - __('PygmentsBridge for style {} not found'.format(code_style))) + __('PygmentsBridge for style {} not found'.format(code_style)) + ) else: hlcode = pb.highlight_block( node.rawsource, From 0c3d5a72b5414a4bf8e266570079fe7c10bf6caa Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Tue, 3 Jun 2025 16:30:21 -0400 Subject: [PATCH 17/25] Fix MyPy ? --- sphinx/highlighting.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index 52ef1d3de6e..999cc65b2be 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -231,10 +231,10 @@ def highlight_block( def get_stylesheet(self, selectors: list[int] | None = None) -> str: formatter = self.get_formatter() - if self.dest == 'html': + if isinstance(formatter, HtmlFormatter): if selectors: - return formatter.get_style_defs(['.c{}'.format(s) for s in selectors]) + return formatter.get_style_defs(['.c{}'.format(s) for s in selectors]) # type: ignore [no-untyped-call] else: - return formatter.get_style_defs('.highlight') + return formatter.get_style_defs('.highlight') # type: ignore [no-untyped-call] else: return formatter.get_style_defs() + _LATEX_ADD_STYLES From fa2a79872972b2ac8a727f641e2c4cec63ada112 Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Wed, 11 Jun 2025 22:03:23 -0400 Subject: [PATCH 18/25] Fix docs-lint; line too long after adding PR number --- CHANGES.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 60f73489b26..4f073115ff1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -43,8 +43,8 @@ Features added Patch by Adam Turner. * #13647: LaTeX: allow more cases of table nesting. Patch by Jean-François B. -* #13611: Allow `Pygments style `_ overriding on a per-block - basis via new options (:rst:dir:`code-block:style-light` and +* #13611: Allow `Pygments style `_ overriding on a + per-block basis via new options (:rst:dir:`code-block:style-light` and :rst:dir:`code-block:style-dark`) for the :rst:dir:`code-block`, :rst:dir:`sourcecode`, :rst:dir:`literalinclude` and :rst:dir:`code`. Patch by Héctor Medina Abarca. From 35f782cbccde59bd9278ccab039ef1fd338a856d Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Sat, 14 Jun 2025 04:30:04 -0400 Subject: [PATCH 19/25] "sanitize" LaTeX command prefix from user-specified style * Whether a character is in the correct category for the definition of a LaTeX macro can, in the general sense, only be determined at compile time. As a fall-back, "valid" macro names generally use the a-zA-Z range; so we use this to replace any character not in those ranges with an uppercase Z using regular expressions. * Since the user-facing names come from the SetupTools installation entry-points, they need not match the `name` attribute in the associated classes (sigh). Added to this, something needs to be printed to the LaTeX file. So, on the Python side and any user-facing usage retain the user-given name (this also helps avoid collisions, as Z-replacement loses information); any LaTeX-printed file will use the "sanitized" version in the macro names where likely-wrong-category characters got replaced with a Z. A header / comment in the .sty file specifies the user-given name for that portion of the file * The sphinx re-definitions of "problematic" LaTeX characters (e.g. \PYGam{\&} ) gets overhauled; the string now contains an `override` key, for use with Python's `str.format(key=value)` syntax. When given an empty string, the string prior to this commit is returned; otherwise, appropriate overrides are generated for the various style-specific special characters. However, the usage in `sphinx/texinputs/sphinxlatexliterals.sty`, lines 561-650, seem to have the `PYG` prefix baked-in... --- sphinx/builders/latex/__init__.py | 5 ++- sphinx/highlighting.py | 61 ++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index 6e26b0f896c..3d0e1347d94 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -4,6 +4,7 @@ import os import os.path +import re import warnings from pathlib import Path from typing import TYPE_CHECKING @@ -280,7 +281,7 @@ def add_block_style(self, style: str) -> None: """Add a styler to the tracker of highlighting styles.""" if style not in self.specialized_highlighters: pb = highlighting.PygmentsBridge(dest='latex', stylename=style) - pb.formatter_args['commandprefix'] = 'PYG' + style + pb.formatter_args['commandprefix'] = 'PYG' + re.sub(r'[^a-zA-Z]', 'Z', style) self.specialized_highlighters[style] = pb def get_bridge_for_style(self, style: str) -> highlighting.PygmentsBridge | None: @@ -310,7 +311,7 @@ def write_stylesheet(self) -> None: specialized_styles = [] for style_name, pyg_bridge in self.specialized_highlighters.items(): specialized_style = '\n% Stylesheet for style {}'.format(style_name) - specialized_style += pyg_bridge.get_stylesheet() + specialized_style += pyg_bridge.get_stylesheet(style_name) specialized_styles.append(specialized_style) f.write('\n'.join(specialized_styles)) diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index 999cc65b2be..6b520c97bdf 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from functools import partial from importlib import import_module from typing import TYPE_CHECKING @@ -73,24 +74,24 @@ % to fix problems for the 5.0.0 inline code highlighting (captions!). % The \text is from amstext, a dependency of sphinx.sty. It is here only % to avoid build errors if for some reason expansion is in math mode. -\def\PYGZbs{\text\textbackslash} -\def\PYGZus{\_} -\def\PYGZob{\{} -\def\PYGZcb{\}} -\def\PYGZca{\text\textasciicircum} -\def\PYGZam{\&} -\def\PYGZlt{\text\textless} -\def\PYGZgt{\text\textgreater} -\def\PYGZsh{\#} -\def\PYGZpc{\%} -\def\PYGZdl{\$} -\def\PYGZhy{\sphinxhyphen}% defined in sphinxlatexstyletext.sty -\def\PYGZsq{\text\textquotesingle} -\def\PYGZdq{"} -\def\PYGZti{\text\textasciitilde} +\def\PYG{override}Zbs{{\text\textbackslash}} +\def\PYG{override}Zus{{\_}} +\def\PYG{override}Zob{{\{{}} +\def\PYG{override}Zcb{{\}}}} +\def\PYG{override}Zca{{\text\textasciicircum}} +\def\PYG{override}Zam{{\&}} +\def\PYG{override}Zlt{{\text\textless}} +\def\PYG{override}Zgt{{\text\textgreater}} +\def\PYG{override}Zsh{{\#}} +\def\PYG{override}Zpc{{\%}} +\def\PYG{override}Zdl{{\$}} +\def\PYG{override}Zhy{{\sphinxhyphen}}% defined in sphinxlatexstyletext.sty +\def\PYG{override}Zsq{{\text\textquotesingle}} +\def\PYG{override}Zdq{{"}} +\def\PYG{override}Zti{{\text\textasciitilde}} \makeatletter % use \protected to allow syntax highlighting in captions -\protected\def\PYG#1#2{\PYG@reset\PYG@toks#1+\relax+{\PYG@do{#2}}} +\protected\def\PYG{override}#1#2{{\PYG{override}@reset\PYG{override}@toks#1+\relax+{{\PYG{override}@do{{#2}}}}}} \makeatother """ @@ -229,7 +230,13 @@ def highlight_block( # MEMO: this is done to escape Unicode chars with non-Unicode engines return texescape.hlescape(hlsource, self.latex_engine) - def get_stylesheet(self, selectors: list[int] | None = None) -> str: + def get_stylesheet(self, selectors: list[int] | str | None = None) -> str: + """Return a string with the specification for the tokens yielded by the language + lexer, appropriate for the output formatter, using the style defined at + initialization. In an HTML context, `selectors` is a list of CSS class selectors. In a + LaTeX context, it modifies the command prefix used for macro definitions; see also + LaTeXBuilder.add_block_style() + """ formatter = self.get_formatter() if isinstance(formatter, HtmlFormatter): if selectors: @@ -237,4 +244,22 @@ def get_stylesheet(self, selectors: list[int] | None = None) -> str: else: return formatter.get_style_defs('.highlight') # type: ignore [no-untyped-call] else: - return formatter.get_style_defs() + _LATEX_ADD_STYLES + if selectors: + if isinstance(selectors, str): + tex_name = re.sub(r'[^a-zA-Z]', 'Z', selectors) + else: + logger.error( + __('Encountered %s in selectors field; expected a string for the ' + 'LaTeX formatter, using default values as fallback.\n' + 'Please report his error.'), + type(selectors), + type='misc', + subtype='highlighting_failure' + ) + tex_name = '' + stylesheet = self.formatter(commandprefix='PYG' + tex_name).get_style_defs() + sphinx_redefs = _LATEX_ADD_STYLES.format(override=tex_name) + else: + stylesheet = formatter.get_style_defs() + sphinx_redefs = _LATEX_ADD_STYLES.format(override='') + return stylesheet + sphinx_redefs From 7fab81e97741e26a015478b1d34f015fdfab5639 Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Sat, 14 Jun 2025 04:35:21 -0400 Subject: [PATCH 20/25] Fix ruff format? --- sphinx/builders/latex/__init__.py | 4 +++- sphinx/highlighting.py | 14 +++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index 3d0e1347d94..ae6b1d52833 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -281,7 +281,9 @@ def add_block_style(self, style: str) -> None: """Add a styler to the tracker of highlighting styles.""" if style not in self.specialized_highlighters: pb = highlighting.PygmentsBridge(dest='latex', stylename=style) - pb.formatter_args['commandprefix'] = 'PYG' + re.sub(r'[^a-zA-Z]', 'Z', style) + pb.formatter_args['commandprefix'] = 'PYG' + re.sub( + r'[^a-zA-Z]', 'Z', style + ) self.specialized_highlighters[style] = pb def get_bridge_for_style(self, style: str) -> highlighting.PygmentsBridge | None: diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index 6b520c97bdf..80a0a9b2530 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -249,15 +249,19 @@ def get_stylesheet(self, selectors: list[int] | str | None = None) -> str: tex_name = re.sub(r'[^a-zA-Z]', 'Z', selectors) else: logger.error( - __('Encountered %s in selectors field; expected a string for the ' - 'LaTeX formatter, using default values as fallback.\n' - 'Please report his error.'), + __( + 'Encountered %s in selectors field; expected a string for the ' + 'LaTeX formatter, using default values as fallback.\n' + 'Please report his error.' + ), type(selectors), type='misc', - subtype='highlighting_failure' + subtype='highlighting_failure', ) tex_name = '' - stylesheet = self.formatter(commandprefix='PYG' + tex_name).get_style_defs() + stylesheet = self.formatter( + commandprefix='PYG' + tex_name + ).get_style_defs() sphinx_redefs = _LATEX_ADD_STYLES.format(override=tex_name) else: stylesheet = formatter.get_style_defs() From 4b09baa72ef2bd63e0c3cefadc0374f22a9d7eff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20B=2E?= <2589111+jfbu@users.noreply.github.com> Date: Sun, 15 Jun 2025 15:06:07 +0200 Subject: [PATCH 21/25] LaTeX: fix style arg lacking, refactor to achieve full LaTeX support --- sphinx/builders/latex/__init__.py | 9 ++--- sphinx/highlighting.py | 65 +++++++++++++++++++------------ sphinx/writers/latex.py | 24 ++++++++++++ 3 files changed, 67 insertions(+), 31 deletions(-) diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index ae6b1d52833..83528799b02 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -4,7 +4,6 @@ import os import os.path -import re import warnings from pathlib import Path from typing import TYPE_CHECKING @@ -281,9 +280,6 @@ def add_block_style(self, style: str) -> None: """Add a styler to the tracker of highlighting styles.""" if style not in self.specialized_highlighters: pb = highlighting.PygmentsBridge(dest='latex', stylename=style) - pb.formatter_args['commandprefix'] = 'PYG' + re.sub( - r'[^a-zA-Z]', 'Z', style - ) self.specialized_highlighters[style] = pb def get_bridge_for_style(self, style: str) -> highlighting.PygmentsBridge | None: @@ -303,10 +299,11 @@ def write_stylesheet(self) -> None: f.write('\\NeedsTeXFormat{LaTeX2e}[1995/12/01]\n') f.write( '\\ProvidesPackage{sphinxhighlight}' - '[2022/06/30 stylesheet for highlighting with pygments]\n' + '[2025/06/15 stylesheet for highlighting with pygments]\n' ) f.write( - '% Its contents depend on pygments_style configuration variable.\n\n' + '% Its contents depend on pygments_style configuration variable.\n' + '% And also on encountered code-blocks :style-light: options.\n\n' ) f.write(highlighter.get_stylesheet()) if self.specialized_highlighters: diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index 80a0a9b2530..7195893694c 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -2,8 +2,8 @@ from __future__ import annotations -import re from functools import partial +from hashlib import md5 from importlib import import_module from typing import TYPE_CHECKING @@ -74,24 +74,26 @@ % to fix problems for the 5.0.0 inline code highlighting (captions!). % The \text is from amstext, a dependency of sphinx.sty. It is here only % to avoid build errors if for some reason expansion is in math mode. -\def\PYG{override}Zbs{{\text\textbackslash}} -\def\PYG{override}Zus{{\_}} -\def\PYG{override}Zob{{\{{}} -\def\PYG{override}Zcb{{\}}}} -\def\PYG{override}Zca{{\text\textasciicircum}} -\def\PYG{override}Zam{{\&}} -\def\PYG{override}Zlt{{\text\textless}} -\def\PYG{override}Zgt{{\text\textgreater}} -\def\PYG{override}Zsh{{\#}} -\def\PYG{override}Zpc{{\%}} -\def\PYG{override}Zdl{{\$}} -\def\PYG{override}Zhy{{\sphinxhyphen}}% defined in sphinxlatexstyletext.sty -\def\PYG{override}Zsq{{\text\textquotesingle}} -\def\PYG{override}Zdq{{"}} -\def\PYG{override}Zti{{\text\textasciitilde}} +\def\PYGZbs{{\text\textbackslash}} +\def\PYGZus{{\_}} +\def\PYGZob{{\{{}} +\def\PYGZcb{{\}}}} +\def\PYGZca{{\text\textasciicircum}} +\def\PYGZam{{\&}} +\def\PYGZlt{{\text\textless}} +\def\PYGZgt{{\text\textgreater}} +\def\PYGZsh{{\#}} +\def\PYGZpc{{\%}} +\def\PYGZdl{{\$}} +\def\PYGZhy{{\sphinxhyphen}}% defined in sphinxlatexstyletext.sty +\def\PYGZsq{{\text\textquotesingle}} +\def\PYGZdq{{"}} +\def\PYGZti{{\text\textasciitilde}} \makeatletter % use \protected to allow syntax highlighting in captions -\protected\def\PYG{override}#1#2{{\PYG{override}@reset\PYG{override}@toks#1+\relax+{{\PYG{override}@do{{#2}}}}}} +\def\PYG@#1#2{{\PYG@reset\PYG@toks#1+\relax+{{\PYG@do{{#2}}}}}} +\protected\def\PYG{\csname PYG\ifdefined\sphinxpygmentsstylename + \sphinxpygmentsstylename\else @\fi\endcsname} \makeatother """ @@ -246,24 +248,37 @@ def get_stylesheet(self, selectors: list[int] | str | None = None) -> str: else: if selectors: if isinstance(selectors, str): - tex_name = re.sub(r'[^a-zA-Z]', 'Z', selectors) + _tex_name = md5(selectors.encode()).hexdigest()[:6] # noqa: S324 + for d, l in [ + ('0', 'G'), + ('1', 'H'), + ('2', 'I'), + ('3', 'J'), + ('4', 'K'), + ('5', 'L'), + ('6', 'M'), + ('7', 'N'), + ('8', 'O'), + ('9', 'P'), + ]: + _tex_name = _tex_name.replace(d, l) else: logger.error( __( - 'Encountered %s in selectors field; expected a string for the ' - 'LaTeX formatter, using default values as fallback.\n' - 'Please report his error.' + 'Encountered %s in selectors field; expected a string ' + 'for the LaTeX formatter. Please report this error.' ), type(selectors), type='misc', subtype='highlighting_failure', ) - tex_name = '' + # not using '' as we don't want \PYG being overwritten. + _tex_name = 'INVALID' stylesheet = self.formatter( - commandprefix='PYG' + tex_name + style=selectors, commandprefix='PYG' + _tex_name ).get_style_defs() - sphinx_redefs = _LATEX_ADD_STYLES.format(override=tex_name) + sphinx_redefs = '' else: stylesheet = formatter.get_style_defs() - sphinx_redefs = _LATEX_ADD_STYLES.format(override='') + sphinx_redefs = _LATEX_ADD_STYLES return stylesheet + sphinx_redefs diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 5f1bff95f0a..6dbffa701e3 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -8,6 +8,7 @@ import re from collections import defaultdict +from hashlib import md5 from pathlib import Path from typing import TYPE_CHECKING, cast @@ -2258,6 +2259,7 @@ def visit_literal_block(self, node: Element) -> None: opts = self.config.highlight_options.get(lang, {}) # As blocks are processed, we discover specified styles. + _texstylename = '' if node.get('style-light'): code_style = node.get('style-light') self.builder.add_block_style(style=code_style) @@ -2275,6 +2277,20 @@ def visit_literal_block(self, node: Element) -> None: location=node, **highlight_args, ) + _texstylename = md5(code_style.encode()).hexdigest()[:6] # noqa: S324 + for d, l in [ + ('0', 'G'), + ('1', 'H'), + ('2', 'I'), + ('3', 'J'), + ('4', 'K'), + ('5', 'L'), + ('6', 'M'), + ('7', 'N'), + ('8', 'O'), + ('9', 'P'), + ]: + _texstylename = _texstylename.replace(d, l) else: hlcode = self.highlighter.highlight_block( node.rawsource, @@ -2284,6 +2300,10 @@ def visit_literal_block(self, node: Element) -> None: location=node, **highlight_args, ) + if _texstylename: + self.body.append( + CR + f'\\def\\sphinxpygmentsstylename{{{_texstylename}}}' + ) if self.in_footnote: self.body.append(CR + r'\sphinxSetupCodeBlockInFootnote') hlcode = hlcode.replace(r'\begin{Verbatim}', r'\begin{sphinxVerbatim}') @@ -2300,6 +2320,8 @@ def visit_literal_block(self, node: Element) -> None: # get consistent trailer hlcode = hlcode.rstrip()[:-14] # strip \end{Verbatim} if self.table and not self.in_footnote: + # TODO: probably add a % at end to avoid a space token if for a + # block with style-light option hlcode += r'\end{sphinxVerbatimintable}' else: hlcode += r'\end{sphinxVerbatim}' @@ -2310,6 +2332,8 @@ def visit_literal_block(self, node: Element) -> None: self.body.append(CR + hlcode + CR) if hllines: self.body.append(r'\sphinxresetverbatimhllines' + CR) + if _texstylename: + self.body.append(r'\let\sphinxpygmentsstylename\undefined' + CR) raise nodes.SkipNode def depart_literal_block(self, node: Element) -> None: From 27b5239189ebcb41b704b89ae85cf07375b27f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20B=2E?= <2589111+jfbu@users.noreply.github.com> Date: Tue, 17 Jun 2025 14:34:30 +0200 Subject: [PATCH 22/25] Remove left-over curly brace pairs in LaTeX code in highlighting.py --- sphinx/highlighting.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index 7195893694c..ccc08b03d7b 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -74,21 +74,21 @@ % to fix problems for the 5.0.0 inline code highlighting (captions!). % The \text is from amstext, a dependency of sphinx.sty. It is here only % to avoid build errors if for some reason expansion is in math mode. -\def\PYGZbs{{\text\textbackslash}} -\def\PYGZus{{\_}} -\def\PYGZob{{\{{}} -\def\PYGZcb{{\}}}} -\def\PYGZca{{\text\textasciicircum}} -\def\PYGZam{{\&}} -\def\PYGZlt{{\text\textless}} -\def\PYGZgt{{\text\textgreater}} -\def\PYGZsh{{\#}} -\def\PYGZpc{{\%}} -\def\PYGZdl{{\$}} -\def\PYGZhy{{\sphinxhyphen}}% defined in sphinxlatexstyletext.sty -\def\PYGZsq{{\text\textquotesingle}} -\def\PYGZdq{{"}} -\def\PYGZti{{\text\textasciitilde}} +\def\PYGZbs{\text\textbackslash} +\def\PYGZus{\_} +\def\PYGZob{\{} +\def\PYGZcb{\}} +\def\PYGZca{\text\textasciicircum} +\def\PYGZam{\&} +\def\PYGZlt{\text\textless} +\def\PYGZgt{\text\textgreater} +\def\PYGZsh{\#} +\def\PYGZpc{\%} +\def\PYGZdl{\$} +\def\PYGZhy{\sphinxhyphen}% defined in sphinxlatexstyletext.sty +\def\PYGZsq{\text\textquotesingle} +\def\PYGZdq{"} +\def\PYGZti{\text\textasciitilde} \makeatletter % use \protected to allow syntax highlighting in captions \def\PYG@#1#2{{\PYG@reset\PYG@toks#1+\relax+{{\PYG@do{{#2}}}}}} From 83932f795532a6a3596b901c0f89ea97f934832e Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Thu, 19 Jun 2025 18:56:28 -0400 Subject: [PATCH 23/25] Simplify LaTeX code * `LaTeXBuilder.get_bridge_for_style()` was only used to get a recently-created `PygmentsBridge` object, but added a None-check. Refactoring the method that added the created object to return it (i.e. `LaTeXBuilder.add_block_style()`) avoids the extra method and the requirement for a None check. If the method fails, it would fail at creation of the PygmentsBridge object, which is better handled by its own reporting * Rename `LaTeXBuilder.add_block_style()` to `update_override_styles()` --- sphinx/builders/latex/__init__.py | 17 ++++------ sphinx/writers/latex.py | 52 ++++++++++++++----------------- 2 files changed, 29 insertions(+), 40 deletions(-) diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index 1f45c48ee23..3af185304ec 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -273,21 +273,16 @@ def init_multilingual(self) -> None: self.context['multilingual'] = f'{self.context["polyglossia"]}\n{language}' - def add_block_style(self, style: str) -> None: - """Add a styler to the tracker of highlighting styles.""" - if style not in self.specialized_highlighters: - pb = highlighting.PygmentsBridge(dest='latex', stylename=style) - self.specialized_highlighters[style] = pb - - def get_bridge_for_style(self, style: str) -> highlighting.PygmentsBridge | None: - """Returns the PygmentsBridge associated with a style, if any. - Since the default highlighter is initialized and discarded in self.write_stylesheet(), - it is not supported in this search. + def update_override_styles(self, style: str) -> highlighting.PygmentsBridge: + """Update the tracker of highlighting styles with a possibly new style; + return the PygmentsBridge object associated with said style. """ if style in self.specialized_highlighters: return self.specialized_highlighters[style] else: - return None + pb = highlighting.PygmentsBridge(dest='latex', stylename=style) + self.specialized_highlighters[style] = pb + return pb def write_stylesheet(self) -> None: highlighter = highlighting.PygmentsBridge('latex', self.config.pygments_style) diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 6dbffa701e3..3927664f5dc 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -2262,35 +2262,29 @@ def visit_literal_block(self, node: Element) -> None: _texstylename = '' if node.get('style-light'): code_style = node.get('style-light') - self.builder.add_block_style(style=code_style) - pb = self.builder.get_bridge_for_style(code_style) - if pb is None: - logger.warning( - __('PygmentsBridge for style {} not found'.format(code_style)) - ) - else: - hlcode = pb.highlight_block( - node.rawsource, - lang, - opts=opts, - linenos=linenos, - location=node, - **highlight_args, - ) - _texstylename = md5(code_style.encode()).hexdigest()[:6] # noqa: S324 - for d, l in [ - ('0', 'G'), - ('1', 'H'), - ('2', 'I'), - ('3', 'J'), - ('4', 'K'), - ('5', 'L'), - ('6', 'M'), - ('7', 'N'), - ('8', 'O'), - ('9', 'P'), - ]: - _texstylename = _texstylename.replace(d, l) + pb = self.builder.update_override_styles(style=code_style) + hlcode = pb.highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + **highlight_args, + ) + _texstylename = md5(code_style.encode()).hexdigest()[:6] # noqa: S324 + for d, l in [ + ('0', 'G'), + ('1', 'H'), + ('2', 'I'), + ('3', 'J'), + ('4', 'K'), + ('5', 'L'), + ('6', 'M'), + ('7', 'N'), + ('8', 'O'), + ('9', 'P'), + ]: + _texstylename = _texstylename.replace(d, l) else: hlcode = self.highlighter.highlight_block( node.rawsource, From f5d51780b668813ccde8f69344a7fd2344a0691f Mon Sep 17 00:00:00 2001 From: Hector Medina Date: Thu, 19 Jun 2025 19:20:22 -0400 Subject: [PATCH 24/25] Simplify HTML code * `StandaloneHTMLBuilder.get_bridge_for_style()` was only used to get a recently-created `PygmentsBridge` object, but added a None-check. Refactoring the methods that added the created object to return it (i.e. `add_block_dark_style()` & `add_block_light_style()`) avoids the extra method and the requirement for a None check. If the methods fail, they would fail at creation of the PygmentsBridge object(s), which is better handled by its own reporting * Rename `add_block_dark_style()` to `update_override_styles_dark()` * Rename `add_block_light_style()` to `update_override_styles_light()` --- sphinx/builders/html/__init__.py | 32 +++++----------- sphinx/writers/html5.py | 66 +++++++++++--------------------- 2 files changed, 33 insertions(+), 65 deletions(-) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 1111a16391f..e4473a9c533 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -841,39 +841,27 @@ def to_relpath(f: str) -> str: err, ) - def add_block_dark_style(self, style: str, id: int) -> None: - """Add a code-block id to the tracker of dark highlighting styles.""" + def update_override_styles_dark(self, style: str, id: int) -> PygmentsBridge: + """Update the tracker of highlighting styles with a possibly new dark-mode style; + return the PygmentsBridge object associated with said style. + """ if style in self.specialized_dark_lighters: self.specialized_dark_lighters[style]['ids'].append(id) else: pb = PygmentsBridge(dest='html', stylename=style) self.specialized_dark_lighters[style] = {'bridge': pb, 'ids': [id]} + return self.specialized_dark_lighters[style]['bridge'] - def add_block_light_style(self, style: str, id: int) -> None: - """Add a code-block id to the tracker of light highlighting styles.""" + def update_override_styles_light(self, style: str, id: int) -> PygmentsBridge: + """Update the tracker of highlighting styles with a possibly new light-mode style; + return the PygmentsBridge object associated with said style. + """ if style in self.specialized_light_lighters: self.specialized_light_lighters[style]['ids'].append(id) else: pb = PygmentsBridge(dest='html', stylename=style) self.specialized_light_lighters[style] = {'bridge': pb, 'ids': [id]} - - def get_bridge_for_style(self, style: str) -> PygmentsBridge | None: - """Returns the PygmentsBridge associated with a style, if any. - Searches the dark list first, then the light list, then the default dark - and light styles. - """ - if style in self.specialized_dark_lighters: - return self.specialized_dark_lighters[style]['bridge'] - elif style in self.specialized_light_lighters: - return self.specialized_light_lighters[style]['bridge'] - elif self.dark_highlighter and ( - style == self.dark_highlighter.get_style(style).name - ): - return self.dark_highlighter - elif style == self.highlighter.get_style(style).name: - return self.highlighter - else: - return None + return self.specialized_light_lighters[style]['bridge'] def create_pygments_style_file(self) -> None: """Create style file(s) for Pygments.""" diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index 3fe2041a9dc..0f23af5b006 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -601,53 +601,33 @@ def visit_literal_block(self, node: nodes.literal_block) -> None: linenos = self.config.html_codeblock_linenos_style # As blocks are processed, we discover specified styles. - block_id = hash(node) - if node.get('style-dark'): - dark_style = node.get('style-dark') - self.builder.add_block_dark_style(style=dark_style, id=block_id) - else: - dark_style = None - - if node.get('style-light'): - light_style = node.get('style-light') - self.builder.add_block_light_style(style=light_style, id=block_id) - else: - light_style = None - # If either dark or style were requested, use their specialized # highlighter. If neither, use the default highlighter. + block_id = hash(node) + dark_style = node.get('style-dark', None) + light_style = node.get('style-light', None) if dark_style: - pb = self.builder.get_bridge_for_style(dark_style) - if pb is None: - logger.warning( - __('PygmentsBridge for style {} not found'.format(dark_style)) - ) - else: - highlighted = pb.highlight_block( - node.rawsource, - lang, - opts=opts, - linenos=linenos, - location=node, - cssclass='highlight c{}'.format(block_id), - **highlight_args, - ) + pb = self.builder.update_override_styles_dark(dark_style, block_id) + highlighted = pb.highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + cssclass='highlight c{}'.format(block_id), + **highlight_args, + ) if light_style: - pb = self.builder.get_bridge_for_style(light_style) - if pb is None: - logger.warning( - __('PygmentsBridge for style {} not found'.format(light_style)) - ) - else: - highlighted = pb.highlight_block( - node.rawsource, - lang, - opts=opts, - linenos=linenos, - location=node, - cssclass='highlight c{}'.format(block_id), - **highlight_args, - ) + pb = self.builder.update_override_styles_light(light_style, block_id) + highlighted = pb.highlight_block( + node.rawsource, + lang, + opts=opts, + linenos=linenos, + location=node, + cssclass='highlight c{}'.format(block_id), + **highlight_args, + ) if not (dark_style or light_style): highlighted = self.highlighter.highlight_block( node.rawsource, From 37fabbeb38f6f977db688003a273ece44d6c42d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20B=2E?= <2589111+jfbu@users.noreply.github.com> Date: Fri, 20 Jun 2025 15:25:16 +0200 Subject: [PATCH 25/25] LaTeX: implement support for custom background color Some styles set the background_color to #ffffff, in such cases we ignore that because the default Sphinx PDF light gray is nicer. This also handles a "default" text color, but some testing shoud be done. --- sphinx/highlighting.py | 76 ++++++++++++++++++++++++++++++++--------- sphinx/writers/latex.py | 17 ++++++--- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index ccc08b03d7b..2d39b678cc2 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from functools import partial from hashlib import md5 from importlib import import_module @@ -21,6 +22,7 @@ guess_lexer, ) from pygments.styles import get_style_by_name +from pygments.token import Token from pygments.util import ClassNotFound from sphinx.locale import __ @@ -247,22 +249,7 @@ def get_stylesheet(self, selectors: list[int] | str | None = None) -> str: return formatter.get_style_defs('.highlight') # type: ignore [no-untyped-call] else: if selectors: - if isinstance(selectors, str): - _tex_name = md5(selectors.encode()).hexdigest()[:6] # noqa: S324 - for d, l in [ - ('0', 'G'), - ('1', 'H'), - ('2', 'I'), - ('3', 'J'), - ('4', 'K'), - ('5', 'L'), - ('6', 'M'), - ('7', 'N'), - ('8', 'O'), - ('9', 'P'), - ]: - _tex_name = _tex_name.replace(d, l) - else: + if not isinstance(selectors, str): logger.error( __( 'Encountered %s in selectors field; expected a string ' @@ -274,10 +261,67 @@ def get_stylesheet(self, selectors: list[int] | str | None = None) -> str: ) # not using '' as we don't want \PYG being overwritten. _tex_name = 'INVALID' + selectors = 'default' # TODO: make more informed choice? + _tex_name = md5(selectors.encode()).hexdigest()[:6] # noqa: S324 + for d, l in [ + ('0', 'G'), + ('1', 'H'), + ('2', 'I'), + ('3', 'J'), + ('4', 'K'), + ('5', 'L'), + ('6', 'M'), + ('7', 'N'), + ('8', 'O'), + ('9', 'P'), + ]: + _tex_name = _tex_name.replace(d, l) stylesheet = self.formatter( style=selectors, commandprefix='PYG' + _tex_name ).get_style_defs() sphinx_redefs = '' + bc = self.get_style(selectors).background_color + if bc is not None: + bc = bc.lstrip('#').lower() + # The xcolor LaTeX package requires 6 hexadecimal digits + if len(bc) == 3: + bc = bc[0] * 2 + bc[1] * 2 + bc[2] * 2 + # We intercept a purely white background, so that PDF will use Sphinx + # light gray default, rather, or the user VerbatimColor global choice. + # TODO: argue pros and cons. + if bc != 'ffffff': + sphinx_redefs = ( + '% background color for above style, "HTML" syntax\n' + f'\\def\\sphinxPYG{_tex_name}bc{{{bc}}}\n' + ) + # TODO: THIS MAY NOT BE THE RIGHT THING TO DO. + # TODO: REMOVE NEXT COMMENTS. + # I wanted to try with + # solarized-light which will use #657b83 but my sample code-block + # has no token not using a color so I could not confirm it does work. + # (indeed solarized-light uses \textcolor everywhere in its stylesheet, + # so I modified manually LaTeX output to confirm the whole thing + # actually worked as expected). + # I have not for lack of time searched for a pygments style defining + # such a color and not using \textcolor everywhere. + # The idea is to avoid invisible text on dark background which I believe + # I have experienced in the past when using dark background via injection + # of \sphinxsetup using raw:: latex directive. + base_style = self.get_style(selectors).styles[Token] + if base_style: # could look like 'italic #000 bg:#ffffff' + match = re.match( + r'#([0-9a-fA-F]{3,6})(?:\s+bg:#([0-9a-fA-F]{3,6}))?', base_style + ) + if match is not None: + tc = match.group(1) + if len(tc) == 3: + tc = tc[0] * 2 + tc[1] * 2 + tc[2] * 2 + sphinx_redefs += ( + '% text default color for above style, "HTML" syntax\n' + f'\\def\\sphinxPYG{_tex_name}tc{{{tc}}}\n' + ) + # TODO: what should we do for the color used to emphasize lines? + # It is VerbatimHightlightColor. else: stylesheet = formatter.get_style_defs() sphinx_redefs = _LATEX_ADD_STYLES diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 3927664f5dc..5bf521faa0d 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -2295,8 +2295,19 @@ def visit_literal_block(self, node: Element) -> None: **highlight_args, ) if _texstylename: + # There is no a priori "VerbatimTextColor" set, except is user employed + # the sphinxsetup with pre_TeXcolor. We could query the TeX boolean + # ifspx@opt@pre@withtextcolor but the @ letter is annoying here. So + # let's simply add a group level and not worry about testing if this + # or other things pre-exist so we don't have to reset. self.body.append( - CR + f'\\def\\sphinxpygmentsstylename{{{_texstylename}}}' + f'{CR}\\begingroup\\def\\sphinxpygmentsstylename{{{_texstylename}}}%' + f'{CR}\\ifdefined\\sphinxPYG{_texstylename}bc' + f'{CR} \\sphinxsetup{{VerbatimColor={{HTML}}' + f'{{\\sphinxPYG{_texstylename}bc}}}}%{CR}\\fi' + f'{CR}\\ifdefined\\sphinxPYG{_texstylename}tc' + f'{CR} \\sphinxsetup{{pre_TeXcolor={{HTML}}' + f'{{\\sphinxPYG{_texstylename}tc}}}}%{CR}\\fi' ) if self.in_footnote: self.body.append(CR + r'\sphinxSetupCodeBlockInFootnote') @@ -2314,8 +2325,6 @@ def visit_literal_block(self, node: Element) -> None: # get consistent trailer hlcode = hlcode.rstrip()[:-14] # strip \end{Verbatim} if self.table and not self.in_footnote: - # TODO: probably add a % at end to avoid a space token if for a - # block with style-light option hlcode += r'\end{sphinxVerbatimintable}' else: hlcode += r'\end{sphinxVerbatim}' @@ -2327,7 +2336,7 @@ def visit_literal_block(self, node: Element) -> None: if hllines: self.body.append(r'\sphinxresetverbatimhllines' + CR) if _texstylename: - self.body.append(r'\let\sphinxpygmentsstylename\undefined' + CR) + self.body.append(r'\endgroup' + CR) raise nodes.SkipNode def depart_literal_block(self, node: Element) -> None: