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 476b5da0178..f866e3b27df 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -54,6 +54,11 @@ 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 + :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. Bugs fixed ---------- diff --git a/doc/usage/restructuredtext/directives.rst b/doc/usage/restructuredtext/directives.rst index 5845a6ab717..de5da6659a8 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 @@ -903,6 +905,51 @@ __ https://pygments.org/docs/lexers .. 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 ` & `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. 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 Longer displays of verbatim text may be included by storing the example text diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index e72dffc2b33..e4473a9c533 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -66,7 +66,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 @@ -262,6 +262,19 @@ 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: + + 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]: _deprecation_warning( @@ -691,6 +704,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) @@ -827,16 +841,68 @@ def to_relpath(f: str) -> str: err, ) + 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 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]} + return self.specialized_light_lighters[style]['bridge'] + 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()) + 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: - f.write(self.dark_highlighter.get_stylesheet()) + 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.""" @@ -930,7 +996,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/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index feaa8e021cb..3af185304ec 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -130,6 +130,7 @@ def init(self) -> None: self.docnames: Iterable[str] = {} self.document_data: list[tuple[str, str, str, str, str, bool]] = [] self.themes = ThemeFactory(srcdir=self.srcdir, config=self.config) + self.specialized_highlighters: dict[str, highlighting.PygmentsBridge] = {} texescape.init() self.init_context() @@ -272,6 +273,17 @@ def init_multilingual(self) -> None: self.context['multilingual'] = f'{self.context["polyglossia"]}\n{language}' + 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: + 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) stylesheet = self.outdir / 'sphinxhighlight.sty' @@ -279,16 +291,23 @@ 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: + 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(style_name) + 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() @@ -413,6 +432,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/builders/singlehtml.py b/sphinx/builders/singlehtml.py index 1888f6679d1..46b86b92010 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() 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 0a7419ed563..4a840ec782b 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..2d39b678cc2 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -2,7 +2,9 @@ from __future__ import annotations +import re from functools import partial +from hashlib import md5 from importlib import import_module from typing import TYPE_CHECKING @@ -20,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 __ @@ -90,7 +93,9 @@ \def\PYGZti{\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}}} +\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 """ @@ -229,9 +234,95 @@ 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: 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 self.dest == 'html': - return formatter.get_style_defs('.highlight') + if isinstance(formatter, HtmlFormatter): + if 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') # type: ignore [no-untyped-call] else: - return formatter.get_style_defs() + _LATEX_ADD_STYLES + if selectors: + if not isinstance(selectors, str): + logger.error( + __( + 'Encountered %s in selectors field; expected a string ' + 'for the LaTeX formatter. Please report this error.' + ), + type(selectors), + type='misc', + subtype='highlighting_failure', + ) + # 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 + return stylesheet + sphinx_redefs diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index 39d7ecea680..0f23af5b006 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -600,14 +600,43 @@ def visit_literal_block(self, node: nodes.literal_block) -> 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. + # 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.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.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, + lang, + opts=opts, + linenos=linenos, + location=node, + **highlight_args, + ) starttag = self.starttag( node, 'div', suffix='', CLASS='highlight-%s notranslate' % lang ) diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 39aef55ddfe..5bf521faa0d 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 @@ -2257,14 +2258,57 @@ 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. + _texstylename = '' + if node.get('style-light'): + code_style = node.get('style-light') + 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, + lang, + opts=opts, + linenos=linenos, + location=node, + **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( + 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') hlcode = hlcode.replace(r'\begin{Verbatim}', r'\begin{sphinxVerbatim}') @@ -2291,6 +2335,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'\endgroup' + CR) raise nodes.SkipNode def depart_literal_block(self, node: Element) -> None: