summaryrefslogtreecommitdiffstats
path: root/sphinx/writers
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/writers')
-rw-r--r--sphinx/writers/__init__.py1
-rw-r--r--sphinx/writers/html.py44
-rw-r--r--sphinx/writers/html5.py936
-rw-r--r--sphinx/writers/latex.py2266
-rw-r--r--sphinx/writers/manpage.py473
-rw-r--r--sphinx/writers/texinfo.py1572
-rw-r--r--sphinx/writers/text.py1305
-rw-r--r--sphinx/writers/xml.py52
8 files changed, 6649 insertions, 0 deletions
diff --git a/sphinx/writers/__init__.py b/sphinx/writers/__init__.py
new file mode 100644
index 0000000..e90088e
--- /dev/null
+++ b/sphinx/writers/__init__.py
@@ -0,0 +1 @@
+"""Custom docutils writers."""
diff --git a/sphinx/writers/html.py b/sphinx/writers/html.py
new file mode 100644
index 0000000..99d47c9
--- /dev/null
+++ b/sphinx/writers/html.py
@@ -0,0 +1,44 @@
+"""docutils writers handling Sphinx' custom nodes."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, cast
+
+from docutils.writers.html4css1 import Writer
+
+from sphinx.util import logging
+from sphinx.writers.html5 import HTML5Translator
+
+if TYPE_CHECKING:
+ from sphinx.builders.html import StandaloneHTMLBuilder
+
+
+logger = logging.getLogger(__name__)
+HTMLTranslator = HTML5Translator
+
+# A good overview of the purpose behind these classes can be found here:
+# http://www.arnebrodowski.de/blog/write-your-own-restructuredtext-writer.html
+
+
+class HTMLWriter(Writer):
+
+ # override embed-stylesheet default value to False.
+ settings_default_overrides = {"embed_stylesheet": False}
+
+ def __init__(self, builder: StandaloneHTMLBuilder) -> None:
+ super().__init__()
+ self.builder = builder
+
+ def translate(self) -> None:
+ # sadly, this is mostly copied from parent class
+ visitor = self.builder.create_translator(self.document, self.builder)
+ self.visitor = cast(HTML5Translator, visitor)
+ self.document.walkabout(visitor)
+ self.output = self.visitor.astext()
+ for attr in ('head_prefix', 'stylesheet', 'head', 'body_prefix',
+ 'body_pre_docinfo', 'docinfo', 'body', 'fragment',
+ 'body_suffix', 'meta', 'title', 'subtitle', 'header',
+ 'footer', 'html_prolog', 'html_head', 'html_title',
+ 'html_subtitle', 'html_body'):
+ setattr(self, attr, getattr(visitor, attr, None))
+ self.clean_meta = ''.join(self.visitor.meta[2:])
diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py
new file mode 100644
index 0000000..eb0a1ee
--- /dev/null
+++ b/sphinx/writers/html5.py
@@ -0,0 +1,936 @@
+"""Experimental docutils writers for HTML5 handling Sphinx's custom nodes."""
+
+from __future__ import annotations
+
+import os
+import posixpath
+import re
+import urllib.parse
+from collections.abc import Iterable
+from typing import TYPE_CHECKING, cast
+
+from docutils import nodes
+from docutils.writers.html5_polyglot import HTMLTranslator as BaseTranslator
+
+from sphinx import addnodes
+from sphinx.locale import _, __, admonitionlabels
+from sphinx.util import logging
+from sphinx.util.docutils import SphinxTranslator
+from sphinx.util.images import get_image_size
+
+if TYPE_CHECKING:
+ from docutils.nodes import Element, Node, Text
+
+ from sphinx.builders import Builder
+ from sphinx.builders.html import StandaloneHTMLBuilder
+
+
+logger = logging.getLogger(__name__)
+
+# A good overview of the purpose behind these classes can be found here:
+# http://www.arnebrodowski.de/blog/write-your-own-restructuredtext-writer.html
+
+
+def multiply_length(length: str, scale: int) -> str:
+ """Multiply *length* (width or height) by *scale*."""
+ matched = re.match(r'^(\d*\.?\d*)\s*(\S*)$', length)
+ if not matched:
+ return length
+ if scale == 100:
+ return length
+ amount, unit = matched.groups()
+ result = float(amount) * scale / 100
+ return f"{int(result)}{unit}"
+
+
+class HTML5Translator(SphinxTranslator, BaseTranslator):
+ """
+ Our custom HTML translator.
+ """
+
+ builder: StandaloneHTMLBuilder
+ # Override docutils.writers.html5_polyglot:HTMLTranslator
+ # otherwise, nodes like <inline classes="s">...</inline> will be
+ # converted to <s>...</s> by `visit_inline`.
+ supported_inline_tags: set[str] = set()
+
+ def __init__(self, document: nodes.document, builder: Builder) -> None:
+ super().__init__(document, builder)
+
+ self.highlighter = self.builder.highlighter
+ self.docnames = [self.builder.current_docname] # for singlehtml builder
+ self.manpages_url = self.config.manpages_url
+ self.protect_literal_text = 0
+ self.secnumber_suffix = self.config.html_secnumber_suffix
+ self.param_separator = ''
+ self.optional_param_level = 0
+ self._table_row_indices = [0]
+ self._fieldlist_row_indices = [0]
+ self.required_params_left = 0
+
+ def visit_start_of_file(self, node: Element) -> None:
+ # only occurs in the single-file builder
+ self.docnames.append(node['docname'])
+ self.body.append('<span id="document-%s"></span>' % node['docname'])
+
+ def depart_start_of_file(self, node: Element) -> None:
+ self.docnames.pop()
+
+ #############################################################
+ # Domain-specific object descriptions
+ #############################################################
+
+ # Top-level nodes for descriptions
+ ##################################
+
+ def visit_desc(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'dl'))
+
+ def depart_desc(self, node: Element) -> None:
+ self.body.append('</dl>\n\n')
+
+ def visit_desc_signature(self, node: Element) -> None:
+ # the id is set automatically
+ self.body.append(self.starttag(node, 'dt'))
+ self.protect_literal_text += 1
+
+ def depart_desc_signature(self, node: Element) -> None:
+ self.protect_literal_text -= 1
+ if not node.get('is_multiline'):
+ self.add_permalink_ref(node, _('Link to this definition'))
+ self.body.append('</dt>\n')
+
+ def visit_desc_signature_line(self, node: Element) -> None:
+ pass
+
+ def depart_desc_signature_line(self, node: Element) -> None:
+ if node.get('add_permalink'):
+ # the permalink info is on the parent desc_signature node
+ self.add_permalink_ref(node.parent, _('Link to this definition'))
+ self.body.append('<br />')
+
+ def visit_desc_content(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'dd', ''))
+
+ def depart_desc_content(self, node: Element) -> None:
+ self.body.append('</dd>')
+
+ def visit_desc_inline(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'span', ''))
+
+ def depart_desc_inline(self, node: Element) -> None:
+ self.body.append('</span>')
+
+ # Nodes for high-level structure in signatures
+ ##############################################
+
+ def visit_desc_name(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'span', ''))
+
+ def depart_desc_name(self, node: Element) -> None:
+ self.body.append('</span>')
+
+ def visit_desc_addname(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'span', ''))
+
+ def depart_desc_addname(self, node: Element) -> None:
+ self.body.append('</span>')
+
+ def visit_desc_type(self, node: Element) -> None:
+ pass
+
+ def depart_desc_type(self, node: Element) -> None:
+ pass
+
+ def visit_desc_returns(self, node: Element) -> None:
+ self.body.append(' <span class="sig-return">')
+ self.body.append('<span class="sig-return-icon">&#x2192;</span>')
+ self.body.append(' <span class="sig-return-typehint">')
+
+ def depart_desc_returns(self, node: Element) -> None:
+ self.body.append('</span></span>')
+
+ def _visit_sig_parameter_list(
+ self,
+ node: Element,
+ parameter_group: type[Element],
+ sig_open_paren: str,
+ sig_close_paren: str,
+ ) -> None:
+ """Visit a signature parameters or type parameters list.
+
+ The *parameter_group* value is the type of child nodes acting as required parameters
+ or as a set of contiguous optional parameters.
+ """
+ self.body.append(f'<span class="sig-paren">{sig_open_paren}</span>')
+ self.is_first_param = True
+ self.optional_param_level = 0
+ self.params_left_at_level = 0
+ self.param_group_index = 0
+ # Counts as what we call a parameter group either a required parameter, or a
+ # set of contiguous optional ones.
+ self.list_is_required_param = [isinstance(c, parameter_group) for c in node.children]
+ # How many required parameters are left.
+ self.required_params_left = sum(self.list_is_required_param)
+ self.param_separator = node.child_text_separator
+ self.multi_line_parameter_list = node.get('multi_line_parameter_list', False)
+ if self.multi_line_parameter_list:
+ self.body.append('\n\n')
+ self.body.append(self.starttag(node, 'dl'))
+ self.param_separator = self.param_separator.rstrip()
+ self.context.append(sig_close_paren)
+
+ def _depart_sig_parameter_list(self, node: Element) -> None:
+ if node.get('multi_line_parameter_list'):
+ self.body.append('</dl>\n\n')
+ sig_close_paren = self.context.pop()
+ self.body.append(f'<span class="sig-paren">{sig_close_paren}</span>')
+
+ def visit_desc_parameterlist(self, node: Element) -> None:
+ self._visit_sig_parameter_list(node, addnodes.desc_parameter, '(', ')')
+
+ def depart_desc_parameterlist(self, node: Element) -> None:
+ self._depart_sig_parameter_list(node)
+
+ def visit_desc_type_parameter_list(self, node: Element) -> None:
+ self._visit_sig_parameter_list(node, addnodes.desc_type_parameter, '[', ']')
+
+ def depart_desc_type_parameter_list(self, node: Element) -> None:
+ self._depart_sig_parameter_list(node)
+
+ # If required parameters are still to come, then put the comma after
+ # the parameter. Otherwise, put the comma before. This ensures that
+ # signatures like the following render correctly (see issue #1001):
+ #
+ # foo([a, ]b, c[, d])
+ #
+ def visit_desc_parameter(self, node: Element) -> None:
+ on_separate_line = self.multi_line_parameter_list
+ if on_separate_line and not (self.is_first_param and self.optional_param_level > 0):
+ self.body.append(self.starttag(node, 'dd', ''))
+ if self.is_first_param:
+ self.is_first_param = False
+ elif not on_separate_line and not self.required_params_left:
+ self.body.append(self.param_separator)
+ if self.optional_param_level == 0:
+ self.required_params_left -= 1
+ else:
+ self.params_left_at_level -= 1
+ if not node.hasattr('noemph'):
+ self.body.append('<em class="sig-param">')
+
+ def depart_desc_parameter(self, node: Element) -> None:
+ if not node.hasattr('noemph'):
+ self.body.append('</em>')
+ is_required = self.list_is_required_param[self.param_group_index]
+ if self.multi_line_parameter_list:
+ is_last_group = self.param_group_index + 1 == len(self.list_is_required_param)
+ next_is_required = (
+ not is_last_group
+ and self.list_is_required_param[self.param_group_index + 1]
+ )
+ opt_param_left_at_level = self.params_left_at_level > 0
+ if opt_param_left_at_level or is_required and (is_last_group or next_is_required):
+ self.body.append(self.param_separator)
+ self.body.append('</dd>\n')
+
+ elif self.required_params_left:
+ self.body.append(self.param_separator)
+
+ if is_required:
+ self.param_group_index += 1
+
+ def visit_desc_type_parameter(self, node: Element) -> None:
+ self.visit_desc_parameter(node)
+
+ def depart_desc_type_parameter(self, node: Element) -> None:
+ self.depart_desc_parameter(node)
+
+ def visit_desc_optional(self, node: Element) -> None:
+ self.params_left_at_level = sum([isinstance(c, addnodes.desc_parameter)
+ for c in node.children])
+ self.optional_param_level += 1
+ self.max_optional_param_level = self.optional_param_level
+ if self.multi_line_parameter_list:
+ # If the first parameter is optional, start a new line and open the bracket.
+ if self.is_first_param:
+ self.body.append(self.starttag(node, 'dd', ''))
+ self.body.append('<span class="optional">[</span>')
+ # Else, if there remains at least one required parameter, append the
+ # parameter separator, open a new bracket, and end the line.
+ elif self.required_params_left:
+ self.body.append(self.param_separator)
+ self.body.append('<span class="optional">[</span>')
+ self.body.append('</dd>\n')
+ # Else, open a new bracket, append the parameter separator,
+ # and end the line.
+ else:
+ self.body.append('<span class="optional">[</span>')
+ self.body.append(self.param_separator)
+ self.body.append('</dd>\n')
+ else:
+ self.body.append('<span class="optional">[</span>')
+
+ def depart_desc_optional(self, node: Element) -> None:
+ self.optional_param_level -= 1
+ if self.multi_line_parameter_list:
+ # If it's the first time we go down one level, add the separator
+ # before the bracket.
+ if self.optional_param_level == self.max_optional_param_level - 1:
+ self.body.append(self.param_separator)
+ self.body.append('<span class="optional">]</span>')
+ # End the line if we have just closed the last bracket of this
+ # optional parameter group.
+ if self.optional_param_level == 0:
+ self.body.append('</dd>\n')
+ else:
+ self.body.append('<span class="optional">]</span>')
+ if self.optional_param_level == 0:
+ self.param_group_index += 1
+
+ def visit_desc_annotation(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'em', '', CLASS='property'))
+
+ def depart_desc_annotation(self, node: Element) -> None:
+ self.body.append('</em>')
+
+ ##############################################
+
+ def visit_versionmodified(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'div', CLASS=node['type']))
+
+ def depart_versionmodified(self, node: Element) -> None:
+ self.body.append('</div>\n')
+
+ # overwritten
+ def visit_reference(self, node: Element) -> None:
+ atts = {'class': 'reference'}
+ if node.get('internal') or 'refuri' not in node:
+ atts['class'] += ' internal'
+ else:
+ atts['class'] += ' external'
+ if 'refuri' in node:
+ atts['href'] = node['refuri'] or '#'
+ if self.settings.cloak_email_addresses and atts['href'].startswith('mailto:'):
+ atts['href'] = self.cloak_mailto(atts['href'])
+ self.in_mailto = True
+ else:
+ assert 'refid' in node, \
+ 'References must have "refuri" or "refid" attribute.'
+ atts['href'] = '#' + node['refid']
+ if not isinstance(node.parent, nodes.TextElement):
+ assert len(node) == 1 and isinstance(node[0], nodes.image) # NoQA: PT018
+ atts['class'] += ' image-reference'
+ if 'reftitle' in node:
+ atts['title'] = node['reftitle']
+ if 'target' in node:
+ atts['target'] = node['target']
+ self.body.append(self.starttag(node, 'a', '', **atts))
+
+ if node.get('secnumber'):
+ self.body.append(('%s' + self.secnumber_suffix) %
+ '.'.join(map(str, node['secnumber'])))
+
+ def visit_number_reference(self, node: Element) -> None:
+ self.visit_reference(node)
+
+ def depart_number_reference(self, node: Element) -> None:
+ self.depart_reference(node)
+
+ # overwritten -- we don't want source comments to show up in the HTML
+ def visit_comment(self, node: Element) -> None: # type: ignore[override]
+ raise nodes.SkipNode
+
+ # overwritten
+ def visit_admonition(self, node: Element, name: str = '') -> None:
+ self.body.append(self.starttag(
+ node, 'div', CLASS=('admonition ' + name)))
+ if name:
+ node.insert(0, nodes.title(name, admonitionlabels[name]))
+
+ def depart_admonition(self, node: Element | None = None) -> None:
+ self.body.append('</div>\n')
+
+ def visit_seealso(self, node: Element) -> None:
+ self.visit_admonition(node, 'seealso')
+
+ def depart_seealso(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def get_secnumber(self, node: Element) -> tuple[int, ...] | None:
+ if node.get('secnumber'):
+ return node['secnumber']
+
+ if isinstance(node.parent, nodes.section):
+ if self.builder.name == 'singlehtml':
+ docname = self.docnames[-1]
+ anchorname = "{}/#{}".format(docname, node.parent['ids'][0])
+ if anchorname not in self.builder.secnumbers:
+ anchorname = "%s/" % docname # try first heading which has no anchor
+ else:
+ anchorname = '#' + node.parent['ids'][0]
+ if anchorname not in self.builder.secnumbers:
+ anchorname = '' # try first heading which has no anchor
+
+ if self.builder.secnumbers.get(anchorname):
+ return self.builder.secnumbers[anchorname]
+
+ return None
+
+ def add_secnumber(self, node: Element) -> None:
+ secnumber = self.get_secnumber(node)
+ if secnumber:
+ self.body.append('<span class="section-number">%s</span>' %
+ ('.'.join(map(str, secnumber)) + self.secnumber_suffix))
+
+ def add_fignumber(self, node: Element) -> None:
+ def append_fignumber(figtype: str, figure_id: str) -> None:
+ if self.builder.name == 'singlehtml':
+ key = f"{self.docnames[-1]}/{figtype}"
+ else:
+ key = figtype
+
+ if figure_id in self.builder.fignumbers.get(key, {}):
+ self.body.append('<span class="caption-number">')
+ prefix = self.config.numfig_format.get(figtype)
+ if prefix is None:
+ msg = __('numfig_format is not defined for %s') % figtype
+ logger.warning(msg)
+ else:
+ numbers = self.builder.fignumbers[key][figure_id]
+ self.body.append(prefix % '.'.join(map(str, numbers)) + ' ')
+ self.body.append('</span>')
+
+ figtype = self.builder.env.domains['std'].get_enumerable_node_type(node)
+ if figtype:
+ if len(node['ids']) == 0:
+ msg = __('Any IDs not assigned for %s node') % node.tagname
+ logger.warning(msg, location=node)
+ else:
+ append_fignumber(figtype, node['ids'][0])
+
+ def add_permalink_ref(self, node: Element, title: str) -> None:
+ icon = self.config.html_permalinks_icon
+ if node['ids'] and self.config.html_permalinks and self.builder.add_permalinks:
+ self.body.append(
+ f'<a class="headerlink" href="#{node["ids"][0]}" title="{title}">{icon}</a>',
+ )
+
+ # overwritten
+ def visit_bullet_list(self, node: Element) -> None:
+ if len(node) == 1 and isinstance(node[0], addnodes.toctree):
+ # avoid emitting empty <ul></ul>
+ raise nodes.SkipNode
+ super().visit_bullet_list(node)
+
+ # overwritten
+ def visit_definition(self, node: Element) -> None:
+ # don't insert </dt> here.
+ self.body.append(self.starttag(node, 'dd', ''))
+
+ # overwritten
+ def depart_definition(self, node: Element) -> None:
+ self.body.append('</dd>\n')
+
+ # overwritten
+ def visit_classifier(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'span', '', CLASS='classifier'))
+
+ # overwritten
+ def depart_classifier(self, node: Element) -> None:
+ self.body.append('</span>')
+
+ next_node: Node = node.next_node(descend=False, siblings=True)
+ if not isinstance(next_node, nodes.classifier):
+ # close `<dt>` tag at the tail of classifiers
+ self.body.append('</dt>')
+
+ # overwritten
+ def visit_term(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'dt', ''))
+
+ # overwritten
+ def depart_term(self, node: Element) -> None:
+ next_node: Node = node.next_node(descend=False, siblings=True)
+ if isinstance(next_node, nodes.classifier):
+ # Leave the end tag to `self.depart_classifier()`, in case
+ # there's a classifier.
+ pass
+ else:
+ if isinstance(node.parent.parent.parent, addnodes.glossary):
+ # add permalink if glossary terms
+ self.add_permalink_ref(node, _('Link to this term'))
+
+ self.body.append('</dt>')
+
+ # overwritten
+ def visit_title(self, node: Element) -> None:
+ if isinstance(node.parent, addnodes.compact_paragraph) and node.parent.get('toctree'):
+ self.body.append(self.starttag(node, 'p', '', CLASS='caption', ROLE='heading'))
+ self.body.append('<span class="caption-text">')
+ self.context.append('</span></p>\n')
+ else:
+ super().visit_title(node)
+ self.add_secnumber(node)
+ self.add_fignumber(node.parent)
+ if isinstance(node.parent, nodes.table):
+ self.body.append('<span class="caption-text">')
+
+ def depart_title(self, node: Element) -> None:
+ close_tag = self.context[-1]
+ if (self.config.html_permalinks and self.builder.add_permalinks and
+ node.parent.hasattr('ids') and node.parent['ids']):
+ # add permalink anchor
+ if close_tag.startswith('</h'):
+ self.add_permalink_ref(node.parent, _('Link to this heading'))
+ elif close_tag.startswith('</a></h'):
+ self.body.append('</a><a class="headerlink" href="#%s" ' %
+ node.parent['ids'][0] +
+ 'title="{}">{}'.format(
+ _('Link to this heading'),
+ self.config.html_permalinks_icon))
+ elif isinstance(node.parent, nodes.table):
+ self.body.append('</span>')
+ self.add_permalink_ref(node.parent, _('Link to this table'))
+ elif isinstance(node.parent, nodes.table):
+ self.body.append('</span>')
+
+ super().depart_title(node)
+
+ # overwritten
+ def visit_literal_block(self, node: Element) -> None:
+ if node.rawsource != node.astext():
+ # most probably a parsed-literal block -- don't highlight
+ return super().visit_literal_block(node)
+
+ lang = node.get('language', 'default')
+ linenos = node.get('linenos', False)
+ highlight_args = node.get('highlight_args', {})
+ highlight_args['force'] = node.get('force', False)
+ opts = self.config.highlight_options.get(lang, {})
+
+ 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,
+ )
+ starttag = self.starttag(node, 'div', suffix='',
+ CLASS='highlight-%s notranslate' % lang)
+ self.body.append(starttag + highlighted + '</div>\n')
+ raise nodes.SkipNode
+
+ def visit_caption(self, node: Element) -> None:
+ if isinstance(node.parent, nodes.container) and node.parent.get('literal_block'):
+ self.body.append('<div class="code-block-caption">')
+ else:
+ super().visit_caption(node)
+ self.add_fignumber(node.parent)
+ self.body.append(self.starttag(node, 'span', '', CLASS='caption-text'))
+
+ def depart_caption(self, node: Element) -> None:
+ self.body.append('</span>')
+
+ # append permalink if available
+ if isinstance(node.parent, nodes.container) and node.parent.get('literal_block'):
+ self.add_permalink_ref(node.parent, _('Link to this code'))
+ elif isinstance(node.parent, nodes.figure):
+ self.add_permalink_ref(node.parent, _('Link to this image'))
+ elif node.parent.get('toctree'):
+ self.add_permalink_ref(node.parent.parent, _('Link to this toctree'))
+
+ if isinstance(node.parent, nodes.container) and node.parent.get('literal_block'):
+ self.body.append('</div>\n')
+ else:
+ super().depart_caption(node)
+
+ def visit_doctest_block(self, node: Element) -> None:
+ self.visit_literal_block(node)
+
+ # overwritten to add the <div> (for XHTML compliance)
+ def visit_block_quote(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'blockquote') + '<div>')
+
+ def depart_block_quote(self, node: Element) -> None:
+ self.body.append('</div></blockquote>\n')
+
+ # overwritten
+ def visit_literal(self, node: Element) -> None:
+ if 'kbd' in node['classes']:
+ self.body.append(self.starttag(node, 'kbd', '',
+ CLASS='docutils literal notranslate'))
+ return
+ lang = node.get("language", None)
+ if 'code' not in node['classes'] or not lang:
+ self.body.append(self.starttag(node, 'code', '',
+ CLASS='docutils literal notranslate'))
+ self.protect_literal_text += 1
+ return
+
+ opts = self.config.highlight_options.get(lang, {})
+ highlighted = self.highlighter.highlight_block(
+ node.astext(), lang, opts=opts, location=node, nowrap=True)
+ starttag = self.starttag(
+ node,
+ "code",
+ suffix="",
+ CLASS="docutils literal highlight highlight-%s" % lang,
+ )
+ self.body.append(starttag + highlighted.strip() + "</code>")
+ raise nodes.SkipNode
+
+ def depart_literal(self, node: Element) -> None:
+ if 'kbd' in node['classes']:
+ self.body.append('</kbd>')
+ else:
+ self.protect_literal_text -= 1
+ self.body.append('</code>')
+
+ def visit_productionlist(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'pre'))
+ names = []
+ productionlist = cast(Iterable[addnodes.production], node)
+ for production in productionlist:
+ names.append(production['tokenname'])
+ maxlen = max(len(name) for name in names)
+ lastname = None
+ for production in productionlist:
+ if production['tokenname']:
+ lastname = production['tokenname'].ljust(maxlen)
+ self.body.append(self.starttag(production, 'strong', ''))
+ self.body.append(lastname + '</strong> ::= ')
+ elif lastname is not None:
+ self.body.append('%s ' % (' ' * len(lastname)))
+ production.walkabout(self)
+ self.body.append('\n')
+ self.body.append('</pre>\n')
+ raise nodes.SkipNode
+
+ def depart_productionlist(self, node: Element) -> None:
+ pass
+
+ def visit_production(self, node: Element) -> None:
+ pass
+
+ def depart_production(self, node: Element) -> None:
+ pass
+
+ def visit_centered(self, node: Element) -> None:
+ self.body.append(self.starttag(node, 'p', CLASS="centered") +
+ '<strong>')
+
+ def depart_centered(self, node: Element) -> None:
+ self.body.append('</strong></p>')
+
+ def visit_compact_paragraph(self, node: Element) -> None:
+ pass
+
+ def depart_compact_paragraph(self, node: Element) -> None:
+ pass
+
+ def visit_download_reference(self, node: Element) -> None:
+ atts = {'class': 'reference download',
+ 'download': ''}
+
+ if not self.builder.download_support:
+ self.context.append('')
+ elif 'refuri' in node:
+ atts['class'] += ' external'
+ atts['href'] = node['refuri']
+ self.body.append(self.starttag(node, 'a', '', **atts))
+ self.context.append('</a>')
+ elif 'filename' in node:
+ atts['class'] += ' internal'
+ atts['href'] = posixpath.join(self.builder.dlpath,
+ urllib.parse.quote(node['filename']))
+ self.body.append(self.starttag(node, 'a', '', **atts))
+ self.context.append('</a>')
+ else:
+ self.context.append('')
+
+ def depart_download_reference(self, node: Element) -> None:
+ self.body.append(self.context.pop())
+
+ # overwritten
+ def visit_figure(self, node: Element) -> None:
+ # set align=default if align not specified to give a default style
+ node.setdefault('align', 'default')
+
+ return super().visit_figure(node)
+
+ # overwritten
+ def visit_image(self, node: Element) -> None:
+ olduri = node['uri']
+ # rewrite the URI if the environment knows about it
+ if olduri in self.builder.images:
+ node['uri'] = posixpath.join(self.builder.imgpath,
+ urllib.parse.quote(self.builder.images[olduri]))
+
+ if 'scale' in node:
+ # Try to figure out image height and width. Docutils does that too,
+ # but it tries the final file name, which does not necessarily exist
+ # yet at the time the HTML file is written.
+ if not ('width' in node and 'height' in node):
+ path = os.path.join(self.builder.srcdir, olduri) # type: ignore[has-type]
+ size = get_image_size(path)
+ if size is None:
+ logger.warning(
+ __('Could not obtain image size. :scale: option is ignored.'),
+ location=node,
+ )
+ else:
+ if 'width' not in node:
+ node['width'] = str(size[0])
+ if 'height' not in node:
+ node['height'] = str(size[1])
+
+ uri = node['uri']
+ if uri.lower().endswith(('svg', 'svgz')):
+ atts = {'src': uri}
+ if 'width' in node:
+ atts['width'] = node['width']
+ if 'height' in node:
+ atts['height'] = node['height']
+ if 'scale' in node:
+ if 'width' in atts:
+ atts['width'] = multiply_length(atts['width'], node['scale'])
+ if 'height' in atts:
+ atts['height'] = multiply_length(atts['height'], node['scale'])
+ atts['alt'] = node.get('alt', uri)
+ if 'align' in node:
+ atts['class'] = 'align-%s' % node['align']
+ self.body.append(self.emptytag(node, 'img', '', **atts))
+ return
+
+ super().visit_image(node)
+
+ # overwritten
+ def depart_image(self, node: Element) -> None:
+ if node['uri'].lower().endswith(('svg', 'svgz')):
+ pass
+ else:
+ super().depart_image(node)
+
+ def visit_toctree(self, node: Element) -> None:
+ # this only happens when formatting a toc from env.tocs -- in this
+ # case we don't want to include the subtree
+ raise nodes.SkipNode
+
+ def visit_index(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_tabular_col_spec(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_glossary(self, node: Element) -> None:
+ pass
+
+ def depart_glossary(self, node: Element) -> None:
+ pass
+
+ def visit_acks(self, node: Element) -> None:
+ pass
+
+ def depart_acks(self, node: Element) -> None:
+ pass
+
+ def visit_hlist(self, node: Element) -> None:
+ self.body.append('<table class="hlist"><tr>')
+
+ def depart_hlist(self, node: Element) -> None:
+ self.body.append('</tr></table>\n')
+
+ def visit_hlistcol(self, node: Element) -> None:
+ self.body.append('<td>')
+
+ def depart_hlistcol(self, node: Element) -> None:
+ self.body.append('</td>')
+
+ # overwritten
+ def visit_Text(self, node: Text) -> None:
+ text = node.astext()
+ encoded = self.encode(text)
+ if self.protect_literal_text:
+ # moved here from base class's visit_literal to support
+ # more formatting in literal nodes
+ for token in self.words_and_spaces.findall(encoded):
+ if token.strip():
+ # protect literal text from line wrapping
+ self.body.append('<span class="pre">%s</span>' % token)
+ elif token in ' \n':
+ # allow breaks at whitespace
+ self.body.append(token)
+ else:
+ # protect runs of multiple spaces; the last one can wrap
+ self.body.append('&#160;' * (len(token) - 1) + ' ')
+ else:
+ if self.in_mailto and self.settings.cloak_email_addresses:
+ encoded = self.cloak_email(encoded)
+ self.body.append(encoded)
+
+ def visit_note(self, node: Element) -> None:
+ self.visit_admonition(node, 'note')
+
+ def depart_note(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def visit_warning(self, node: Element) -> None:
+ self.visit_admonition(node, 'warning')
+
+ def depart_warning(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def visit_attention(self, node: Element) -> None:
+ self.visit_admonition(node, 'attention')
+
+ def depart_attention(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def visit_caution(self, node: Element) -> None:
+ self.visit_admonition(node, 'caution')
+
+ def depart_caution(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def visit_danger(self, node: Element) -> None:
+ self.visit_admonition(node, 'danger')
+
+ def depart_danger(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def visit_error(self, node: Element) -> None:
+ self.visit_admonition(node, 'error')
+
+ def depart_error(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def visit_hint(self, node: Element) -> None:
+ self.visit_admonition(node, 'hint')
+
+ def depart_hint(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def visit_important(self, node: Element) -> None:
+ self.visit_admonition(node, 'important')
+
+ def depart_important(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def visit_tip(self, node: Element) -> None:
+ self.visit_admonition(node, 'tip')
+
+ def depart_tip(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def visit_literal_emphasis(self, node: Element) -> None:
+ return self.visit_emphasis(node)
+
+ def depart_literal_emphasis(self, node: Element) -> None:
+ return self.depart_emphasis(node)
+
+ def visit_literal_strong(self, node: Element) -> None:
+ return self.visit_strong(node)
+
+ def depart_literal_strong(self, node: Element) -> None:
+ return self.depart_strong(node)
+
+ def visit_abbreviation(self, node: Element) -> None:
+ attrs = {}
+ if node.hasattr('explanation'):
+ attrs['title'] = node['explanation']
+ self.body.append(self.starttag(node, 'abbr', '', **attrs))
+
+ def depart_abbreviation(self, node: Element) -> None:
+ self.body.append('</abbr>')
+
+ def visit_manpage(self, node: Element) -> None:
+ self.visit_literal_emphasis(node)
+ if self.manpages_url:
+ node['refuri'] = self.manpages_url.format(**node.attributes)
+ self.visit_reference(node)
+
+ def depart_manpage(self, node: Element) -> None:
+ if self.manpages_url:
+ self.depart_reference(node)
+ self.depart_literal_emphasis(node)
+
+ # overwritten to add even/odd classes
+
+ def visit_table(self, node: Element) -> None:
+ self._table_row_indices.append(0)
+
+ atts = {}
+ classes = [cls.strip(' \t\n') for cls in self.settings.table_style.split(',')]
+ classes.insert(0, "docutils") # compat
+
+ # set align-default if align not specified to give a default style
+ classes.append('align-%s' % node.get('align', 'default'))
+
+ if 'width' in node:
+ atts['style'] = 'width: %s' % node['width']
+ tag = self.starttag(node, 'table', CLASS=' '.join(classes), **atts)
+ self.body.append(tag)
+
+ def depart_table(self, node: Element) -> None:
+ self._table_row_indices.pop()
+ super().depart_table(node)
+
+ def visit_row(self, node: Element) -> None:
+ self._table_row_indices[-1] += 1
+ if self._table_row_indices[-1] % 2 == 0:
+ node['classes'].append('row-even')
+ else:
+ node['classes'].append('row-odd')
+ self.body.append(self.starttag(node, 'tr', ''))
+ node.column = 0 # type: ignore[attr-defined]
+
+ def visit_field_list(self, node: Element) -> None:
+ self._fieldlist_row_indices.append(0)
+ return super().visit_field_list(node)
+
+ def depart_field_list(self, node: Element) -> None:
+ self._fieldlist_row_indices.pop()
+ return super().depart_field_list(node)
+
+ def visit_field(self, node: Element) -> None:
+ self._fieldlist_row_indices[-1] += 1
+ if self._fieldlist_row_indices[-1] % 2 == 0:
+ node['classes'].append('field-even')
+ else:
+ node['classes'].append('field-odd')
+
+ def visit_math(self, node: Element, math_env: str = '') -> None:
+ # see validate_math_renderer
+ name: str = self.builder.math_renderer_name # type: ignore[assignment]
+ visit, _ = self.builder.app.registry.html_inline_math_renderers[name]
+ visit(self, node)
+
+ def depart_math(self, node: Element, math_env: str = '') -> None:
+ # see validate_math_renderer
+ name: str = self.builder.math_renderer_name # type: ignore[assignment]
+ _, depart = self.builder.app.registry.html_inline_math_renderers[name]
+ if depart:
+ depart(self, node)
+
+ def visit_math_block(self, node: Element, math_env: str = '') -> None:
+ # see validate_math_renderer
+ name: str = self.builder.math_renderer_name # type: ignore[assignment]
+ visit, _ = self.builder.app.registry.html_block_math_renderers[name]
+ visit(self, node)
+
+ def depart_math_block(self, node: Element, math_env: str = '') -> None:
+ # see validate_math_renderer
+ name: str = self.builder.math_renderer_name # type: ignore[assignment]
+ _, depart = self.builder.app.registry.html_block_math_renderers[name]
+ if depart:
+ depart(self, node)
+
+ # See Docutils r9413
+ # Re-instate the footnote-reference class
+ def visit_footnote_reference(self, node):
+ href = '#' + node['refid']
+ classes = ['footnote-reference', self.settings.footnote_references]
+ self.body.append(self.starttag(node, 'a', suffix='', classes=classes,
+ role='doc-noteref', href=href))
+ self.body.append('<span class="fn-bracket">[</span>')
diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py
new file mode 100644
index 0000000..89b349a
--- /dev/null
+++ b/sphinx/writers/latex.py
@@ -0,0 +1,2266 @@
+"""Custom docutils writer for LaTeX.
+
+Much of this code is adapted from Dave Kuhlman's "docpy" writer from his
+docutils sandbox.
+"""
+
+from __future__ import annotations
+
+import re
+from collections import defaultdict
+from collections.abc import Iterable
+from os import path
+from typing import TYPE_CHECKING, Any, cast
+
+from docutils import nodes, writers
+
+from sphinx import addnodes, highlighting
+from sphinx.domains.std import StandardDomain
+from sphinx.errors import SphinxError
+from sphinx.locale import _, __, admonitionlabels
+from sphinx.util import logging, texescape
+from sphinx.util.docutils import SphinxTranslator
+from sphinx.util.index_entries import split_index_msg
+from sphinx.util.nodes import clean_astext, get_prev_node
+from sphinx.util.template import LaTeXRenderer
+from sphinx.util.texescape import tex_replace_map
+
+try:
+ from docutils.utils.roman import toRoman
+except ImportError:
+ # In Debian/Ubuntu, roman package is provided as roman, not as docutils.utils.roman
+ from roman import toRoman # type: ignore[no-redef]
+
+if TYPE_CHECKING:
+ from docutils.nodes import Element, Node, Text
+
+ from sphinx.builders.latex import LaTeXBuilder
+ from sphinx.builders.latex.theming import Theme
+ from sphinx.domains import IndexEntry
+
+
+logger = logging.getLogger(__name__)
+
+MAX_CITATION_LABEL_LENGTH = 8
+LATEXSECTIONNAMES = ["part", "chapter", "section", "subsection",
+ "subsubsection", "paragraph", "subparagraph"]
+ENUMERATE_LIST_STYLE = defaultdict(lambda: r'\arabic',
+ {
+ 'arabic': r'\arabic',
+ 'loweralpha': r'\alph',
+ 'upperalpha': r'\Alph',
+ 'lowerroman': r'\roman',
+ 'upperroman': r'\Roman',
+ })
+
+CR = '\n'
+BLANKLINE = '\n\n'
+EXTRA_RE = re.compile(r'^(.*\S)\s+\(([^()]*)\)\s*$')
+
+
+class collected_footnote(nodes.footnote):
+ """Footnotes that are collected are assigned this class."""
+
+
+class UnsupportedError(SphinxError):
+ category = 'Markup is unsupported in LaTeX'
+
+
+class LaTeXWriter(writers.Writer):
+
+ supported = ('sphinxlatex',)
+
+ settings_spec = ('LaTeX writer options', '', (
+ ('Document name', ['--docname'], {'default': ''}),
+ ('Document class', ['--docclass'], {'default': 'manual'}),
+ ('Author', ['--author'], {'default': ''}),
+ ))
+ settings_defaults: dict[str, Any] = {}
+
+ theme: Theme
+
+ def __init__(self, builder: LaTeXBuilder) -> None:
+ super().__init__()
+ self.builder = builder
+
+ def translate(self) -> None:
+ visitor = self.builder.create_translator(self.document, self.builder, self.theme)
+ self.document.walkabout(visitor)
+ self.output = cast(LaTeXTranslator, visitor).astext()
+
+
+# Helper classes
+
+class Table:
+ """A table data"""
+
+ def __init__(self, node: Element) -> None:
+ self.header: list[str] = []
+ self.body: list[str] = []
+ self.align = node.get('align', 'default')
+ self.classes: list[str] = node.get('classes', [])
+ self.styles: list[str] = []
+ if 'standard' in self.classes:
+ self.styles.append('standard')
+ elif 'borderless' in self.classes:
+ self.styles.append('borderless')
+ elif 'booktabs' in self.classes:
+ self.styles.append('booktabs')
+ if 'nocolorrows' in self.classes:
+ self.styles.append('nocolorrows')
+ elif 'colorrows' in self.classes:
+ self.styles.append('colorrows')
+ self.colcount = 0
+ self.colspec: str = ''
+ if 'booktabs' in self.styles or 'borderless' in self.styles:
+ self.colsep: str | None = ''
+ elif 'standard' in self.styles:
+ self.colsep = '|'
+ else:
+ self.colsep = None
+ self.colwidths: list[int] = []
+ self.has_problematic = False
+ self.has_oldproblematic = False
+ self.has_verbatim = False
+ self.caption: list[str] = []
+ self.stubs: list[int] = []
+
+ # current position
+ self.col = 0
+ self.row = 0
+
+ # A dict mapping a table location to a cell_id (cell = rectangular area)
+ self.cells: dict[tuple[int, int], int] = defaultdict(int)
+ self.cell_id = 0 # last assigned cell_id
+
+ def is_longtable(self) -> bool:
+ """True if and only if table uses longtable environment."""
+ return self.row > 30 or 'longtable' in self.classes
+
+ def get_table_type(self) -> str:
+ """Returns the LaTeX environment name for the table.
+
+ The class currently supports:
+
+ * longtable
+ * tabular
+ * tabulary
+ """
+ if self.is_longtable():
+ return 'longtable'
+ elif self.has_verbatim:
+ return 'tabular'
+ elif self.colspec:
+ return 'tabulary'
+ elif self.has_problematic or (self.colwidths and 'colwidths-given' in self.classes):
+ return 'tabular'
+ else:
+ return 'tabulary'
+
+ def get_colspec(self) -> str:
+ """Returns a column spec of table.
+
+ This is what LaTeX calls the 'preamble argument' of the used table environment.
+
+ .. note::
+
+ The ``\\X`` and ``T`` column type specifiers are defined in
+ ``sphinxlatextables.sty``.
+ """
+ if self.colspec:
+ return self.colspec
+
+ _colsep = self.colsep
+ assert _colsep is not None
+ if self.colwidths and 'colwidths-given' in self.classes:
+ total = sum(self.colwidths)
+ colspecs = [r'\X{%d}{%d}' % (width, total) for width in self.colwidths]
+ return f'{{{_colsep}{_colsep.join(colspecs)}{_colsep}}}' + CR
+ elif self.has_problematic:
+ return r'{%s*{%d}{\X{1}{%d}%s}}' % (_colsep, self.colcount,
+ self.colcount, _colsep) + CR
+ elif self.get_table_type() == 'tabulary':
+ # sphinx.sty sets T to be J by default.
+ return '{' + _colsep + (('T' + _colsep) * self.colcount) + '}' + CR
+ elif self.has_oldproblematic:
+ return r'{%s*{%d}{\X{1}{%d}%s}}' % (_colsep, self.colcount,
+ self.colcount, _colsep) + CR
+ else:
+ return '{' + _colsep + (('l' + _colsep) * self.colcount) + '}' + CR
+
+ def add_cell(self, height: int, width: int) -> None:
+ """Adds a new cell to a table.
+
+ It will be located at current position: (``self.row``, ``self.col``).
+ """
+ self.cell_id += 1
+ for col in range(width):
+ for row in range(height):
+ assert self.cells[(self.row + row, self.col + col)] == 0
+ self.cells[(self.row + row, self.col + col)] = self.cell_id
+
+ def cell(
+ self, row: int | None = None, col: int | None = None,
+ ) -> TableCell | None:
+ """Returns a cell object (i.e. rectangular area) containing given position.
+
+ If no option arguments: ``row`` or ``col`` are given, the current position;
+ ``self.row`` and ``self.col`` are used to get a cell object by default.
+ """
+ try:
+ if row is None:
+ row = self.row
+ if col is None:
+ col = self.col
+ return TableCell(self, row, col)
+ except IndexError:
+ return None
+
+
+class TableCell:
+ """Data of a cell in a table."""
+
+ def __init__(self, table: Table, row: int, col: int) -> None:
+ if table.cells[(row, col)] == 0:
+ raise IndexError
+
+ self.table = table
+ self.cell_id = table.cells[(row, col)]
+ self.row = row
+ self.col = col
+
+ # adjust position for multirow/multicol cell
+ while table.cells[(self.row - 1, self.col)] == self.cell_id:
+ self.row -= 1
+ while table.cells[(self.row, self.col - 1)] == self.cell_id:
+ self.col -= 1
+
+ @property
+ def width(self) -> int:
+ """Returns the cell width."""
+ width = 0
+ while self.table.cells[(self.row, self.col + width)] == self.cell_id:
+ width += 1
+ return width
+
+ @property
+ def height(self) -> int:
+ """Returns the cell height."""
+ height = 0
+ while self.table.cells[(self.row + height, self.col)] == self.cell_id:
+ height += 1
+ return height
+
+
+def escape_abbr(text: str) -> str:
+ """Adjust spacing after abbreviations."""
+ return re.sub(r'\.(?=\s|$)', r'.\@', text)
+
+
+def rstdim_to_latexdim(width_str: str, scale: int = 100) -> str:
+ """Convert `width_str` with rst length to LaTeX length."""
+ match = re.match(r'^(\d*\.?\d*)\s*(\S*)$', width_str)
+ if not match:
+ raise ValueError
+ res = width_str
+ amount, unit = match.groups()[:2]
+ if scale == 100:
+ float(amount) # validate amount is float
+ if unit in ('', "px"):
+ res = r"%s\sphinxpxdimen" % amount
+ elif unit == 'pt':
+ res = '%sbp' % amount # convert to 'bp'
+ elif unit == "%":
+ res = r"%.3f\linewidth" % (float(amount) / 100.0)
+ else:
+ amount_float = float(amount) * scale / 100.0
+ if unit in ('', "px"):
+ res = r"%.5f\sphinxpxdimen" % amount_float
+ elif unit == 'pt':
+ res = '%.5fbp' % amount_float
+ elif unit == "%":
+ res = r"%.5f\linewidth" % (amount_float / 100.0)
+ else:
+ res = f"{amount_float:.5f}{unit}"
+ return res
+
+
+class LaTeXTranslator(SphinxTranslator):
+ builder: LaTeXBuilder
+
+ secnumdepth = 2 # legacy sphinxhowto.cls uses this, whereas article.cls
+ # default is originally 3. For book/report, 2 is already LaTeX default.
+ ignore_missing_images = False
+
+ def __init__(self, document: nodes.document, builder: LaTeXBuilder,
+ theme: Theme) -> None:
+ super().__init__(document, builder)
+ self.body: list[str] = []
+ self.theme = theme
+
+ # flags
+ self.in_title = 0
+ self.in_production_list = 0
+ self.in_footnote = 0
+ self.in_caption = 0
+ self.in_term = 0
+ self.needs_linetrimming = 0
+ self.in_minipage = 0
+ self.no_latex_floats = 0
+ self.first_document = 1
+ self.this_is_the_title = 1
+ self.literal_whitespace = 0
+ self.in_parsed_literal = 0
+ self.compact_list = 0
+ self.first_param = 0
+ self.in_desc_signature = False
+
+ sphinxpkgoptions = []
+
+ # sort out some elements
+ self.elements = self.builder.context.copy()
+
+ # initial section names
+ self.sectionnames = LATEXSECTIONNAMES[:]
+ if self.theme.toplevel_sectioning == 'section':
+ self.sectionnames.remove('chapter')
+
+ # determine top section level
+ self.top_sectionlevel = 1
+ if self.config.latex_toplevel_sectioning:
+ try:
+ self.top_sectionlevel = \
+ self.sectionnames.index(self.config.latex_toplevel_sectioning)
+ except ValueError:
+ logger.warning(__('unknown %r toplevel_sectioning for class %r') %
+ (self.config.latex_toplevel_sectioning, self.theme.docclass))
+
+ if self.config.numfig:
+ self.numfig_secnum_depth = self.config.numfig_secnum_depth
+ if self.numfig_secnum_depth > 0: # default is 1
+ # numfig_secnum_depth as passed to sphinx.sty indices same names as in
+ # LATEXSECTIONNAMES but with -1 for part, 0 for chapter, 1 for section...
+ if len(self.sectionnames) < len(LATEXSECTIONNAMES) and \
+ self.top_sectionlevel > 0:
+ self.numfig_secnum_depth += self.top_sectionlevel
+ else:
+ self.numfig_secnum_depth += self.top_sectionlevel - 1
+ # this (minus one) will serve as minimum to LaTeX's secnumdepth
+ self.numfig_secnum_depth = min(self.numfig_secnum_depth,
+ len(LATEXSECTIONNAMES) - 1)
+ # if passed key value is < 1 LaTeX will act as if 0; see sphinx.sty
+ sphinxpkgoptions.append('numfigreset=%s' % self.numfig_secnum_depth)
+ else:
+ sphinxpkgoptions.append('nonumfigreset')
+
+ if self.config.numfig and self.config.math_numfig:
+ sphinxpkgoptions.append('mathnumfig')
+
+ if (self.config.language not in {'en', 'ja'} and
+ 'fncychap' not in self.config.latex_elements):
+ # use Sonny style if any language specified (except English)
+ self.elements['fncychap'] = (r'\usepackage[Sonny]{fncychap}' + CR +
+ r'\ChNameVar{\Large\normalfont\sffamily}' + CR +
+ r'\ChTitleVar{\Large\normalfont\sffamily}')
+
+ self.babel = self.builder.babel
+ if not self.babel.is_supported_language():
+ # emit warning if specified language is invalid
+ # (only emitting, nothing changed to processing)
+ logger.warning(__('no Babel option known for language %r'),
+ self.config.language)
+
+ minsecnumdepth = self.secnumdepth # 2 from legacy sphinx manual/howto
+ if self.document.get('tocdepth'):
+ # reduce tocdepth if `part` or `chapter` is used for top_sectionlevel
+ # tocdepth = -1: show only parts
+ # tocdepth = 0: show parts and chapters
+ # tocdepth = 1: show parts, chapters and sections
+ # tocdepth = 2: show parts, chapters, sections and subsections
+ # ...
+ tocdepth = self.document.get('tocdepth', 999) + self.top_sectionlevel - 2
+ if len(self.sectionnames) < len(LATEXSECTIONNAMES) and \
+ self.top_sectionlevel > 0:
+ tocdepth += 1 # because top_sectionlevel is shifted by -1
+ if tocdepth > len(LATEXSECTIONNAMES) - 2: # default is 5 <-> subparagraph
+ logger.warning(__('too large :maxdepth:, ignored.'))
+ tocdepth = len(LATEXSECTIONNAMES) - 2
+
+ self.elements['tocdepth'] = r'\setcounter{tocdepth}{%d}' % tocdepth
+ minsecnumdepth = max(minsecnumdepth, tocdepth)
+
+ if self.config.numfig and (self.config.numfig_secnum_depth > 0):
+ minsecnumdepth = max(minsecnumdepth, self.numfig_secnum_depth - 1)
+
+ if minsecnumdepth > self.secnumdepth:
+ self.elements['secnumdepth'] = r'\setcounter{secnumdepth}{%d}' %\
+ minsecnumdepth
+
+ contentsname = document.get('contentsname')
+ if contentsname:
+ self.elements['contentsname'] = self.babel_renewcommand(r'\contentsname',
+ contentsname)
+
+ if self.elements['maxlistdepth']:
+ sphinxpkgoptions.append('maxlistdepth=%s' % self.elements['maxlistdepth'])
+ if sphinxpkgoptions:
+ self.elements['sphinxpkgoptions'] = '[,%s]' % ','.join(sphinxpkgoptions)
+ if self.elements['sphinxsetup']:
+ self.elements['sphinxsetup'] = (r'\sphinxsetup{%s}' % self.elements['sphinxsetup'])
+ if self.elements['extraclassoptions']:
+ self.elements['classoptions'] += ',' + \
+ self.elements['extraclassoptions']
+
+ self.highlighter = highlighting.PygmentsBridge('latex', self.config.pygments_style,
+ latex_engine=self.config.latex_engine)
+ self.context: list[Any] = []
+ self.descstack: list[str] = []
+ self.tables: list[Table] = []
+ self.next_table_colspec: str | None = None
+ self.bodystack: list[list[str]] = []
+ self.footnote_restricted: Element | None = None
+ self.pending_footnotes: list[nodes.footnote_reference] = []
+ self.curfilestack: list[str] = []
+ self.handled_abbrs: set[str] = set()
+
+ def pushbody(self, newbody: list[str]) -> None:
+ self.bodystack.append(self.body)
+ self.body = newbody
+
+ def popbody(self) -> list[str]:
+ body = self.body
+ self.body = self.bodystack.pop()
+ return body
+
+ def astext(self) -> str:
+ self.elements.update({
+ 'body': ''.join(self.body),
+ 'indices': self.generate_indices(),
+ })
+ return self.render('latex.tex_t', self.elements)
+
+ def hypertarget(self, id: str, withdoc: bool = True, anchor: bool = True) -> str:
+ if withdoc:
+ id = self.curfilestack[-1] + ':' + id
+ return (r'\phantomsection' if anchor else '') + r'\label{%s}' % self.idescape(id)
+
+ def hypertarget_to(self, node: Element, anchor: bool = False) -> str:
+ labels = ''.join(self.hypertarget(node_id, anchor=False) for node_id in node['ids'])
+ if anchor:
+ return r'\phantomsection' + labels
+ else:
+ return labels
+
+ def hyperlink(self, id: str) -> str:
+ return r'{\hyperref[%s]{' % self.idescape(id)
+
+ def hyperpageref(self, id: str) -> str:
+ return r'\autopageref*{%s}' % self.idescape(id)
+
+ def escape(self, s: str) -> str:
+ return texescape.escape(s, self.config.latex_engine)
+
+ def idescape(self, id: str) -> str:
+ return r'\detokenize{%s}' % str(id).translate(tex_replace_map).\
+ encode('ascii', 'backslashreplace').decode('ascii').\
+ replace('\\', '_')
+
+ def babel_renewcommand(self, command: str, definition: str) -> str:
+ if self.elements['multilingual']:
+ prefix = r'\addto\captions%s{' % self.babel.get_language()
+ suffix = '}'
+ else: # babel is disabled (mainly for Japanese environment)
+ prefix = ''
+ suffix = ''
+
+ return fr'{prefix}\renewcommand{{{command}}}{{{definition}}}{suffix}' + CR
+
+ def generate_indices(self) -> str:
+ def generate(content: list[tuple[str, list[IndexEntry]]], collapsed: bool) -> None:
+ ret.append(r'\begin{sphinxtheindex}' + CR)
+ ret.append(r'\let\bigletter\sphinxstyleindexlettergroup' + CR)
+ for i, (letter, entries) in enumerate(content):
+ if i > 0:
+ ret.append(r'\indexspace' + CR)
+ ret.append(r'\bigletter{%s}' % self.escape(letter) + CR)
+ for entry in entries:
+ if not entry[3]:
+ continue
+ ret.append(r'\item\relax\sphinxstyleindexentry{%s}' %
+ self.encode(entry[0]))
+ if entry[4]:
+ # add "extra" info
+ ret.append(r'\sphinxstyleindexextra{%s}' % self.encode(entry[4]))
+ ret.append(r'\sphinxstyleindexpageref{%s:%s}' %
+ (entry[2], self.idescape(entry[3])) + CR)
+ ret.append(r'\end{sphinxtheindex}' + CR)
+
+ ret = []
+ # latex_domain_indices can be False/True or a list of index names
+ indices_config = self.config.latex_domain_indices
+ if indices_config:
+ for domain in self.builder.env.domains.values():
+ for indexcls in domain.indices:
+ indexname = f'{domain.name}-{indexcls.name}'
+ if isinstance(indices_config, list):
+ if indexname not in indices_config:
+ continue
+ content, collapsed = indexcls(domain).generate(
+ self.builder.docnames)
+ if not content:
+ continue
+ ret.append(r'\renewcommand{\indexname}{%s}' % indexcls.localname + CR)
+ generate(content, collapsed)
+
+ return ''.join(ret)
+
+ def render(self, template_name: str, variables: dict[str, Any]) -> str:
+ renderer = LaTeXRenderer(latex_engine=self.config.latex_engine)
+ for template_dir in self.config.templates_path:
+ template = path.join(self.builder.confdir, template_dir,
+ template_name)
+ if path.exists(template):
+ return renderer.render(template, variables)
+
+ return renderer.render(template_name, variables)
+
+ @property
+ def table(self) -> Table | None:
+ """Get current table."""
+ if self.tables:
+ return self.tables[-1]
+ else:
+ return None
+
+ def visit_document(self, node: Element) -> None:
+ self.curfilestack.append(node.get('docname', ''))
+ if self.first_document == 1:
+ # the first document is all the regular content ...
+ self.first_document = 0
+ elif self.first_document == 0:
+ # ... and all others are the appendices
+ self.body.append(CR + r'\appendix' + CR)
+ self.first_document = -1
+ if 'docname' in node:
+ self.body.append(self.hypertarget(':doc'))
+ # "- 1" because the level is increased before the title is visited
+ self.sectionlevel = self.top_sectionlevel - 1
+
+ def depart_document(self, node: Element) -> None:
+ pass
+
+ def visit_start_of_file(self, node: Element) -> None:
+ self.curfilestack.append(node['docname'])
+ self.body.append(CR + r'\sphinxstepscope' + CR)
+
+ def depart_start_of_file(self, node: Element) -> None:
+ self.curfilestack.pop()
+
+ def visit_section(self, node: Element) -> None:
+ if not self.this_is_the_title:
+ self.sectionlevel += 1
+ self.body.append(BLANKLINE)
+
+ def depart_section(self, node: Element) -> None:
+ self.sectionlevel = max(self.sectionlevel - 1,
+ self.top_sectionlevel - 1)
+
+ def visit_problematic(self, node: Element) -> None:
+ self.body.append(r'{\color{red}\bfseries{}')
+
+ def depart_problematic(self, node: Element) -> None:
+ self.body.append('}')
+
+ def visit_topic(self, node: Element) -> None:
+ self.in_minipage = 1
+ self.body.append(CR + r'\begin{sphinxShadowBox}' + CR)
+
+ def depart_topic(self, node: Element) -> None:
+ self.in_minipage = 0
+ self.body.append(r'\end{sphinxShadowBox}' + CR)
+ visit_sidebar = visit_topic
+ depart_sidebar = depart_topic
+
+ def visit_glossary(self, node: Element) -> None:
+ pass
+
+ def depart_glossary(self, node: Element) -> None:
+ pass
+
+ def visit_productionlist(self, node: Element) -> None:
+ self.body.append(BLANKLINE)
+ self.body.append(r'\begin{productionlist}' + CR)
+ self.in_production_list = 1
+
+ def depart_productionlist(self, node: Element) -> None:
+ self.body.append(r'\end{productionlist}' + BLANKLINE)
+ self.in_production_list = 0
+
+ def visit_production(self, node: Element) -> None:
+ if node['tokenname']:
+ tn = node['tokenname']
+ self.body.append(self.hypertarget('grammar-token-' + tn))
+ self.body.append(r'\production{%s}{' % self.encode(tn))
+ else:
+ self.body.append(r'\productioncont{')
+
+ def depart_production(self, node: Element) -> None:
+ self.body.append('}' + CR)
+
+ def visit_transition(self, node: Element) -> None:
+ self.body.append(self.elements['transition'])
+
+ def depart_transition(self, node: Element) -> None:
+ pass
+
+ def visit_title(self, node: Element) -> None:
+ parent = node.parent
+ if isinstance(parent, addnodes.seealso):
+ # the environment already handles this
+ raise nodes.SkipNode
+ if isinstance(parent, nodes.section):
+ if self.this_is_the_title:
+ if len(node.children) != 1 and not isinstance(node.children[0],
+ nodes.Text):
+ logger.warning(__('document title is not a single Text node'),
+ location=node)
+ if not self.elements['title']:
+ # text needs to be escaped since it is inserted into
+ # the output literally
+ self.elements['title'] = self.escape(node.astext())
+ self.this_is_the_title = 0
+ raise nodes.SkipNode
+ short = ''
+ if any(node.findall(nodes.image)):
+ short = ('[%s]' % self.escape(' '.join(clean_astext(node).split())))
+
+ try:
+ self.body.append(fr'\{self.sectionnames[self.sectionlevel]}{short}{{')
+ except IndexError:
+ # just use "subparagraph", it's not numbered anyway
+ self.body.append(fr'\{self.sectionnames[-1]}{short}{{')
+ self.context.append('}' + CR + self.hypertarget_to(node.parent))
+ elif isinstance(parent, nodes.topic):
+ self.body.append(r'\sphinxstyletopictitle{')
+ self.context.append('}' + CR)
+ elif isinstance(parent, nodes.sidebar):
+ self.body.append(r'\sphinxstylesidebartitle{')
+ self.context.append('}' + CR)
+ elif isinstance(parent, nodes.Admonition):
+ self.body.append('{')
+ self.context.append('}' + CR)
+ elif isinstance(parent, nodes.table):
+ # Redirect body output until title is finished.
+ self.pushbody([])
+ else:
+ logger.warning(__('encountered title node not in section, topic, table, '
+ 'admonition or sidebar'),
+ location=node)
+ self.body.append(r'\sphinxstyleothertitle{')
+ self.context.append('}' + CR)
+ self.in_title = 1
+
+ def depart_title(self, node: Element) -> None:
+ self.in_title = 0
+ if isinstance(node.parent, nodes.table):
+ assert self.table is not None
+ self.table.caption = self.popbody()
+ else:
+ self.body.append(self.context.pop())
+
+ def visit_subtitle(self, node: Element) -> None:
+ if isinstance(node.parent, nodes.sidebar):
+ self.body.append(r'\sphinxstylesidebarsubtitle{')
+ self.context.append('}' + CR)
+ else:
+ self.context.append('')
+
+ def depart_subtitle(self, node: Element) -> None:
+ self.body.append(self.context.pop())
+
+ #############################################################
+ # Domain-specific object descriptions
+ #############################################################
+
+ # Top-level nodes for descriptions
+ ##################################
+
+ def visit_desc(self, node: Element) -> None:
+ if self.config.latex_show_urls == 'footnote':
+ self.body.append(BLANKLINE)
+ self.body.append(r'\begin{savenotes}\begin{fulllineitems}' + CR)
+ else:
+ self.body.append(BLANKLINE)
+ self.body.append(r'\begin{fulllineitems}' + CR)
+ if self.table:
+ self.table.has_problematic = True
+
+ def depart_desc(self, node: Element) -> None:
+ if self.in_desc_signature:
+ self.body.append(CR + r'\pysigstopsignatures')
+ self.in_desc_signature = False
+ if self.config.latex_show_urls == 'footnote':
+ self.body.append(CR + r'\end{fulllineitems}\end{savenotes}' + BLANKLINE)
+ else:
+ self.body.append(CR + r'\end{fulllineitems}' + BLANKLINE)
+
+ def _visit_signature_line(self, node: Element) -> None:
+ def next_sibling(e: Node) -> Node | None:
+ try:
+ return e.parent[e.parent.index(e) + 1]
+ except (AttributeError, IndexError):
+ return None
+
+ def has_multi_line(e: Element) -> bool:
+ return e.get('multi_line_parameter_list')
+
+ self.has_tp_list = False
+
+ for child in node:
+ if isinstance(child, addnodes.desc_type_parameter_list):
+ self.has_tp_list = True
+ # recall that return annotations must follow an argument list,
+ # so signatures of the form "foo[tp_list] -> retann" will not
+ # be encountered (if they should, the `domains.python.py_sig_re`
+ # pattern must be modified accordingly)
+ arglist = next_sibling(child)
+ assert isinstance(arglist, addnodes.desc_parameterlist)
+ # tp_list + arglist: \macro{name}{tp_list}{arglist}{return}
+ multi_tp_list = has_multi_line(child)
+ multi_arglist = has_multi_line(arglist)
+
+ if multi_tp_list:
+ if multi_arglist:
+ self.body.append(CR + r'\pysigwithonelineperargwithonelinepertparg{')
+ else:
+ self.body.append(CR + r'\pysiglinewithargsretwithonelinepertparg{')
+ else:
+ if multi_arglist:
+ self.body.append(CR + r'\pysigwithonelineperargwithtypelist{')
+ else:
+ self.body.append(CR + r'\pysiglinewithargsretwithtypelist{')
+ break
+
+ if isinstance(child, addnodes.desc_parameterlist):
+ # arglist only: \macro{name}{arglist}{return}
+ if has_multi_line(child):
+ self.body.append(CR + r'\pysigwithonelineperarg{')
+ else:
+ self.body.append(CR + r'\pysiglinewithargsret{')
+ break
+ else:
+ # no tp_list, no arglist: \macro{name}
+ self.body.append(CR + r'\pysigline{')
+
+ def _depart_signature_line(self, node: Element) -> None:
+ self.body.append('}')
+
+ def visit_desc_signature(self, node: Element) -> None:
+ hyper = ''
+ if node.parent['objtype'] != 'describe' and node['ids']:
+ for id in node['ids']:
+ hyper += self.hypertarget(id)
+ self.body.append(hyper)
+ if not self.in_desc_signature:
+ self.in_desc_signature = True
+ self.body.append(CR + r'\pysigstartsignatures')
+ if not node.get('is_multiline'):
+ self._visit_signature_line(node)
+ else:
+ self.body.append(CR + r'\pysigstartmultiline')
+
+ def depart_desc_signature(self, node: Element) -> None:
+ if not node.get('is_multiline'):
+ self._depart_signature_line(node)
+ else:
+ self.body.append(CR + r'\pysigstopmultiline')
+
+ def visit_desc_signature_line(self, node: Element) -> None:
+ self._visit_signature_line(node)
+
+ def depart_desc_signature_line(self, node: Element) -> None:
+ self._depart_signature_line(node)
+
+ def visit_desc_content(self, node: Element) -> None:
+ assert self.in_desc_signature
+ self.body.append(CR + r'\pysigstopsignatures')
+ self.in_desc_signature = False
+
+ def depart_desc_content(self, node: Element) -> None:
+ pass
+
+ def visit_desc_inline(self, node: Element) -> None:
+ self.body.append(r'\sphinxcode{\sphinxupquote{')
+
+ def depart_desc_inline(self, node: Element) -> None:
+ self.body.append('}}')
+
+ # Nodes for high-level structure in signatures
+ ##############################################
+
+ def visit_desc_name(self, node: Element) -> None:
+ self.body.append(r'\sphinxbfcode{\sphinxupquote{')
+ self.literal_whitespace += 1
+
+ def depart_desc_name(self, node: Element) -> None:
+ self.body.append('}}')
+ self.literal_whitespace -= 1
+
+ def visit_desc_addname(self, node: Element) -> None:
+ self.body.append(r'\sphinxcode{\sphinxupquote{')
+ self.literal_whitespace += 1
+
+ def depart_desc_addname(self, node: Element) -> None:
+ self.body.append('}}')
+ self.literal_whitespace -= 1
+
+ def visit_desc_type(self, node: Element) -> None:
+ pass
+
+ def depart_desc_type(self, node: Element) -> None:
+ pass
+
+ def visit_desc_returns(self, node: Element) -> None:
+ self.body.append(r'{ $\rightarrow$ ')
+
+ def depart_desc_returns(self, node: Element) -> None:
+ self.body.append(r'}')
+
+ def _visit_sig_parameter_list(self, node: Element, parameter_group: type[Element]) -> None:
+ """Visit a signature parameters or type parameters list.
+
+ The *parameter_group* value is the type of a child node acting as a required parameter
+ or as a set of contiguous optional parameters.
+
+ The caller is responsible for closing adding surrounding LaTeX macro argument start
+ and stop tokens.
+ """
+ self.is_first_param = True
+ self.optional_param_level = 0
+ self.params_left_at_level = 0
+ self.param_group_index = 0
+ # Counts as what we call a parameter group either a required parameter, or a
+ # set of contiguous optional ones.
+ self.list_is_required_param = [isinstance(c, parameter_group) for c in node.children]
+ # How many required parameters are left.
+ self.required_params_left = sum(self.list_is_required_param)
+ self.param_separator = r'\sphinxparamcomma '
+ self.multi_line_parameter_list = node.get('multi_line_parameter_list', False)
+
+ def visit_desc_parameterlist(self, node: Element) -> None:
+ if not self.has_tp_list:
+ # close name argument (#1), open parameters list argument (#2)
+ self.body.append('}{')
+ self._visit_sig_parameter_list(node, addnodes.desc_parameter)
+
+ def depart_desc_parameterlist(self, node: Element) -> None:
+ # close parameterlist, open return annotation
+ self.body.append('}{')
+
+ def visit_desc_type_parameter_list(self, node: Element) -> None:
+ # close name argument (#1), open type parameters list argument (#2)
+ self.body.append('}{')
+ self._visit_sig_parameter_list(node, addnodes.desc_type_parameter)
+
+ def depart_desc_type_parameter_list(self, node: Element) -> None:
+ # close type parameters list, open parameters list argument (#3)
+ self.body.append('}{')
+
+ def _visit_sig_parameter(self, node: Element, parameter_macro: str) -> None:
+ if self.is_first_param:
+ self.is_first_param = False
+ elif not self.multi_line_parameter_list and not self.required_params_left:
+ self.body.append(self.param_separator)
+ if self.optional_param_level == 0:
+ self.required_params_left -= 1
+ else:
+ self.params_left_at_level -= 1
+ if not node.hasattr('noemph'):
+ self.body.append(parameter_macro)
+
+ def _depart_sig_parameter(self, node: Element) -> None:
+ if not node.hasattr('noemph'):
+ self.body.append('}')
+ is_required = self.list_is_required_param[self.param_group_index]
+ if self.multi_line_parameter_list:
+ is_last_group = self.param_group_index + 1 == len(self.list_is_required_param)
+ next_is_required = (
+ not is_last_group
+ and self.list_is_required_param[self.param_group_index + 1]
+ )
+ opt_param_left_at_level = self.params_left_at_level > 0
+ if opt_param_left_at_level or is_required and (is_last_group or next_is_required):
+ self.body.append(self.param_separator)
+
+ elif self.required_params_left:
+ self.body.append(self.param_separator)
+
+ if is_required:
+ self.param_group_index += 1
+
+ def visit_desc_parameter(self, node: Element) -> None:
+ self._visit_sig_parameter(node, r'\sphinxparam{')
+
+ def depart_desc_parameter(self, node: Element) -> None:
+ self._depart_sig_parameter(node)
+
+ def visit_desc_type_parameter(self, node: Element) -> None:
+ self._visit_sig_parameter(node, r'\sphinxtypeparam{')
+
+ def depart_desc_type_parameter(self, node: Element) -> None:
+ self._depart_sig_parameter(node)
+
+ def visit_desc_optional(self, node: Element) -> None:
+ self.params_left_at_level = sum([isinstance(c, addnodes.desc_parameter)
+ for c in node.children])
+ self.optional_param_level += 1
+ self.max_optional_param_level = self.optional_param_level
+ if self.multi_line_parameter_list:
+ if self.is_first_param:
+ self.body.append(r'\sphinxoptional{')
+ elif self.required_params_left:
+ self.body.append(self.param_separator)
+ self.body.append(r'\sphinxoptional{')
+ else:
+ self.body.append(r'\sphinxoptional{')
+ self.body.append(self.param_separator)
+ else:
+ self.body.append(r'\sphinxoptional{')
+
+ def depart_desc_optional(self, node: Element) -> None:
+ self.optional_param_level -= 1
+ if self.multi_line_parameter_list:
+ # If it's the first time we go down one level, add the separator before the
+ # bracket.
+ if self.optional_param_level == self.max_optional_param_level - 1:
+ self.body.append(self.param_separator)
+ self.body.append('}')
+ if self.optional_param_level == 0:
+ self.param_group_index += 1
+
+ def visit_desc_annotation(self, node: Element) -> None:
+ self.body.append(r'\sphinxbfcode{\sphinxupquote{')
+
+ def depart_desc_annotation(self, node: Element) -> None:
+ self.body.append('}}')
+
+ ##############################################
+
+ def visit_seealso(self, node: Element) -> None:
+ self.body.append(BLANKLINE)
+ self.body.append(r'\begin{sphinxseealso}{%s:}' % admonitionlabels['seealso'] + CR)
+
+ def depart_seealso(self, node: Element) -> None:
+ self.body.append(BLANKLINE)
+ self.body.append(r'\end{sphinxseealso}')
+ self.body.append(BLANKLINE)
+
+ def visit_rubric(self, node: Element) -> None:
+ if len(node) == 1 and node.astext() in ('Footnotes', _('Footnotes')):
+ raise nodes.SkipNode
+ self.body.append(r'\subsubsection*{')
+ self.context.append('}' + CR)
+ self.in_title = 1
+
+ def depart_rubric(self, node: Element) -> None:
+ self.in_title = 0
+ self.body.append(self.context.pop())
+
+ def visit_footnote(self, node: Element) -> None:
+ self.in_footnote += 1
+ label = cast(nodes.label, node[0])
+ if self.in_parsed_literal:
+ self.body.append(r'\begin{footnote}[%s]' % label.astext())
+ else:
+ self.body.append('%' + CR)
+ self.body.append(r'\begin{footnote}[%s]' % label.astext())
+ if 'referred' in node:
+ # TODO: in future maybe output a latex macro with backrefs here
+ pass
+ self.body.append(r'\sphinxAtStartFootnote' + CR)
+
+ def depart_footnote(self, node: Element) -> None:
+ if self.in_parsed_literal:
+ self.body.append(r'\end{footnote}')
+ else:
+ self.body.append('%' + CR)
+ self.body.append(r'\end{footnote}')
+ self.in_footnote -= 1
+
+ def visit_label(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_tabular_col_spec(self, node: Element) -> None:
+ self.next_table_colspec = node['spec']
+ raise nodes.SkipNode
+
+ def visit_table(self, node: Element) -> None:
+ if len(self.tables) == 1:
+ assert self.table is not None
+ if self.table.get_table_type() == 'longtable':
+ raise UnsupportedError(
+ '%s:%s: longtable does not support nesting a table.' %
+ (self.curfilestack[-1], node.line or ''))
+ # change type of parent table to tabular
+ # see https://groups.google.com/d/msg/sphinx-users/7m3NeOBixeo/9LKP2B4WBQAJ
+ self.table.has_problematic = True
+ elif len(self.tables) > 2:
+ raise UnsupportedError(
+ '%s:%s: deeply nested tables are not implemented.' %
+ (self.curfilestack[-1], node.line or ''))
+
+ table = Table(node)
+ self.tables.append(table)
+ if table.colsep is None:
+ table.colsep = '|' * (
+ 'booktabs' not in self.builder.config.latex_table_style
+ and 'borderless' not in self.builder.config.latex_table_style
+ )
+ if self.next_table_colspec:
+ table.colspec = '{%s}' % self.next_table_colspec + CR
+ if '|' in table.colspec:
+ table.styles.append('vlines')
+ table.colsep = '|'
+ else:
+ table.styles.append('novlines')
+ table.colsep = ''
+ if 'colwidths-given' in node.get('classes', []):
+ logger.info(__('both tabularcolumns and :widths: option are given. '
+ ':widths: is ignored.'), location=node)
+ self.next_table_colspec = None
+
+ def depart_table(self, node: Element) -> None:
+ assert self.table is not None
+ labels = self.hypertarget_to(node)
+ table_type = self.table.get_table_type()
+ table = self.render(table_type + '.tex_t',
+ {'table': self.table, 'labels': labels})
+ self.body.append(BLANKLINE)
+ self.body.append(table)
+ self.body.append(CR)
+
+ self.tables.pop()
+
+ def visit_colspec(self, node: Element) -> None:
+ assert self.table is not None
+ self.table.colcount += 1
+ if 'colwidth' in node:
+ self.table.colwidths.append(node['colwidth'])
+ if 'stub' in node:
+ self.table.stubs.append(self.table.colcount - 1)
+
+ def depart_colspec(self, node: Element) -> None:
+ pass
+
+ def visit_tgroup(self, node: Element) -> None:
+ pass
+
+ def depart_tgroup(self, node: Element) -> None:
+ pass
+
+ def visit_thead(self, node: Element) -> None:
+ assert self.table is not None
+ # Redirect head output until header is finished.
+ self.pushbody(self.table.header)
+
+ def depart_thead(self, node: Element) -> None:
+ if self.body and self.body[-1] == r'\sphinxhline':
+ self.body.pop()
+ self.popbody()
+
+ def visit_tbody(self, node: Element) -> None:
+ assert self.table is not None
+ # Redirect body output until table is finished.
+ self.pushbody(self.table.body)
+
+ def depart_tbody(self, node: Element) -> None:
+ if self.body and self.body[-1] == r'\sphinxhline':
+ self.body.pop()
+ self.popbody()
+
+ def visit_row(self, node: Element) -> None:
+ assert self.table is not None
+ self.table.col = 0
+ _colsep = self.table.colsep
+ # fill columns if the row starts with the bottom of multirow cell
+ while True:
+ cell = self.table.cell(self.table.row, self.table.col)
+ if cell is None: # not a bottom of multirow cell
+ break
+ # a bottom of multirow cell
+ self.table.col += cell.width
+ if cell.col:
+ self.body.append('&')
+ if cell.width == 1:
+ # insert suitable strut for equalizing row heights in given multirow
+ self.body.append(r'\sphinxtablestrut{%d}' % cell.cell_id)
+ else: # use \multicolumn for wide multirow cell
+ self.body.append(r'\multicolumn{%d}{%sl%s}{\sphinxtablestrut{%d}}' %
+ (cell.width, _colsep, _colsep, cell.cell_id))
+
+ def depart_row(self, node: Element) -> None:
+ assert self.table is not None
+ self.body.append(r'\\' + CR)
+ cells = [self.table.cell(self.table.row, i) for i in range(self.table.colcount)]
+ underlined = [cell.row + cell.height == self.table.row + 1 # type: ignore[union-attr]
+ for cell in cells]
+ if all(underlined):
+ self.body.append(r'\sphinxhline')
+ else:
+ i = 0
+ underlined.extend([False]) # sentinel
+ if underlined[0] is False:
+ i = 1
+ while i < self.table.colcount and underlined[i] is False:
+ if cells[i - 1].cell_id != cells[i].cell_id: # type: ignore[union-attr]
+ self.body.append(r'\sphinxvlinecrossing{%d}' % i)
+ i += 1
+ while i < self.table.colcount:
+ # each time here underlined[i] is True
+ j = underlined[i:].index(False)
+ self.body.append(r'\sphinxcline{%d-%d}' % (i + 1, i + j))
+ i += j
+ i += 1
+ while i < self.table.colcount and underlined[i] is False:
+ if cells[i - 1].cell_id != cells[i].cell_id: # type: ignore[union-attr]
+ self.body.append(r'\sphinxvlinecrossing{%d}' % i)
+ i += 1
+ self.body.append(r'\sphinxfixclines{%d}' % self.table.colcount)
+ self.table.row += 1
+
+ def visit_entry(self, node: Element) -> None:
+ assert self.table is not None
+ if self.table.col > 0:
+ self.body.append('&')
+ self.table.add_cell(node.get('morerows', 0) + 1, node.get('morecols', 0) + 1)
+ cell = self.table.cell()
+ assert cell is not None
+ context = ''
+ _colsep = self.table.colsep
+ if cell.width > 1:
+ if self.config.latex_use_latex_multicolumn:
+ if self.table.col == 0:
+ self.body.append(r'\multicolumn{%d}{%sl%s}{%%' %
+ (cell.width, _colsep, _colsep) + CR)
+ else:
+ self.body.append(r'\multicolumn{%d}{l%s}{%%' % (cell.width, _colsep) + CR)
+ context = '}%' + CR
+ else:
+ self.body.append(r'\sphinxstartmulticolumn{%d}%%' % cell.width + CR)
+ context = r'\sphinxstopmulticolumn' + CR
+ if cell.height > 1:
+ # \sphinxmultirow 2nd arg "cell_id" will serve as id for LaTeX macros as well
+ self.body.append(r'\sphinxmultirow{%d}{%d}{%%' % (cell.height, cell.cell_id) + CR)
+ context = '}%' + CR + context
+ if cell.width > 1 or cell.height > 1:
+ self.body.append(r'\begin{varwidth}[t]{\sphinxcolwidth{%d}{%d}}'
+ % (cell.width, self.table.colcount) + CR)
+ context = (r'\par' + CR + r'\vskip-\baselineskip'
+ r'\vbox{\hbox{\strut}}\end{varwidth}%' + CR + context)
+ self.needs_linetrimming = 1
+ if len(list(node.findall(nodes.paragraph))) >= 2:
+ self.table.has_oldproblematic = True
+ if isinstance(node.parent.parent, nodes.thead) or (cell.col in self.table.stubs):
+ if len(node) == 1 and isinstance(node[0], nodes.paragraph) and node.astext() == '':
+ pass
+ else:
+ self.body.append(r'\sphinxstyletheadfamily ')
+ if self.needs_linetrimming:
+ self.pushbody([])
+ self.context.append(context)
+
+ def depart_entry(self, node: Element) -> None:
+ if self.needs_linetrimming:
+ self.needs_linetrimming = 0
+ body = self.popbody()
+
+ # Remove empty lines from top of merged cell
+ while body and body[0] == CR:
+ body.pop(0)
+ self.body.extend(body)
+
+ self.body.append(self.context.pop())
+
+ assert self.table is not None
+ cell = self.table.cell()
+ assert cell is not None
+ self.table.col += cell.width
+ _colsep = self.table.colsep
+
+ # fill columns if next ones are a bottom of wide-multirow cell
+ while True:
+ nextcell = self.table.cell()
+ if nextcell is None: # not a bottom of multirow cell
+ break
+ # a bottom part of multirow cell
+ self.body.append('&')
+ if nextcell.width == 1:
+ # insert suitable strut for equalizing row heights in multirow
+ # they also serve to clear colour panels which would hide the text
+ self.body.append(r'\sphinxtablestrut{%d}' % nextcell.cell_id)
+ else:
+ # use \multicolumn for not first row of wide multirow cell
+ self.body.append(r'\multicolumn{%d}{l%s}{\sphinxtablestrut{%d}}' %
+ (nextcell.width, _colsep, nextcell.cell_id))
+ self.table.col += nextcell.width
+
+ def visit_acks(self, node: Element) -> None:
+ # this is a list in the source, but should be rendered as a
+ # comma-separated list here
+ bullet_list = cast(nodes.bullet_list, node[0])
+ list_items = cast(Iterable[nodes.list_item], bullet_list)
+ self.body.append(BLANKLINE)
+ self.body.append(', '.join(n.astext() for n in list_items) + '.')
+ self.body.append(BLANKLINE)
+ raise nodes.SkipNode
+
+ def visit_bullet_list(self, node: Element) -> None:
+ if not self.compact_list:
+ self.body.append(r'\begin{itemize}' + CR)
+ if self.table:
+ self.table.has_problematic = True
+
+ def depart_bullet_list(self, node: Element) -> None:
+ if not self.compact_list:
+ self.body.append(r'\end{itemize}' + CR)
+
+ def visit_enumerated_list(self, node: Element) -> None:
+ def get_enumtype(node: Element) -> str:
+ enumtype = node.get('enumtype', 'arabic')
+ if 'alpha' in enumtype and (node.get('start', 0) + len(node)) > 26:
+ # fallback to arabic if alphabet counter overflows
+ enumtype = 'arabic'
+
+ return enumtype
+
+ def get_nested_level(node: Element) -> int:
+ if node is None:
+ return 0
+ elif isinstance(node, nodes.enumerated_list):
+ return get_nested_level(node.parent) + 1
+ else:
+ return get_nested_level(node.parent)
+
+ enum = "enum%s" % toRoman(get_nested_level(node)).lower()
+ enumnext = "enum%s" % toRoman(get_nested_level(node) + 1).lower()
+ style = ENUMERATE_LIST_STYLE.get(get_enumtype(node))
+ prefix = node.get('prefix', '')
+ suffix = node.get('suffix', '.')
+
+ self.body.append(r'\begin{enumerate}' + CR)
+ self.body.append(r'\sphinxsetlistlabels{%s}{%s}{%s}{%s}{%s}%%' %
+ (style, enum, enumnext, prefix, suffix) + CR)
+ if 'start' in node:
+ self.body.append(r'\setcounter{%s}{%d}' % (enum, node['start'] - 1) + CR)
+ if self.table:
+ self.table.has_problematic = True
+
+ def depart_enumerated_list(self, node: Element) -> None:
+ self.body.append(r'\end{enumerate}' + CR)
+
+ def visit_list_item(self, node: Element) -> None:
+ # Append "{}" in case the next character is "[", which would break
+ # LaTeX's list environment (no numbering and the "[" is not printed).
+ self.body.append(r'\item {} ')
+
+ def depart_list_item(self, node: Element) -> None:
+ self.body.append(CR)
+
+ def visit_definition_list(self, node: Element) -> None:
+ self.body.append(r'\begin{description}' + CR)
+ if self.table:
+ self.table.has_problematic = True
+
+ def depart_definition_list(self, node: Element) -> None:
+ self.body.append(r'\end{description}' + CR)
+
+ def visit_definition_list_item(self, node: Element) -> None:
+ pass
+
+ def depart_definition_list_item(self, node: Element) -> None:
+ pass
+
+ def visit_term(self, node: Element) -> None:
+ self.in_term += 1
+ ctx = ''
+ if node.get('ids'):
+ ctx = r'\phantomsection'
+ for node_id in node['ids']:
+ ctx += self.hypertarget(node_id, anchor=False)
+ ctx += r'}'
+ self.body.append(r'\sphinxlineitem{')
+ self.context.append(ctx)
+
+ def depart_term(self, node: Element) -> None:
+ self.body.append(self.context.pop())
+ self.in_term -= 1
+
+ def visit_classifier(self, node: Element) -> None:
+ self.body.append('{[}')
+
+ def depart_classifier(self, node: Element) -> None:
+ self.body.append('{]}')
+
+ def visit_definition(self, node: Element) -> None:
+ pass
+
+ def depart_definition(self, node: Element) -> None:
+ self.body.append(CR)
+
+ def visit_field_list(self, node: Element) -> None:
+ self.body.append(r'\begin{quote}\begin{description}' + CR)
+ if self.table:
+ self.table.has_problematic = True
+
+ def depart_field_list(self, node: Element) -> None:
+ self.body.append(r'\end{description}\end{quote}' + CR)
+
+ def visit_field(self, node: Element) -> None:
+ pass
+
+ def depart_field(self, node: Element) -> None:
+ pass
+
+ visit_field_name = visit_term
+ depart_field_name = depart_term
+
+ visit_field_body = visit_definition
+ depart_field_body = depart_definition
+
+ def visit_paragraph(self, node: Element) -> None:
+ index = node.parent.index(node)
+ if (index > 0 and isinstance(node.parent, nodes.compound) and
+ not isinstance(node.parent[index - 1], nodes.paragraph) and
+ not isinstance(node.parent[index - 1], nodes.compound)):
+ # insert blank line, if the paragraph follows a non-paragraph node in a compound
+ self.body.append(r'\noindent' + CR)
+ elif index == 1 and isinstance(node.parent, (nodes.footnote, footnotetext)):
+ # don't insert blank line, if the paragraph is second child of a footnote
+ # (first one is label node)
+ pass
+ else:
+ # the \sphinxAtStartPar is to allow hyphenation of first word of
+ # a paragraph in narrow contexts such as in a table cell
+ # added as two items (cf. line trimming in depart_entry())
+ self.body.extend([CR, r'\sphinxAtStartPar' + CR])
+
+ def depart_paragraph(self, node: Element) -> None:
+ self.body.append(CR)
+
+ def visit_centered(self, node: Element) -> None:
+ self.body.append(CR + r'\begin{center}')
+ if self.table:
+ self.table.has_problematic = True
+
+ def depart_centered(self, node: Element) -> None:
+ self.body.append(CR + r'\end{center}')
+
+ def visit_hlist(self, node: Element) -> None:
+ self.compact_list += 1
+ ncolumns = node['ncolumns']
+ if self.compact_list > 1:
+ self.body.append(r'\setlength{\multicolsep}{0pt}' + CR)
+ self.body.append(r'\begin{multicols}{' + ncolumns + r'}\raggedright' + CR)
+ self.body.append(r'\begin{itemize}\setlength{\itemsep}{0pt}'
+ r'\setlength{\parskip}{0pt}' + CR)
+ if self.table:
+ self.table.has_problematic = True
+
+ def depart_hlist(self, node: Element) -> None:
+ self.compact_list -= 1
+ self.body.append(r'\end{itemize}\raggedcolumns\end{multicols}' + CR)
+
+ def visit_hlistcol(self, node: Element) -> None:
+ pass
+
+ def depart_hlistcol(self, node: Element) -> None:
+ # \columnbreak would guarantee same columns as in html output. But
+ # some testing with long items showed that columns may be too uneven.
+ # And in case only of short items, the automatic column breaks should
+ # match the ones pre-computed by the hlist() directive.
+ # self.body.append(r'\columnbreak\n')
+ pass
+
+ def latex_image_length(self, width_str: str, scale: int = 100) -> str | None:
+ try:
+ return rstdim_to_latexdim(width_str, scale)
+ except ValueError:
+ logger.warning(__('dimension unit %s is invalid. Ignored.'), width_str)
+ return None
+
+ def is_inline(self, node: Element) -> bool:
+ """Check whether a node represents an inline element."""
+ return isinstance(node.parent, nodes.TextElement)
+
+ def visit_image(self, node: Element) -> None:
+ pre: list[str] = [] # in reverse order
+ post: list[str] = []
+ include_graphics_options = []
+ has_hyperlink = isinstance(node.parent, nodes.reference)
+ if has_hyperlink:
+ is_inline = self.is_inline(node.parent)
+ else:
+ is_inline = self.is_inline(node)
+ if 'width' in node:
+ if 'scale' in node:
+ w = self.latex_image_length(node['width'], node['scale'])
+ else:
+ w = self.latex_image_length(node['width'])
+ if w:
+ include_graphics_options.append('width=%s' % w)
+ if 'height' in node:
+ if 'scale' in node:
+ h = self.latex_image_length(node['height'], node['scale'])
+ else:
+ h = self.latex_image_length(node['height'])
+ if h:
+ include_graphics_options.append('height=%s' % h)
+ if 'scale' in node:
+ if not include_graphics_options:
+ # if no "width" nor "height", \sphinxincludegraphics will fit
+ # to the available text width if oversized after rescaling.
+ include_graphics_options.append('scale=%s'
+ % (float(node['scale']) / 100.0))
+ if 'align' in node:
+ align_prepost = {
+ # By default latex aligns the top of an image.
+ (1, 'top'): ('', ''),
+ (1, 'middle'): (r'\raisebox{-0.5\height}{', '}'),
+ (1, 'bottom'): (r'\raisebox{-\height}{', '}'),
+ (0, 'center'): (r'{\hspace*{\fill}', r'\hspace*{\fill}}'),
+ # These 2 don't exactly do the right thing. The image should
+ # be floated alongside the paragraph. See
+ # https://www.w3.org/TR/html4/struct/objects.html#adef-align-IMG
+ (0, 'left'): ('{', r'\hspace*{\fill}}'),
+ (0, 'right'): (r'{\hspace*{\fill}', '}'),
+ }
+ try:
+ pre.append(align_prepost[is_inline, node['align']][0])
+ post.append(align_prepost[is_inline, node['align']][1])
+ except KeyError:
+ pass
+ if self.in_parsed_literal:
+ pre.append(r'{\sphinxunactivateextrasandspace ')
+ post.append('}')
+ if not is_inline and not has_hyperlink:
+ pre.append(CR + r'\noindent')
+ post.append(CR)
+ pre.reverse()
+ if node['uri'] in self.builder.images:
+ uri = self.builder.images[node['uri']]
+ else:
+ # missing image!
+ if self.ignore_missing_images:
+ return
+ uri = node['uri']
+ if uri.find('://') != -1:
+ # ignore remote images
+ return
+ self.body.extend(pre)
+ options = ''
+ if include_graphics_options:
+ options = '[%s]' % ','.join(include_graphics_options)
+ base, ext = path.splitext(uri)
+
+ if self.in_title and base:
+ # Lowercase tokens forcely because some fncychap themes capitalize
+ # the options of \sphinxincludegraphics unexpectedly (ex. WIDTH=...).
+ cmd = fr'\lowercase{{\sphinxincludegraphics{options}}}{{{{{base}}}{ext}}}'
+ else:
+ cmd = fr'\sphinxincludegraphics{options}{{{{{base}}}{ext}}}'
+ # escape filepath for includegraphics, https://tex.stackexchange.com/a/202714/41112
+ if '#' in base:
+ cmd = r'{\catcode`\#=12' + cmd + '}'
+ self.body.append(cmd)
+ self.body.extend(post)
+
+ def depart_image(self, node: Element) -> None:
+ pass
+
+ def visit_figure(self, node: Element) -> None:
+ align = self.elements['figure_align']
+ if self.no_latex_floats:
+ align = "H"
+ if self.table:
+ # TODO: support align option
+ if 'width' in node:
+ length = self.latex_image_length(node['width'])
+ if length:
+ self.body.append(r'\begin{sphinxfigure-in-table}[%s]' % length + CR)
+ self.body.append(r'\centering' + CR)
+ else:
+ self.body.append(r'\begin{sphinxfigure-in-table}' + CR)
+ self.body.append(r'\centering' + CR)
+ if any(isinstance(child, nodes.caption) for child in node):
+ self.body.append(r'\capstart')
+ self.context.append(r'\end{sphinxfigure-in-table}\relax' + CR)
+ elif node.get('align', '') in ('left', 'right'):
+ length = None
+ if 'width' in node:
+ length = self.latex_image_length(node['width'])
+ elif isinstance(node[0], nodes.image) and 'width' in node[0]:
+ length = self.latex_image_length(node[0]['width'])
+ # Insert a blank line to prevent an infinite loop
+ # https://github.com/sphinx-doc/sphinx/issues/7059
+ self.body.append(BLANKLINE)
+ self.body.append(r'\begin{wrapfigure}{%s}{%s}' %
+ ('r' if node['align'] == 'right' else 'l', length or '0pt') + CR)
+ self.body.append(r'\centering')
+ self.context.append(r'\end{wrapfigure}' +
+ BLANKLINE +
+ r'\mbox{}\par\vskip-\dimexpr\baselineskip+\parskip\relax' +
+ CR) # avoid disappearance if no text next issues/11079
+ elif self.in_minipage:
+ self.body.append(CR + r'\begin{center}')
+ self.context.append(r'\end{center}' + CR)
+ else:
+ self.body.append(CR + r'\begin{figure}[%s]' % align + CR)
+ self.body.append(r'\centering' + CR)
+ if any(isinstance(child, nodes.caption) for child in node):
+ self.body.append(r'\capstart' + CR)
+ self.context.append(r'\end{figure}' + CR)
+
+ def depart_figure(self, node: Element) -> None:
+ self.body.append(self.context.pop())
+
+ def visit_caption(self, node: Element) -> None:
+ self.in_caption += 1
+ if isinstance(node.parent, captioned_literal_block):
+ self.body.append(r'\sphinxSetupCaptionForVerbatim{')
+ elif self.in_minipage and isinstance(node.parent, nodes.figure):
+ self.body.append(r'\captionof{figure}{')
+ elif self.table and node.parent.tagname == 'figure':
+ self.body.append(r'\sphinxfigcaption{')
+ else:
+ self.body.append(r'\caption{')
+
+ def depart_caption(self, node: Element) -> None:
+ self.body.append('}')
+ if isinstance(node.parent, nodes.figure):
+ labels = self.hypertarget_to(node.parent)
+ self.body.append(labels)
+ self.in_caption -= 1
+
+ def visit_legend(self, node: Element) -> None:
+ self.body.append(CR + r'\begin{sphinxlegend}')
+
+ def depart_legend(self, node: Element) -> None:
+ self.body.append(r'\end{sphinxlegend}' + CR)
+
+ def visit_admonition(self, node: Element) -> None:
+ self.body.append(CR + r'\begin{sphinxadmonition}{note}')
+ self.no_latex_floats += 1
+
+ def depart_admonition(self, node: Element) -> None:
+ self.body.append(r'\end{sphinxadmonition}' + CR)
+ self.no_latex_floats -= 1
+
+ def _visit_named_admonition(self, node: Element) -> None:
+ label = admonitionlabels[node.tagname]
+ self.body.append(CR + r'\begin{sphinxadmonition}{%s}{%s:}' %
+ (node.tagname, label))
+ self.no_latex_floats += 1
+
+ def _depart_named_admonition(self, node: Element) -> None:
+ self.body.append(r'\end{sphinxadmonition}' + CR)
+ self.no_latex_floats -= 1
+
+ visit_attention = _visit_named_admonition
+ depart_attention = _depart_named_admonition
+ visit_caution = _visit_named_admonition
+ depart_caution = _depart_named_admonition
+ visit_danger = _visit_named_admonition
+ depart_danger = _depart_named_admonition
+ visit_error = _visit_named_admonition
+ depart_error = _depart_named_admonition
+ visit_hint = _visit_named_admonition
+ depart_hint = _depart_named_admonition
+ visit_important = _visit_named_admonition
+ depart_important = _depart_named_admonition
+ visit_note = _visit_named_admonition
+ depart_note = _depart_named_admonition
+ visit_tip = _visit_named_admonition
+ depart_tip = _depart_named_admonition
+ visit_warning = _visit_named_admonition
+ depart_warning = _depart_named_admonition
+
+ def visit_versionmodified(self, node: Element) -> None:
+ pass
+
+ def depart_versionmodified(self, node: Element) -> None:
+ pass
+
+ def visit_target(self, node: Element) -> None:
+ def add_target(id: str) -> None:
+ # indexing uses standard LaTeX index markup, so the targets
+ # will be generated differently
+ if id.startswith('index-'):
+ return
+
+ # equations also need no extra blank line nor hypertarget
+ # TODO: fix this dependency on mathbase extension internals
+ if id.startswith('equation-'):
+ return
+
+ # insert blank line, if the target follows a paragraph node
+ index = node.parent.index(node)
+ if index > 0 and isinstance(node.parent[index - 1], nodes.paragraph):
+ self.body.append(CR)
+
+ # do not generate \phantomsection in \section{}
+ anchor = not self.in_title
+ self.body.append(self.hypertarget(id, anchor=anchor))
+
+ # skip if visitor for next node supports hyperlink
+ next_node: Node = node
+ while isinstance(next_node, nodes.target):
+ next_node = next_node.next_node(ascend=True)
+
+ domain = cast(StandardDomain, self.builder.env.get_domain('std'))
+ if isinstance(next_node, HYPERLINK_SUPPORT_NODES):
+ return
+ if domain.get_enumerable_node_type(next_node) and domain.get_numfig_title(next_node):
+ return
+
+ if 'refuri' in node:
+ return
+ if 'anonymous' in node:
+ return
+ if node.get('refid'):
+ prev_node = get_prev_node(node)
+ if isinstance(prev_node, nodes.reference) and node['refid'] == prev_node['refid']:
+ # a target for a hyperlink reference having alias
+ pass
+ else:
+ add_target(node['refid'])
+ # Temporary fix for https://github.com/sphinx-doc/sphinx/issues/11093
+ # TODO: investigate if a more elegant solution exists (see comments of #11093)
+ if node.get('ismod', False):
+ # Detect if the previous nodes are label targets. If so, remove
+ # the refid thereof from node['ids'] to avoid duplicated ids.
+ def has_dup_label(sib: Node | None) -> bool:
+ return isinstance(sib, nodes.target) and sib.get('refid') in node['ids']
+
+ prev = get_prev_node(node)
+ if has_dup_label(prev):
+ ids = node['ids'][:] # copy to avoid side-effects
+ while has_dup_label(prev):
+ ids.remove(prev['refid']) # type: ignore[index]
+ prev = get_prev_node(prev) # type: ignore[arg-type]
+ else:
+ ids = iter(node['ids']) # read-only iterator
+ else:
+ ids = iter(node['ids']) # read-only iterator
+
+ for id in ids:
+ add_target(id)
+
+ def depart_target(self, node: Element) -> None:
+ pass
+
+ def visit_attribution(self, node: Element) -> None:
+ self.body.append(CR + r'\begin{flushright}' + CR)
+ self.body.append('---')
+
+ def depart_attribution(self, node: Element) -> None:
+ self.body.append(CR + r'\end{flushright}' + CR)
+
+ def visit_index(self, node: Element) -> None:
+ def escape(value: str) -> str:
+ value = self.encode(value)
+ value = value.replace(r'\{', r'\sphinxleftcurlybrace{}')
+ value = value.replace(r'\}', r'\sphinxrightcurlybrace{}')
+ value = value.replace('"', '""')
+ value = value.replace('@', '"@')
+ value = value.replace('!', '"!')
+ value = value.replace('|', r'\textbar{}')
+ return value
+
+ def style(string: str) -> str:
+ match = EXTRA_RE.match(string)
+ if match:
+ return match.expand(r'\\spxentry{\1}\\spxextra{\2}')
+ else:
+ return r'\spxentry{%s}' % string
+
+ if not node.get('inline', True):
+ self.body.append(CR)
+ entries = node['entries']
+ for type, string, _tid, ismain, _key in entries:
+ m = ''
+ if ismain:
+ m = '|spxpagem'
+ try:
+ parts = tuple(map(escape, split_index_msg(type, string)))
+ styled = tuple(map(style, parts))
+ if type == 'single':
+ try:
+ p1, p2 = parts
+ P1, P2 = styled
+ self.body.append(fr'\index{{{p1}@{P1}!{p2}@{P2}{m}}}')
+ except ValueError:
+ p, = parts
+ P, = styled
+ self.body.append(fr'\index{{{p}@{P}{m}}}')
+ elif type == 'pair':
+ p1, p2 = parts
+ P1, P2 = styled
+ self.body.append(fr'\index{{{p1}@{P1}!{p2}@{P2}{m}}}'
+ fr'\index{{{p2}@{P2}!{p1}@{P1}{m}}}')
+ elif type == 'triple':
+ p1, p2, p3 = parts
+ P1, P2, P3 = styled
+ self.body.append(
+ fr'\index{{{p1}@{P1}!{p2} {p3}@{P2} {P3}{m}}}'
+ fr'\index{{{p2}@{P2}!{p3}, {p1}@{P3}, {P1}{m}}}'
+ fr'\index{{{p3}@{P3}!{p1} {p2}@{P1} {P2}{m}}}')
+ elif type in {'see', 'seealso'}:
+ p1, p2 = parts
+ P1, _P2 = styled
+ self.body.append(fr'\index{{{p1}@{P1}|see{{{p2}}}}}')
+ else:
+ logger.warning(__('unknown index entry type %s found'), type)
+ except ValueError as err:
+ logger.warning(str(err))
+ if not node.get('inline', True):
+ self.body.append(r'\ignorespaces ')
+ raise nodes.SkipNode
+
+ def visit_raw(self, node: Element) -> None:
+ if not self.is_inline(node):
+ self.body.append(CR)
+ if 'latex' in node.get('format', '').split():
+ self.body.append(node.astext())
+ if not self.is_inline(node):
+ self.body.append(CR)
+ raise nodes.SkipNode
+
+ def visit_reference(self, node: Element) -> None:
+ if not self.in_title:
+ for id in node.get('ids'):
+ anchor = not self.in_caption
+ self.body += self.hypertarget(id, anchor=anchor)
+ if not self.is_inline(node):
+ self.body.append(CR)
+ uri = node.get('refuri', '')
+ if not uri and node.get('refid'):
+ uri = '%' + self.curfilestack[-1] + '#' + node['refid']
+ if self.in_title or not uri:
+ self.context.append('')
+ elif uri.startswith('#'):
+ # references to labels in the same document
+ id = self.curfilestack[-1] + ':' + uri[1:]
+ self.body.append(self.hyperlink(id))
+ self.body.append(r'\sphinxsamedocref{')
+ if self.config.latex_show_pagerefs and not \
+ self.in_production_list:
+ self.context.append('}}} (%s)' % self.hyperpageref(id))
+ else:
+ self.context.append('}}}')
+ elif uri.startswith('%'):
+ # references to documents or labels inside documents
+ hashindex = uri.find('#')
+ if hashindex == -1:
+ # reference to the document
+ id = uri[1:] + '::doc'
+ else:
+ # reference to a label
+ id = uri[1:].replace('#', ':')
+ self.body.append(self.hyperlink(id))
+ if (len(node) and
+ isinstance(node[0], nodes.Element) and
+ 'std-term' in node[0].get('classes', [])):
+ # don't add a pageref for glossary terms
+ self.context.append('}}}')
+ # mark up as termreference
+ self.body.append(r'\sphinxtermref{')
+ else:
+ self.body.append(r'\sphinxcrossref{')
+ if self.config.latex_show_pagerefs and not self.in_production_list:
+ self.context.append('}}} (%s)' % self.hyperpageref(id))
+ else:
+ self.context.append('}}}')
+ else:
+ if len(node) == 1 and uri == node[0]:
+ if node.get('nolinkurl'):
+ self.body.append(r'\sphinxnolinkurl{%s}' % self.encode_uri(uri))
+ else:
+ self.body.append(r'\sphinxurl{%s}' % self.encode_uri(uri))
+ raise nodes.SkipNode
+ else:
+ self.body.append(r'\sphinxhref{%s}{' % self.encode_uri(uri))
+ self.context.append('}')
+
+ def depart_reference(self, node: Element) -> None:
+ self.body.append(self.context.pop())
+ if not self.is_inline(node):
+ self.body.append(CR)
+
+ def visit_number_reference(self, node: Element) -> None:
+ if node.get('refid'):
+ id = self.curfilestack[-1] + ':' + node['refid']
+ else:
+ id = node.get('refuri', '')[1:].replace('#', ':')
+
+ title = self.escape(node.get('title', '%s')).replace(r'\%s', '%s')
+ if r'\{name\}' in title or r'\{number\}' in title:
+ # new style format (cf. "Fig.%{number}")
+ title = title.replace(r'\{name\}', '{name}').replace(r'\{number\}', '{number}')
+ text = escape_abbr(title).format(name=r'\nameref{%s}' % self.idescape(id),
+ number=r'\ref{%s}' % self.idescape(id))
+ else:
+ # old style format (cf. "Fig.%{number}")
+ text = escape_abbr(title) % (r'\ref{%s}' % self.idescape(id))
+ hyperref = fr'\hyperref[{self.idescape(id)}]{{{text}}}'
+ self.body.append(hyperref)
+
+ raise nodes.SkipNode
+
+ def visit_download_reference(self, node: Element) -> None:
+ pass
+
+ def depart_download_reference(self, node: Element) -> None:
+ pass
+
+ def visit_pending_xref(self, node: Element) -> None:
+ pass
+
+ def depart_pending_xref(self, node: Element) -> None:
+ pass
+
+ def visit_emphasis(self, node: Element) -> None:
+ self.body.append(r'\sphinxstyleemphasis{')
+
+ def depart_emphasis(self, node: Element) -> None:
+ self.body.append('}')
+
+ def visit_literal_emphasis(self, node: Element) -> None:
+ self.body.append(r'\sphinxstyleliteralemphasis{\sphinxupquote{')
+
+ def depart_literal_emphasis(self, node: Element) -> None:
+ self.body.append('}}')
+
+ def visit_strong(self, node: Element) -> None:
+ self.body.append(r'\sphinxstylestrong{')
+
+ def depart_strong(self, node: Element) -> None:
+ self.body.append('}')
+
+ def visit_literal_strong(self, node: Element) -> None:
+ self.body.append(r'\sphinxstyleliteralstrong{\sphinxupquote{')
+
+ def depart_literal_strong(self, node: Element) -> None:
+ self.body.append('}}')
+
+ def visit_abbreviation(self, node: Element) -> None:
+ abbr = node.astext()
+ self.body.append(r'\sphinxstyleabbreviation{')
+ # spell out the explanation once
+ if node.hasattr('explanation') and abbr not in self.handled_abbrs:
+ self.context.append('} (%s)' % self.encode(node['explanation']))
+ self.handled_abbrs.add(abbr)
+ else:
+ self.context.append('}')
+
+ def depart_abbreviation(self, node: Element) -> None:
+ self.body.append(self.context.pop())
+
+ def visit_manpage(self, node: Element) -> None:
+ return self.visit_literal_emphasis(node)
+
+ def depart_manpage(self, node: Element) -> None:
+ return self.depart_literal_emphasis(node)
+
+ def visit_title_reference(self, node: Element) -> None:
+ self.body.append(r'\sphinxtitleref{')
+
+ def depart_title_reference(self, node: Element) -> None:
+ self.body.append('}')
+
+ def visit_thebibliography(self, node: Element) -> None:
+ citations = cast(Iterable[nodes.citation], node)
+ labels = (cast(nodes.label, citation[0]) for citation in citations)
+ longest_label = max((label.astext() for label in labels), key=len)
+ if len(longest_label) > MAX_CITATION_LABEL_LENGTH:
+ # adjust max width of citation labels not to break the layout
+ longest_label = longest_label[:MAX_CITATION_LABEL_LENGTH]
+
+ self.body.append(CR + r'\begin{sphinxthebibliography}{%s}' %
+ self.encode(longest_label) + CR)
+
+ def depart_thebibliography(self, node: Element) -> None:
+ self.body.append(r'\end{sphinxthebibliography}' + CR)
+
+ def visit_citation(self, node: Element) -> None:
+ label = cast(nodes.label, node[0])
+ self.body.append(fr'\bibitem[{self.encode(label.astext())}]'
+ fr'{{{node["docname"]}:{node["ids"][0]}}}')
+
+ def depart_citation(self, node: Element) -> None:
+ pass
+
+ def visit_citation_reference(self, node: Element) -> None:
+ if self.in_title:
+ pass
+ else:
+ self.body.append(fr'\sphinxcite{{{node["docname"]}:{node["refname"]}}}')
+ raise nodes.SkipNode
+
+ def depart_citation_reference(self, node: Element) -> None:
+ pass
+
+ def visit_literal(self, node: Element) -> None:
+ if self.in_title:
+ self.body.append(r'\sphinxstyleliteralintitle{\sphinxupquote{')
+ return
+ elif 'kbd' in node['classes']:
+ self.body.append(r'\sphinxkeyboard{\sphinxupquote{')
+ return
+ lang = node.get("language", None)
+ if 'code' not in node['classes'] or not lang:
+ self.body.append(r'\sphinxcode{\sphinxupquote{')
+ return
+
+ opts = self.config.highlight_options.get(lang, {})
+ hlcode = self.highlighter.highlight_block(
+ node.astext(), lang, opts=opts, location=node, nowrap=True)
+ self.body.append(r'\sphinxcode{\sphinxupquote{%' + CR
+ + hlcode.rstrip() + '%' + CR
+ + '}}')
+ raise nodes.SkipNode
+
+ def depart_literal(self, node: Element) -> None:
+ self.body.append('}}')
+
+ def visit_footnote_reference(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_footnotemark(self, node: Element) -> None:
+ self.body.append(r'\sphinxfootnotemark[')
+
+ def depart_footnotemark(self, node: Element) -> None:
+ self.body.append(']')
+
+ def visit_footnotetext(self, node: Element) -> None:
+ label = cast(nodes.label, node[0])
+ self.body.append('%' + CR)
+ self.body.append(r'\begin{footnotetext}[%s]' % label.astext())
+ self.body.append(r'\sphinxAtStartFootnote' + CR)
+
+ def depart_footnotetext(self, node: Element) -> None:
+ # the \ignorespaces in particular for after table header use
+ self.body.append('%' + CR)
+ self.body.append(r'\end{footnotetext}\ignorespaces ')
+
+ def visit_captioned_literal_block(self, node: Element) -> None:
+ pass
+
+ def depart_captioned_literal_block(self, node: Element) -> None:
+ pass
+
+ def visit_literal_block(self, node: Element) -> None:
+ if node.rawsource != node.astext():
+ # most probably a parsed-literal block -- don't highlight
+ self.in_parsed_literal += 1
+ self.body.append(r'\begin{sphinxalltt}' + CR)
+ else:
+ labels = self.hypertarget_to(node)
+ if isinstance(node.parent, captioned_literal_block):
+ labels += self.hypertarget_to(node.parent)
+ if labels and not self.in_footnote:
+ self.body.append(CR + r'\def\sphinxLiteralBlockLabel{' + labels + '}')
+
+ lang = node.get('language', 'default')
+ linenos = node.get('linenos', False)
+ highlight_args = node.get('highlight_args', {})
+ 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,
+ )
+ if self.in_footnote:
+ self.body.append(CR + r'\sphinxSetupCodeBlockInFootnote')
+ hlcode = hlcode.replace(r'\begin{Verbatim}',
+ r'\begin{sphinxVerbatim}')
+ # if in table raise verbatim flag to avoid "tabulary" environment
+ # and opt for sphinxVerbatimintable to handle caption & long lines
+ elif self.table:
+ self.table.has_problematic = True
+ self.table.has_verbatim = True
+ hlcode = hlcode.replace(r'\begin{Verbatim}',
+ r'\begin{sphinxVerbatimintable}')
+ else:
+ hlcode = hlcode.replace(r'\begin{Verbatim}',
+ r'\begin{sphinxVerbatim}')
+ # get consistent trailer
+ hlcode = hlcode.rstrip()[:-14] # strip \end{Verbatim}
+ if self.table and not self.in_footnote:
+ hlcode += r'\end{sphinxVerbatimintable}'
+ else:
+ hlcode += r'\end{sphinxVerbatim}'
+
+ hllines = str(highlight_args.get('hl_lines', []))[1:-1]
+ if hllines:
+ self.body.append(CR + r'\fvset{hllines={, %s,}}%%' % hllines)
+ self.body.append(CR + hlcode + CR)
+ if hllines:
+ self.body.append(r'\sphinxresetverbatimhllines' + CR)
+ raise nodes.SkipNode
+
+ def depart_literal_block(self, node: Element) -> None:
+ self.body.append(CR + r'\end{sphinxalltt}' + CR)
+ self.in_parsed_literal -= 1
+ visit_doctest_block = visit_literal_block
+ depart_doctest_block = depart_literal_block
+
+ def visit_line(self, node: Element) -> None:
+ self.body.append(r'\item[] ')
+
+ def depart_line(self, node: Element) -> None:
+ self.body.append(CR)
+
+ def visit_line_block(self, node: Element) -> None:
+ if isinstance(node.parent, nodes.line_block):
+ self.body.append(r'\item[]' + CR)
+ self.body.append(r'\begin{DUlineblock}{\DUlineblockindent}' + CR)
+ else:
+ self.body.append(CR + r'\begin{DUlineblock}{0em}' + CR)
+ if self.table:
+ self.table.has_problematic = True
+
+ def depart_line_block(self, node: Element) -> None:
+ self.body.append(r'\end{DUlineblock}' + CR)
+
+ def visit_block_quote(self, node: Element) -> None:
+ # If the block quote contains a single object and that object
+ # is a list, then generate a list not a block quote.
+ # This lets us indent lists.
+ done = 0
+ if len(node.children) == 1:
+ child = node.children[0]
+ if isinstance(child, (nodes.bullet_list, nodes.enumerated_list)):
+ done = 1
+ if not done:
+ self.body.append(r'\begin{quote}' + CR)
+ if self.table:
+ self.table.has_problematic = True
+
+ def depart_block_quote(self, node: Element) -> None:
+ done = 0
+ if len(node.children) == 1:
+ child = node.children[0]
+ if isinstance(child, (nodes.bullet_list, nodes.enumerated_list)):
+ done = 1
+ if not done:
+ self.body.append(r'\end{quote}' + CR)
+
+ # option node handling copied from docutils' latex writer
+
+ def visit_option(self, node: Element) -> None:
+ if self.context[-1]:
+ # this is not the first option
+ self.body.append(', ')
+
+ def depart_option(self, node: Element) -> None:
+ # flag that the first option is done.
+ self.context[-1] += 1
+
+ def visit_option_argument(self, node: Element) -> None:
+ """The delimiter between an option and its argument."""
+ self.body.append(node.get('delimiter', ' '))
+
+ def depart_option_argument(self, node: Element) -> None:
+ pass
+
+ def visit_option_group(self, node: Element) -> None:
+ self.body.append(r'\item [')
+ # flag for first option
+ self.context.append(0)
+
+ def depart_option_group(self, node: Element) -> None:
+ self.context.pop() # the flag
+ self.body.append('] ')
+
+ def visit_option_list(self, node: Element) -> None:
+ self.body.append(r'\begin{optionlist}{3cm}' + CR)
+ if self.table:
+ self.table.has_problematic = True
+
+ def depart_option_list(self, node: Element) -> None:
+ self.body.append(r'\end{optionlist}' + CR)
+
+ def visit_option_list_item(self, node: Element) -> None:
+ pass
+
+ def depart_option_list_item(self, node: Element) -> None:
+ pass
+
+ def visit_option_string(self, node: Element) -> None:
+ ostring = node.astext()
+ self.body.append(self.encode(ostring))
+ raise nodes.SkipNode
+
+ def visit_description(self, node: Element) -> None:
+ self.body.append(' ')
+
+ def depart_description(self, node: Element) -> None:
+ pass
+
+ def visit_superscript(self, node: Element) -> None:
+ self.body.append(r'$^{\text{')
+
+ def depart_superscript(self, node: Element) -> None:
+ self.body.append('}}$')
+
+ def visit_subscript(self, node: Element) -> None:
+ self.body.append(r'$_{\text{')
+
+ def depart_subscript(self, node: Element) -> None:
+ self.body.append('}}$')
+
+ def visit_inline(self, node: Element) -> None:
+ classes = node.get('classes', [])
+ if classes in [['menuselection']]:
+ self.body.append(r'\sphinxmenuselection{')
+ self.context.append('}')
+ elif classes in [['guilabel']]:
+ self.body.append(r'\sphinxguilabel{')
+ self.context.append('}')
+ elif classes in [['accelerator']]:
+ self.body.append(r'\sphinxaccelerator{')
+ self.context.append('}')
+ elif classes and not self.in_title:
+ self.body.append(r'\DUrole{%s}{' % ','.join(classes))
+ self.context.append('}')
+ else:
+ self.context.append('')
+
+ def depart_inline(self, node: Element) -> None:
+ self.body.append(self.context.pop())
+
+ def visit_generated(self, node: Element) -> None:
+ pass
+
+ def depart_generated(self, node: Element) -> None:
+ pass
+
+ def visit_compound(self, node: Element) -> None:
+ pass
+
+ def depart_compound(self, node: Element) -> None:
+ pass
+
+ def visit_container(self, node: Element) -> None:
+ classes = node.get('classes', [])
+ for c in classes:
+ self.body.append('\n\\begin{sphinxuseclass}{%s}' % c)
+
+ def depart_container(self, node: Element) -> None:
+ classes = node.get('classes', [])
+ for _c in classes:
+ self.body.append('\n\\end{sphinxuseclass}')
+
+ def visit_decoration(self, node: Element) -> None:
+ pass
+
+ def depart_decoration(self, node: Element) -> None:
+ pass
+
+ # docutils-generated elements that we don't support
+
+ def visit_header(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_footer(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_docinfo(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ # text handling
+
+ def encode(self, text: str) -> str:
+ text = self.escape(text)
+ if self.literal_whitespace:
+ # Insert a blank before the newline, to avoid
+ # ! LaTeX Error: There's no line here to end.
+ text = text.replace(CR, r'~\\' + CR).replace(' ', '~')
+ return text
+
+ def encode_uri(self, text: str) -> str:
+ # TODO: it is probably wrong that this uses texescape.escape()
+ # this must be checked against hyperref package exact dealings
+ # mainly, %, #, {, } and \ need escaping via a \ escape
+ # in \href, the tilde is allowed and must be represented literally
+ return self.encode(text).replace(r'\textasciitilde{}', '~').\
+ replace(r'\sphinxhyphen{}', '-').\
+ replace(r'\textquotesingle{}', "'")
+
+ def visit_Text(self, node: Text) -> None:
+ text = self.encode(node.astext())
+ self.body.append(text)
+
+ def depart_Text(self, node: Text) -> None:
+ pass
+
+ def visit_comment(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_meta(self, node: Element) -> None:
+ # only valid for HTML
+ raise nodes.SkipNode
+
+ def visit_system_message(self, node: Element) -> None:
+ pass
+
+ def depart_system_message(self, node: Element) -> None:
+ self.body.append(CR)
+
+ def visit_math(self, node: Element) -> None:
+ if self.in_title:
+ self.body.append(r'\protect\(%s\protect\)' % node.astext())
+ else:
+ self.body.append(r'\(%s\)' % node.astext())
+ raise nodes.SkipNode
+
+ def visit_math_block(self, node: Element) -> None:
+ if node.get('label'):
+ label = f"equation:{node['docname']}:{node['label']}"
+ else:
+ label = None
+
+ if node.get('nowrap'):
+ if label:
+ self.body.append(r'\label{%s}' % label)
+ self.body.append(node.astext())
+ else:
+ from sphinx.util.math import wrap_displaymath
+ self.body.append(wrap_displaymath(node.astext(), label,
+ self.config.math_number_all))
+ raise nodes.SkipNode
+
+ def visit_math_reference(self, node: Element) -> None:
+ label = f"equation:{node['docname']}:{node['target']}"
+ eqref_format = self.config.math_eqref_format
+ if eqref_format:
+ try:
+ ref = r'\ref{%s}' % label
+ self.body.append(eqref_format.format(number=ref))
+ except KeyError as exc:
+ logger.warning(__('Invalid math_eqref_format: %r'), exc,
+ location=node)
+ self.body.append(r'\eqref{%s}' % label)
+ else:
+ self.body.append(r'\eqref{%s}' % label)
+
+ def depart_math_reference(self, node: Element) -> None:
+ pass
+
+
+# FIXME: Workaround to avoid circular import
+# refs: https://github.com/sphinx-doc/sphinx/issues/5433
+from sphinx.builders.latex.nodes import ( # noqa: E402 # isort:skip
+ HYPERLINK_SUPPORT_NODES, captioned_literal_block, footnotetext,
+)
diff --git a/sphinx/writers/manpage.py b/sphinx/writers/manpage.py
new file mode 100644
index 0000000..108eb5e
--- /dev/null
+++ b/sphinx/writers/manpage.py
@@ -0,0 +1,473 @@
+"""Manual page writer, extended for Sphinx custom nodes."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from typing import TYPE_CHECKING, Any, cast
+
+from docutils import nodes
+from docutils.writers.manpage import Translator as BaseTranslator
+from docutils.writers.manpage import Writer
+
+from sphinx import addnodes
+from sphinx.locale import _, admonitionlabels
+from sphinx.util import logging
+from sphinx.util.docutils import SphinxTranslator
+from sphinx.util.i18n import format_date
+from sphinx.util.nodes import NodeMatcher
+
+if TYPE_CHECKING:
+ from docutils.nodes import Element
+
+ from sphinx.builders import Builder
+
+logger = logging.getLogger(__name__)
+
+
+class ManualPageWriter(Writer):
+ def __init__(self, builder: Builder) -> None:
+ super().__init__()
+ self.builder = builder
+
+ def translate(self) -> None:
+ transform = NestedInlineTransform(self.document)
+ transform.apply()
+ visitor = self.builder.create_translator(self.document, self.builder)
+ self.visitor = cast(ManualPageTranslator, visitor)
+ self.document.walkabout(visitor)
+ self.output = self.visitor.astext()
+
+
+class NestedInlineTransform:
+ """
+ Flatten nested inline nodes:
+
+ Before:
+ <strong>foo=<emphasis>1</emphasis>
+ &bar=<emphasis>2</emphasis></strong>
+ After:
+ <strong>foo=</strong><emphasis>var</emphasis>
+ <strong>&bar=</strong><emphasis>2</emphasis>
+ """
+ def __init__(self, document: nodes.document) -> None:
+ self.document = document
+
+ def apply(self, **kwargs: Any) -> None:
+ matcher = NodeMatcher(nodes.literal, nodes.emphasis, nodes.strong)
+ for node in list(self.document.findall(matcher)): # type: nodes.TextElement
+ if any(matcher(subnode) for subnode in node):
+ pos = node.parent.index(node)
+ for subnode in reversed(list(node)):
+ node.remove(subnode)
+ if matcher(subnode):
+ node.parent.insert(pos + 1, subnode)
+ else:
+ newnode = node.__class__('', '', subnode, **node.attributes)
+ node.parent.insert(pos + 1, newnode)
+ # move node if all children became siblings of the node
+ if not len(node):
+ node.parent.remove(node)
+
+
+class ManualPageTranslator(SphinxTranslator, BaseTranslator):
+ """
+ Custom man page translator.
+ """
+
+ _docinfo: dict[str, Any] = {}
+
+ def __init__(self, document: nodes.document, builder: Builder) -> None:
+ super().__init__(document, builder)
+
+ self.in_productionlist = 0
+
+ # first title is the manpage title
+ self.section_level = -1
+
+ # docinfo set by man_pages config value
+ self._docinfo['title'] = self.settings.title
+ self._docinfo['subtitle'] = self.settings.subtitle
+ if self.settings.authors:
+ # don't set it if no author given
+ self._docinfo['author'] = self.settings.authors
+ self._docinfo['manual_section'] = self.settings.section
+
+ # docinfo set by other config values
+ self._docinfo['title_upper'] = self._docinfo['title'].upper()
+ if self.config.today:
+ self._docinfo['date'] = self.config.today
+ else:
+ self._docinfo['date'] = format_date(self.config.today_fmt or _('%b %d, %Y'),
+ language=self.config.language)
+ self._docinfo['copyright'] = self.config.copyright
+ self._docinfo['version'] = self.config.version
+ self._docinfo['manual_group'] = self.config.project
+
+ # Overwrite admonition label translations with our own
+ for label, translation in admonitionlabels.items():
+ self.language.labels[label] = self.deunicode(translation)
+
+ # overwritten -- added quotes around all .TH arguments
+ def header(self) -> str:
+ tmpl = (".TH \"%(title_upper)s\" \"%(manual_section)s\""
+ " \"%(date)s\" \"%(version)s\" \"%(manual_group)s\"\n")
+ if self._docinfo['subtitle']:
+ tmpl += (".SH NAME\n"
+ "%(title)s \\- %(subtitle)s\n")
+ return tmpl % self._docinfo
+
+ def visit_start_of_file(self, node: Element) -> None:
+ pass
+
+ def depart_start_of_file(self, node: Element) -> None:
+ pass
+
+ #############################################################
+ # Domain-specific object descriptions
+ #############################################################
+
+ # Top-level nodes for descriptions
+ ##################################
+
+ def visit_desc(self, node: Element) -> None:
+ self.visit_definition_list(node)
+
+ def depart_desc(self, node: Element) -> None:
+ self.depart_definition_list(node)
+
+ def visit_desc_signature(self, node: Element) -> None:
+ self.visit_definition_list_item(node)
+ self.visit_term(node)
+
+ def depart_desc_signature(self, node: Element) -> None:
+ self.depart_term(node)
+
+ def visit_desc_signature_line(self, node: Element) -> None:
+ pass
+
+ def depart_desc_signature_line(self, node: Element) -> None:
+ self.body.append(' ')
+
+ def visit_desc_content(self, node: Element) -> None:
+ self.visit_definition(node)
+
+ def depart_desc_content(self, node: Element) -> None:
+ self.depart_definition(node)
+
+ def visit_desc_inline(self, node: Element) -> None:
+ pass
+
+ def depart_desc_inline(self, node: Element) -> None:
+ pass
+
+ # Nodes for high-level structure in signatures
+ ##############################################
+
+ def visit_desc_name(self, node: Element) -> None:
+ pass
+
+ def depart_desc_name(self, node: Element) -> None:
+ pass
+
+ def visit_desc_addname(self, node: Element) -> None:
+ pass
+
+ def depart_desc_addname(self, node: Element) -> None:
+ pass
+
+ def visit_desc_type(self, node: Element) -> None:
+ pass
+
+ def depart_desc_type(self, node: Element) -> None:
+ pass
+
+ def visit_desc_returns(self, node: Element) -> None:
+ self.body.append(' -> ')
+
+ def depart_desc_returns(self, node: Element) -> None:
+ pass
+
+ def visit_desc_parameterlist(self, node: Element) -> None:
+ self.body.append('(')
+ self.first_param = 1
+
+ def depart_desc_parameterlist(self, node: Element) -> None:
+ self.body.append(')')
+
+ def visit_desc_type_parameter_list(self, node: Element) -> None:
+ self.body.append('[')
+ self.first_param = 1
+
+ def depart_desc_type_parameter_list(self, node: Element) -> None:
+ self.body.append(']')
+
+ def visit_desc_parameter(self, node: Element) -> None:
+ if not self.first_param:
+ self.body.append(', ')
+ else:
+ self.first_param = 0
+
+ def depart_desc_parameter(self, node: Element) -> None:
+ pass
+
+ def visit_desc_type_parameter(self, node: Element) -> None:
+ self.visit_desc_parameter(node)
+
+ def depart_desc_type_parameter(self, node: Element) -> None:
+ self.depart_desc_parameter(node)
+
+ def visit_desc_optional(self, node: Element) -> None:
+ self.body.append('[')
+
+ def depart_desc_optional(self, node: Element) -> None:
+ self.body.append(']')
+
+ def visit_desc_annotation(self, node: Element) -> None:
+ pass
+
+ def depart_desc_annotation(self, node: Element) -> None:
+ pass
+
+ ##############################################
+
+ def visit_versionmodified(self, node: Element) -> None:
+ self.visit_paragraph(node)
+
+ def depart_versionmodified(self, node: Element) -> None:
+ self.depart_paragraph(node)
+
+ # overwritten -- don't make whole of term bold if it includes strong node
+ def visit_term(self, node: Element) -> None:
+ if any(node.findall(nodes.strong)):
+ self.body.append('\n')
+ else:
+ super().visit_term(node)
+
+ # overwritten -- we don't want source comments to show up
+ def visit_comment(self, node: Element) -> None: # type: ignore[override]
+ raise nodes.SkipNode
+
+ # overwritten -- added ensure_eol()
+ def visit_footnote(self, node: Element) -> None:
+ self.ensure_eol()
+ super().visit_footnote(node)
+
+ # overwritten -- handle footnotes rubric
+ def visit_rubric(self, node: Element) -> None:
+ self.ensure_eol()
+ if len(node) == 1 and node.astext() in ('Footnotes', _('Footnotes')):
+ self.body.append('.SH ' + self.deunicode(node.astext()).upper() + '\n')
+ raise nodes.SkipNode
+ self.body.append('.sp\n')
+
+ def depart_rubric(self, node: Element) -> None:
+ self.body.append('\n')
+
+ def visit_seealso(self, node: Element) -> None:
+ self.visit_admonition(node, 'seealso')
+
+ def depart_seealso(self, node: Element) -> None:
+ self.depart_admonition(node)
+
+ def visit_productionlist(self, node: Element) -> None:
+ self.ensure_eol()
+ names = []
+ self.in_productionlist += 1
+ self.body.append('.sp\n.nf\n')
+ productionlist = cast(Iterable[addnodes.production], node)
+ for production in productionlist:
+ names.append(production['tokenname'])
+ maxlen = max(len(name) for name in names)
+ lastname = None
+ for production in productionlist:
+ if production['tokenname']:
+ lastname = production['tokenname'].ljust(maxlen)
+ self.body.append(self.defs['strong'][0])
+ self.body.append(self.deunicode(lastname))
+ self.body.append(self.defs['strong'][1])
+ self.body.append(' ::= ')
+ elif lastname is not None:
+ self.body.append('%s ' % (' ' * len(lastname)))
+ production.walkabout(self)
+ self.body.append('\n')
+ self.body.append('\n.fi\n')
+ self.in_productionlist -= 1
+ raise nodes.SkipNode
+
+ def visit_production(self, node: Element) -> None:
+ pass
+
+ def depart_production(self, node: Element) -> None:
+ pass
+
+ # overwritten -- don't emit a warning for images
+ def visit_image(self, node: Element) -> None:
+ if 'alt' in node.attributes:
+ self.body.append(_('[image: %s]') % node['alt'] + '\n')
+ self.body.append(_('[image]') + '\n')
+ raise nodes.SkipNode
+
+ # overwritten -- don't visit inner marked up nodes
+ def visit_reference(self, node: Element) -> None:
+ self.body.append(self.defs['reference'][0])
+ # avoid repeating escaping code... fine since
+ # visit_Text calls astext() and only works on that afterwards
+ self.visit_Text(node) # type: ignore[arg-type]
+ self.body.append(self.defs['reference'][1])
+
+ uri = node.get('refuri', '')
+ if uri.startswith(('mailto:', 'http:', 'https:', 'ftp:')):
+ # if configured, put the URL after the link
+ if self.config.man_show_urls and node.astext() != uri:
+ if uri.startswith('mailto:'):
+ uri = uri[7:]
+ self.body.extend([
+ ' <',
+ self.defs['strong'][0], uri, self.defs['strong'][1],
+ '>'])
+ raise nodes.SkipNode
+
+ def visit_number_reference(self, node: Element) -> None:
+ text = nodes.Text(node.get('title', '#'))
+ self.visit_Text(text)
+ raise nodes.SkipNode
+
+ def visit_centered(self, node: Element) -> None:
+ self.ensure_eol()
+ self.body.append('.sp\n.ce\n')
+
+ def depart_centered(self, node: Element) -> None:
+ self.body.append('\n.ce 0\n')
+
+ def visit_compact_paragraph(self, node: Element) -> None:
+ pass
+
+ def depart_compact_paragraph(self, node: Element) -> None:
+ pass
+
+ def visit_download_reference(self, node: Element) -> None:
+ pass
+
+ def depart_download_reference(self, node: Element) -> None:
+ pass
+
+ def visit_toctree(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_index(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_tabular_col_spec(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_glossary(self, node: Element) -> None:
+ pass
+
+ def depart_glossary(self, node: Element) -> None:
+ pass
+
+ def visit_acks(self, node: Element) -> None:
+ bullet_list = cast(nodes.bullet_list, node[0])
+ list_items = cast(Iterable[nodes.list_item], bullet_list)
+ self.ensure_eol()
+ bullet_list = cast(nodes.bullet_list, node[0])
+ list_items = cast(Iterable[nodes.list_item], bullet_list)
+ self.body.append(', '.join(n.astext() for n in list_items) + '.')
+ self.body.append('\n')
+ raise nodes.SkipNode
+
+ def visit_hlist(self, node: Element) -> None:
+ self.visit_bullet_list(node)
+
+ def depart_hlist(self, node: Element) -> None:
+ self.depart_bullet_list(node)
+
+ def visit_hlistcol(self, node: Element) -> None:
+ pass
+
+ def depart_hlistcol(self, node: Element) -> None:
+ pass
+
+ def visit_literal_emphasis(self, node: Element) -> None:
+ return self.visit_emphasis(node)
+
+ def depart_literal_emphasis(self, node: Element) -> None:
+ return self.depart_emphasis(node)
+
+ def visit_literal_strong(self, node: Element) -> None:
+ return self.visit_strong(node)
+
+ def depart_literal_strong(self, node: Element) -> None:
+ return self.depart_strong(node)
+
+ def visit_abbreviation(self, node: Element) -> None:
+ pass
+
+ def depart_abbreviation(self, node: Element) -> None:
+ pass
+
+ def visit_manpage(self, node: Element) -> None:
+ return self.visit_strong(node)
+
+ def depart_manpage(self, node: Element) -> None:
+ return self.depart_strong(node)
+
+ # overwritten: handle section titles better than in 0.6 release
+ def visit_caption(self, node: Element) -> None:
+ if isinstance(node.parent, nodes.container) and node.parent.get('literal_block'):
+ self.body.append('.sp\n')
+ else:
+ super().visit_caption(node)
+
+ def depart_caption(self, node: Element) -> None:
+ if isinstance(node.parent, nodes.container) and node.parent.get('literal_block'):
+ self.body.append('\n')
+ else:
+ super().depart_caption(node)
+
+ # overwritten: handle section titles better than in 0.6 release
+ def visit_title(self, node: Element) -> None:
+ if isinstance(node.parent, addnodes.seealso):
+ self.body.append('.IP "')
+ return None
+ elif isinstance(node.parent, nodes.section):
+ if self.section_level == 0:
+ # skip the document title
+ raise nodes.SkipNode
+ elif self.section_level == 1:
+ self.body.append('.SH %s\n' %
+ self.deunicode(node.astext().upper()))
+ raise nodes.SkipNode
+ return super().visit_title(node)
+
+ def depart_title(self, node: Element) -> None:
+ if isinstance(node.parent, addnodes.seealso):
+ self.body.append('"\n')
+ return None
+ return super().depart_title(node)
+
+ def visit_raw(self, node: Element) -> None:
+ if 'manpage' in node.get('format', '').split():
+ self.body.append(node.astext())
+ raise nodes.SkipNode
+
+ def visit_meta(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_inline(self, node: Element) -> None:
+ pass
+
+ def depart_inline(self, node: Element) -> None:
+ pass
+
+ def visit_math(self, node: Element) -> None:
+ pass
+
+ def depart_math(self, node: Element) -> None:
+ pass
+
+ def visit_math_block(self, node: Element) -> None:
+ self.visit_centered(node)
+
+ def depart_math_block(self, node: Element) -> None:
+ self.depart_centered(node)
diff --git a/sphinx/writers/texinfo.py b/sphinx/writers/texinfo.py
new file mode 100644
index 0000000..7032c65
--- /dev/null
+++ b/sphinx/writers/texinfo.py
@@ -0,0 +1,1572 @@
+"""Custom docutils writer for Texinfo."""
+
+from __future__ import annotations
+
+import re
+import textwrap
+from collections.abc import Iterable, Iterator
+from os import path
+from typing import TYPE_CHECKING, Any, cast
+
+from docutils import nodes, writers
+
+from sphinx import __display_version__, addnodes
+from sphinx.domains.index import IndexDomain
+from sphinx.errors import ExtensionError
+from sphinx.locale import _, __, admonitionlabels
+from sphinx.util import logging
+from sphinx.util.docutils import SphinxTranslator
+from sphinx.util.i18n import format_date
+from sphinx.writers.latex import collected_footnote
+
+if TYPE_CHECKING:
+ from docutils.nodes import Element, Node, Text
+
+ from sphinx.builders.texinfo import TexinfoBuilder
+ from sphinx.domains import IndexEntry
+
+
+logger = logging.getLogger(__name__)
+
+
+COPYING = """\
+@quotation
+%(project)s %(release)s, %(date)s
+
+%(author)s
+
+Copyright @copyright{} %(copyright)s
+@end quotation
+"""
+
+TEMPLATE = """\
+\\input texinfo @c -*-texinfo-*-
+@c %%**start of header
+@setfilename %(filename)s
+@documentencoding UTF-8
+@ifinfo
+@*Generated by Sphinx """ + __display_version__ + """.@*
+@end ifinfo
+@settitle %(title)s
+@defindex ge
+@paragraphindent %(paragraphindent)s
+@exampleindent %(exampleindent)s
+@finalout
+%(direntry)s
+@c %%**end of header
+
+@copying
+%(copying)s
+@end copying
+
+@titlepage
+@title %(title)s
+@insertcopying
+@end titlepage
+@contents
+
+@c %%** start of user preamble
+%(preamble)s
+@c %%** end of user preamble
+
+@ifnottex
+@node Top
+@top %(title)s
+@insertcopying
+@end ifnottex
+
+@c %%**start of body
+%(body)s
+@c %%**end of body
+@bye
+"""
+
+
+def find_subsections(section: Element) -> list[nodes.section]:
+ """Return a list of subsections for the given ``section``."""
+ result = []
+ for child in section:
+ if isinstance(child, nodes.section):
+ result.append(child)
+ continue
+ if isinstance(child, nodes.Element):
+ result.extend(find_subsections(child))
+ return result
+
+
+def smart_capwords(s: str, sep: str | None = None) -> str:
+ """Like string.capwords() but does not capitalize words that already
+ contain a capital letter."""
+ words = s.split(sep)
+ for i, word in enumerate(words):
+ if all(x.islower() for x in word):
+ words[i] = word.capitalize()
+ return (sep or ' ').join(words)
+
+
+class TexinfoWriter(writers.Writer):
+ """Texinfo writer for generating Texinfo documents."""
+ supported = ('texinfo', 'texi')
+
+ settings_spec: tuple[str, Any, tuple[tuple[str, list[str], dict[str, str]], ...]] = (
+ 'Texinfo Specific Options', None, (
+ ("Name of the Info file", ['--texinfo-filename'], {'default': ''}),
+ ('Dir entry', ['--texinfo-dir-entry'], {'default': ''}),
+ ('Description', ['--texinfo-dir-description'], {'default': ''}),
+ ('Category', ['--texinfo-dir-category'], {'default':
+ 'Miscellaneous'})))
+
+ settings_defaults: dict[str, Any] = {}
+
+ output: str
+
+ visitor_attributes = ('output', 'fragment')
+
+ def __init__(self, builder: TexinfoBuilder) -> None:
+ super().__init__()
+ self.builder = builder
+
+ def translate(self) -> None:
+ visitor = self.builder.create_translator(self.document, self.builder)
+ self.visitor = cast(TexinfoTranslator, visitor)
+ self.document.walkabout(visitor)
+ self.visitor.finish()
+ for attr in self.visitor_attributes:
+ setattr(self, attr, getattr(self.visitor, attr))
+
+
+class TexinfoTranslator(SphinxTranslator):
+
+ ignore_missing_images = False
+ builder: TexinfoBuilder
+
+ default_elements = {
+ 'author': '',
+ 'body': '',
+ 'copying': '',
+ 'date': '',
+ 'direntry': '',
+ 'exampleindent': 4,
+ 'filename': '',
+ 'paragraphindent': 0,
+ 'preamble': '',
+ 'project': '',
+ 'release': '',
+ 'title': '',
+ }
+
+ def __init__(self, document: nodes.document, builder: TexinfoBuilder) -> None:
+ super().__init__(document, builder)
+ self.init_settings()
+
+ self.written_ids: set[str] = set() # node names and anchors in output
+ # node names and anchors that should be in output
+ self.referenced_ids: set[str] = set()
+ self.indices: list[tuple[str, str]] = [] # (node name, content)
+ self.short_ids: dict[str, str] = {} # anchors --> short ids
+ self.node_names: dict[str, str] = {} # node name --> node's name to display
+ self.node_menus: dict[str, list[str]] = {} # node name --> node's menu entries
+ self.rellinks: dict[str, list[str]] = {} # node name --> (next, previous, up)
+
+ self.collect_indices()
+ self.collect_node_names()
+ self.collect_node_menus()
+ self.collect_rellinks()
+
+ self.body: list[str] = []
+ self.context: list[str] = []
+ self.descs: list[addnodes.desc] = []
+ self.previous_section: nodes.section | None = None
+ self.section_level = 0
+ self.seen_title = False
+ self.next_section_ids: set[str] = set()
+ self.escape_newlines = 0
+ self.escape_hyphens = 0
+ self.curfilestack: list[str] = []
+ self.footnotestack: list[dict[str, list[collected_footnote | bool]]] = []
+ self.in_footnote = 0
+ self.in_samp = 0
+ self.handled_abbrs: set[str] = set()
+ self.colwidths: list[int] = []
+
+ def finish(self) -> None:
+ if self.previous_section is None:
+ self.add_menu('Top')
+ for index in self.indices:
+ name, content = index
+ pointers = tuple([name] + self.rellinks[name])
+ self.body.append('\n@node %s,%s,%s,%s\n' % pointers)
+ self.body.append(f'@unnumbered {name}\n\n{content}\n')
+
+ while self.referenced_ids:
+ # handle xrefs with missing anchors
+ r = self.referenced_ids.pop()
+ if r not in self.written_ids:
+ self.body.append('@anchor{{{}}}@w{{{}}}\n'.format(r, ' ' * 30))
+ self.ensure_eol()
+ self.fragment = ''.join(self.body)
+ self.elements['body'] = self.fragment
+ self.output = TEMPLATE % self.elements
+
+ # -- Helper routines
+
+ def init_settings(self) -> None:
+ elements = self.elements = self.default_elements.copy()
+ elements.update({
+ # if empty, the title is set to the first section title
+ 'title': self.settings.title,
+ 'author': self.settings.author,
+ # if empty, use basename of input file
+ 'filename': self.settings.texinfo_filename,
+ 'release': self.escape(self.config.release),
+ 'project': self.escape(self.config.project),
+ 'copyright': self.escape(self.config.copyright),
+ 'date': self.escape(self.config.today or
+ format_date(self.config.today_fmt or _('%b %d, %Y'),
+ language=self.config.language)),
+ })
+ # title
+ title: str = self.settings.title
+ if not title:
+ title_node = self.document.next_node(nodes.title)
+ title = title_node.astext() if title_node else '<untitled>'
+ elements['title'] = self.escape_id(title) or '<untitled>'
+ # filename
+ if not elements['filename']:
+ elements['filename'] = self.document.get('source') or 'untitled'
+ if elements['filename'][-4:] in ('.txt', '.rst'): # type: ignore[index]
+ elements['filename'] = elements['filename'][:-4] # type: ignore[index]
+ elements['filename'] += '.info' # type: ignore[operator]
+ # direntry
+ if self.settings.texinfo_dir_entry:
+ entry = self.format_menu_entry(
+ self.escape_menu(self.settings.texinfo_dir_entry),
+ '(%s)' % elements['filename'],
+ self.escape_arg(self.settings.texinfo_dir_description))
+ elements['direntry'] = ('@dircategory %s\n'
+ '@direntry\n'
+ '%s'
+ '@end direntry\n') % (
+ self.escape_id(self.settings.texinfo_dir_category), entry)
+ elements['copying'] = COPYING % elements
+ # allow the user to override them all
+ elements.update(self.settings.texinfo_elements)
+
+ def collect_node_names(self) -> None:
+ """Generates a unique id for each section.
+
+ Assigns the attribute ``node_name`` to each section."""
+
+ def add_node_name(name: str) -> str:
+ node_id = self.escape_id(name)
+ nth, suffix = 1, ''
+ while node_id + suffix in self.written_ids or \
+ node_id + suffix in self.node_names:
+ nth += 1
+ suffix = '<%s>' % nth
+ node_id += suffix
+ self.written_ids.add(node_id)
+ self.node_names[node_id] = name
+ return node_id
+
+ # must have a "Top" node
+ self.document['node_name'] = 'Top'
+ add_node_name('Top')
+ add_node_name('top')
+ # each index is a node
+ self.indices = [(add_node_name(name), content)
+ for name, content in self.indices]
+ # each section is also a node
+ for section in self.document.findall(nodes.section):
+ title = cast(nodes.TextElement, section.next_node(nodes.Titular))
+ name = title.astext() if title else '<untitled>'
+ section['node_name'] = add_node_name(name)
+
+ def collect_node_menus(self) -> None:
+ """Collect the menu entries for each "node" section."""
+ node_menus = self.node_menus
+ targets: list[Element] = [self.document]
+ targets.extend(self.document.findall(nodes.section))
+ for node in targets:
+ assert 'node_name' in node and node['node_name'] # NoQA: PT018
+ entries = [s['node_name'] for s in find_subsections(node)]
+ node_menus[node['node_name']] = entries
+ # try to find a suitable "Top" node
+ title = self.document.next_node(nodes.title)
+ top = title.parent if title else self.document
+ if not isinstance(top, (nodes.document, nodes.section)):
+ top = self.document
+ if top is not self.document:
+ entries = node_menus[top['node_name']]
+ entries += node_menus['Top'][1:]
+ node_menus['Top'] = entries
+ del node_menus[top['node_name']]
+ top['node_name'] = 'Top'
+ # handle the indices
+ for name, _content in self.indices:
+ node_menus[name] = []
+ node_menus['Top'].append(name)
+
+ def collect_rellinks(self) -> None:
+ """Collect the relative links (next, previous, up) for each "node"."""
+ rellinks = self.rellinks
+ node_menus = self.node_menus
+ for id in node_menus:
+ rellinks[id] = ['', '', '']
+ # up's
+ for id, entries in node_menus.items():
+ for e in entries:
+ rellinks[e][2] = id
+ # next's and prev's
+ for id, entries in node_menus.items():
+ for i, id in enumerate(entries):
+ # First child's prev is empty
+ if i != 0:
+ rellinks[id][1] = entries[i - 1]
+ # Last child's next is empty
+ if i != len(entries) - 1:
+ rellinks[id][0] = entries[i + 1]
+ # top's next is its first child
+ try:
+ first = node_menus['Top'][0]
+ except IndexError:
+ pass
+ else:
+ rellinks['Top'][0] = first
+ rellinks[first][1] = 'Top'
+
+ # -- Escaping
+ # Which characters to escape depends on the context. In some cases,
+ # namely menus and node names, it's not possible to escape certain
+ # characters.
+
+ def escape(self, s: str) -> str:
+ """Return a string with Texinfo command characters escaped."""
+ s = s.replace('@', '@@')
+ s = s.replace('{', '@{')
+ s = s.replace('}', '@}')
+ # prevent `` and '' quote conversion
+ s = s.replace('``', "`@w{`}")
+ s = s.replace("''", "'@w{'}")
+ return s
+
+ def escape_arg(self, s: str) -> str:
+ """Return an escaped string suitable for use as an argument
+ to a Texinfo command."""
+ s = self.escape(s)
+ # commas are the argument delimiters
+ s = s.replace(',', '@comma{}')
+ # normalize white space
+ s = ' '.join(s.split()).strip()
+ return s
+
+ def escape_id(self, s: str) -> str:
+ """Return an escaped string suitable for node names and anchors."""
+ bad_chars = ',:()'
+ for bc in bad_chars:
+ s = s.replace(bc, ' ')
+ if re.search('[^ .]', s):
+ # remove DOTs if name contains other characters
+ s = s.replace('.', ' ')
+ s = ' '.join(s.split()).strip()
+ return self.escape(s)
+
+ def escape_menu(self, s: str) -> str:
+ """Return an escaped string suitable for menu entries."""
+ s = self.escape_arg(s)
+ s = s.replace(':', ';')
+ s = ' '.join(s.split()).strip()
+ return s
+
+ def ensure_eol(self) -> None:
+ """Ensure the last line in body is terminated by new line."""
+ if self.body and self.body[-1][-1:] != '\n':
+ self.body.append('\n')
+
+ def format_menu_entry(self, name: str, node_name: str, desc: str) -> str:
+ if name == node_name:
+ s = f'* {name}:: '
+ else:
+ s = f'* {name}: {node_name}. '
+ offset = max((24, (len(name) + 4) % 78))
+ wdesc = '\n'.join(' ' * offset + l for l in
+ textwrap.wrap(desc, width=78 - offset))
+ return s + wdesc.strip() + '\n'
+
+ def add_menu_entries(
+ self,
+ entries: list[str],
+ reg: re.Pattern[str] = re.compile(r'\s+---?\s+'),
+ ) -> None:
+ for entry in entries:
+ name = self.node_names[entry]
+ # special formatting for entries that are divided by an em-dash
+ try:
+ parts = reg.split(name, 1)
+ except TypeError:
+ # could be a gettext proxy
+ parts = [name]
+ if len(parts) == 2:
+ name, desc = parts
+ else:
+ desc = ''
+ name = self.escape_menu(name)
+ desc = self.escape(desc)
+ self.body.append(self.format_menu_entry(name, entry, desc))
+
+ def add_menu(self, node_name: str) -> None:
+ entries = self.node_menus[node_name]
+ if not entries:
+ return
+ self.body.append('\n@menu\n')
+ self.add_menu_entries(entries)
+ if (node_name != 'Top' or
+ not self.node_menus[entries[0]] or
+ self.config.texinfo_no_detailmenu):
+ self.body.append('\n@end menu\n')
+ return
+
+ def _add_detailed_menu(name: str) -> None:
+ entries = self.node_menus[name]
+ if not entries:
+ return
+ self.body.append(f'\n{self.escape(self.node_names[name], )}\n\n')
+ self.add_menu_entries(entries)
+ for subentry in entries:
+ _add_detailed_menu(subentry)
+
+ self.body.append('\n@detailmenu\n'
+ ' --- The Detailed Node Listing ---\n')
+ for entry in entries:
+ _add_detailed_menu(entry)
+ self.body.append('\n@end detailmenu\n'
+ '@end menu\n')
+
+ def tex_image_length(self, width_str: str) -> str:
+ match = re.match(r'(\d*\.?\d*)\s*(\S*)', width_str)
+ if not match:
+ # fallback
+ return width_str
+ res = width_str
+ amount, unit = match.groups()[:2]
+ if not unit or unit == "px":
+ # pixels: let TeX alone
+ return ''
+ elif unit == "%":
+ # a4paper: textwidth=418.25368pt
+ res = "%d.0pt" % (float(amount) * 4.1825368)
+ return res
+
+ def collect_indices(self) -> None:
+ def generate(content: list[tuple[str, list[IndexEntry]]], collapsed: bool) -> str:
+ ret = ['\n@menu\n']
+ for _letter, entries in content:
+ for entry in entries:
+ if not entry[3]:
+ continue
+ name = self.escape_menu(entry[0])
+ sid = self.get_short_id(f'{entry[2]}:{entry[3]}')
+ desc = self.escape_arg(entry[6])
+ me = self.format_menu_entry(name, sid, desc)
+ ret.append(me)
+ ret.append('@end menu\n')
+ return ''.join(ret)
+
+ indices_config = self.config.texinfo_domain_indices
+ if indices_config:
+ for domain in self.builder.env.domains.values():
+ for indexcls in domain.indices:
+ indexname = f'{domain.name}-{indexcls.name}'
+ if isinstance(indices_config, list):
+ if indexname not in indices_config:
+ continue
+ content, collapsed = indexcls(domain).generate(
+ self.builder.docnames)
+ if not content:
+ continue
+ self.indices.append((indexcls.localname,
+ generate(content, collapsed)))
+ # only add the main Index if it's not empty
+ domain = cast(IndexDomain, self.builder.env.get_domain('index'))
+ for docname in self.builder.docnames:
+ if domain.entries[docname]:
+ self.indices.append((_('Index'), '\n@printindex ge\n'))
+ break
+
+ # this is copied from the latex writer
+ # TODO: move this to sphinx.util
+
+ def collect_footnotes(
+ self, node: Element,
+ ) -> dict[str, list[collected_footnote | bool]]:
+ def footnotes_under(n: Element) -> Iterator[nodes.footnote]:
+ if isinstance(n, nodes.footnote):
+ yield n
+ else:
+ for c in n.children:
+ if isinstance(c, addnodes.start_of_file):
+ continue
+ elif isinstance(c, nodes.Element):
+ yield from footnotes_under(c)
+ fnotes: dict[str, list[collected_footnote | bool]] = {}
+ for fn in footnotes_under(node):
+ label = cast(nodes.label, fn[0])
+ num = label.astext().strip()
+ fnotes[num] = [collected_footnote('', *fn.children), False]
+ return fnotes
+
+ # -- xref handling
+
+ def get_short_id(self, id: str) -> str:
+ """Return a shorter 'id' associated with ``id``."""
+ # Shorter ids improve paragraph filling in places
+ # that the id is hidden by Emacs.
+ try:
+ sid = self.short_ids[id]
+ except KeyError:
+ sid = hex(len(self.short_ids))[2:]
+ self.short_ids[id] = sid
+ return sid
+
+ def add_anchor(self, id: str, node: Node) -> None:
+ if id.startswith('index-'):
+ return
+ id = self.curfilestack[-1] + ':' + id
+ eid = self.escape_id(id)
+ sid = self.get_short_id(id)
+ for id in (eid, sid):
+ if id not in self.written_ids:
+ self.body.append('@anchor{%s}' % id)
+ self.written_ids.add(id)
+
+ def add_xref(self, id: str, name: str, node: Node) -> None:
+ name = self.escape_menu(name)
+ sid = self.get_short_id(id)
+ if self.config.texinfo_cross_references:
+ self.body.append(f'@ref{{{sid},,{name}}}')
+ self.referenced_ids.add(sid)
+ self.referenced_ids.add(self.escape_id(id))
+ else:
+ self.body.append(name)
+
+ # -- Visiting
+
+ def visit_document(self, node: Element) -> None:
+ self.footnotestack.append(self.collect_footnotes(node))
+ self.curfilestack.append(node.get('docname', ''))
+ if 'docname' in node:
+ self.add_anchor(':doc', node)
+
+ def depart_document(self, node: Element) -> None:
+ self.footnotestack.pop()
+ self.curfilestack.pop()
+
+ def visit_Text(self, node: Text) -> None:
+ s = self.escape(node.astext())
+ if self.escape_newlines:
+ s = s.replace('\n', ' ')
+ if self.escape_hyphens:
+ # prevent "--" and "---" conversion
+ s = s.replace('-', '@w{-}')
+ self.body.append(s)
+
+ def depart_Text(self, node: Text) -> None:
+ pass
+
+ def visit_section(self, node: Element) -> None:
+ self.next_section_ids.update(node.get('ids', []))
+ if not self.seen_title:
+ return
+ if self.previous_section:
+ self.add_menu(self.previous_section['node_name'])
+ else:
+ self.add_menu('Top')
+
+ node_name = node['node_name']
+ pointers = tuple([node_name] + self.rellinks[node_name])
+ self.body.append('\n@node %s,%s,%s,%s\n' % pointers)
+ for id in sorted(self.next_section_ids):
+ self.add_anchor(id, node)
+
+ self.next_section_ids.clear()
+ self.previous_section = cast(nodes.section, node)
+ self.section_level += 1
+
+ def depart_section(self, node: Element) -> None:
+ self.section_level -= 1
+
+ headings = (
+ '@unnumbered',
+ '@chapter',
+ '@section',
+ '@subsection',
+ '@subsubsection',
+ )
+
+ rubrics = (
+ '@heading',
+ '@subheading',
+ '@subsubheading',
+ )
+
+ def visit_title(self, node: Element) -> None:
+ if not self.seen_title:
+ self.seen_title = True
+ raise nodes.SkipNode
+ parent = node.parent
+ if isinstance(parent, nodes.table):
+ return
+ if isinstance(parent, (nodes.Admonition, nodes.sidebar, nodes.topic)):
+ raise nodes.SkipNode
+ if not isinstance(parent, nodes.section):
+ logger.warning(__('encountered title node not in section, topic, table, '
+ 'admonition or sidebar'),
+ location=node)
+ self.visit_rubric(node)
+ else:
+ try:
+ heading = self.headings[self.section_level]
+ except IndexError:
+ heading = self.headings[-1]
+ self.body.append('\n%s ' % heading)
+
+ def depart_title(self, node: Element) -> None:
+ self.body.append('\n\n')
+
+ def visit_rubric(self, node: Element) -> None:
+ if len(node) == 1 and node.astext() in ('Footnotes', _('Footnotes')):
+ raise nodes.SkipNode
+ try:
+ rubric = self.rubrics[self.section_level]
+ except IndexError:
+ rubric = self.rubrics[-1]
+ self.body.append('\n%s ' % rubric)
+ self.escape_newlines += 1
+
+ def depart_rubric(self, node: Element) -> None:
+ self.escape_newlines -= 1
+ self.body.append('\n\n')
+
+ def visit_subtitle(self, node: Element) -> None:
+ self.body.append('\n\n@noindent\n')
+
+ def depart_subtitle(self, node: Element) -> None:
+ self.body.append('\n\n')
+
+ # -- References
+
+ def visit_target(self, node: Element) -> None:
+ # postpone the labels until after the sectioning command
+ parindex = node.parent.index(node)
+ try:
+ try:
+ next = node.parent[parindex + 1]
+ except IndexError:
+ # last node in parent, look at next after parent
+ # (for section of equal level)
+ next = node.parent.parent[node.parent.parent.index(node.parent)]
+ if isinstance(next, nodes.section):
+ if node.get('refid'):
+ self.next_section_ids.add(node['refid'])
+ self.next_section_ids.update(node['ids'])
+ return
+ except (IndexError, AttributeError):
+ pass
+ if 'refuri' in node:
+ return
+ if node.get('refid'):
+ self.add_anchor(node['refid'], node)
+ for id in node['ids']:
+ self.add_anchor(id, node)
+
+ def depart_target(self, node: Element) -> None:
+ pass
+
+ def visit_reference(self, node: Element) -> None:
+ # an xref's target is displayed in Info so we ignore a few
+ # cases for the sake of appearance
+ if isinstance(node.parent, (nodes.title, addnodes.desc_type)):
+ return
+ if isinstance(node[0], nodes.image):
+ return
+ name = node.get('name', node.astext()).strip()
+ uri = node.get('refuri', '')
+ if not uri and node.get('refid'):
+ uri = '%' + self.curfilestack[-1] + '#' + node['refid']
+ if not uri:
+ return
+ if uri.startswith('mailto:'):
+ uri = self.escape_arg(uri[7:])
+ name = self.escape_arg(name)
+ if not name or name == uri:
+ self.body.append('@email{%s}' % uri)
+ else:
+ self.body.append(f'@email{{{uri},{name}}}')
+ elif uri.startswith('#'):
+ # references to labels in the same document
+ id = self.curfilestack[-1] + ':' + uri[1:]
+ self.add_xref(id, name, node)
+ elif uri.startswith('%'):
+ # references to documents or labels inside documents
+ hashindex = uri.find('#')
+ if hashindex == -1:
+ # reference to the document
+ id = uri[1:] + '::doc'
+ else:
+ # reference to a label
+ id = uri[1:].replace('#', ':')
+ self.add_xref(id, name, node)
+ elif uri.startswith('info:'):
+ # references to an external Info file
+ uri = uri[5:].replace('_', ' ')
+ uri = self.escape_arg(uri)
+ id = 'Top'
+ if '#' in uri:
+ uri, id = uri.split('#', 1)
+ id = self.escape_id(id)
+ name = self.escape_menu(name)
+ if name == id:
+ self.body.append(f'@ref{{{id},,,{uri}}}')
+ else:
+ self.body.append(f'@ref{{{id},,{name},{uri}}}')
+ else:
+ uri = self.escape_arg(uri)
+ name = self.escape_arg(name)
+ show_urls = self.config.texinfo_show_urls
+ if self.in_footnote:
+ show_urls = 'inline'
+ if not name or uri == name:
+ self.body.append('@indicateurl{%s}' % uri)
+ elif show_urls == 'inline':
+ self.body.append(f'@uref{{{uri},{name}}}')
+ elif show_urls == 'no':
+ self.body.append(f'@uref{{{uri},,{name}}}')
+ else:
+ self.body.append(f'{name}@footnote{{{uri}}}')
+ raise nodes.SkipNode
+
+ def depart_reference(self, node: Element) -> None:
+ pass
+
+ def visit_number_reference(self, node: Element) -> None:
+ text = nodes.Text(node.get('title', '#'))
+ self.visit_Text(text)
+ raise nodes.SkipNode
+
+ def visit_title_reference(self, node: Element) -> None:
+ text = node.astext()
+ self.body.append('@cite{%s}' % self.escape_arg(text))
+ raise nodes.SkipNode
+
+ # -- Blocks
+
+ def visit_paragraph(self, node: Element) -> None:
+ self.body.append('\n')
+
+ def depart_paragraph(self, node: Element) -> None:
+ self.body.append('\n')
+
+ def visit_block_quote(self, node: Element) -> None:
+ self.body.append('\n@quotation\n')
+
+ def depart_block_quote(self, node: Element) -> None:
+ self.ensure_eol()
+ self.body.append('@end quotation\n')
+
+ def visit_literal_block(self, node: Element | None) -> None:
+ self.body.append('\n@example\n')
+
+ def depart_literal_block(self, node: Element | None) -> None:
+ self.ensure_eol()
+ self.body.append('@end example\n')
+
+ visit_doctest_block = visit_literal_block
+ depart_doctest_block = depart_literal_block
+
+ def visit_line_block(self, node: Element) -> None:
+ if not isinstance(node.parent, nodes.line_block):
+ self.body.append('\n\n')
+ self.body.append('@display\n')
+
+ def depart_line_block(self, node: Element) -> None:
+ self.body.append('@end display\n')
+ if not isinstance(node.parent, nodes.line_block):
+ self.body.append('\n\n')
+
+ def visit_line(self, node: Element) -> None:
+ self.escape_newlines += 1
+
+ def depart_line(self, node: Element) -> None:
+ self.body.append('@w{ }\n')
+ self.escape_newlines -= 1
+
+ # -- Inline
+
+ def visit_strong(self, node: Element) -> None:
+ self.body.append('`')
+
+ def depart_strong(self, node: Element) -> None:
+ self.body.append("'")
+
+ def visit_emphasis(self, node: Element) -> None:
+ if self.in_samp:
+ self.body.append('@var{')
+ self.context.append('}')
+ else:
+ self.body.append('`')
+ self.context.append("'")
+
+ def depart_emphasis(self, node: Element) -> None:
+ self.body.append(self.context.pop())
+
+ def is_samp(self, node: Element) -> bool:
+ return 'samp' in node['classes']
+
+ def visit_literal(self, node: Element) -> None:
+ if self.is_samp(node):
+ self.in_samp += 1
+ self.body.append('@code{')
+
+ def depart_literal(self, node: Element) -> None:
+ if self.is_samp(node):
+ self.in_samp -= 1
+ self.body.append('}')
+
+ def visit_superscript(self, node: Element) -> None:
+ self.body.append('@w{^')
+
+ def depart_superscript(self, node: Element) -> None:
+ self.body.append('}')
+
+ def visit_subscript(self, node: Element) -> None:
+ self.body.append('@w{[')
+
+ def depart_subscript(self, node: Element) -> None:
+ self.body.append(']}')
+
+ # -- Footnotes
+
+ def visit_footnote(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_collected_footnote(self, node: Element) -> None:
+ self.in_footnote += 1
+ self.body.append('@footnote{')
+
+ def depart_collected_footnote(self, node: Element) -> None:
+ self.body.append('}')
+ self.in_footnote -= 1
+
+ def visit_footnote_reference(self, node: Element) -> None:
+ num = node.astext().strip()
+ try:
+ footnode, used = self.footnotestack[-1][num]
+ except (KeyError, IndexError) as exc:
+ raise nodes.SkipNode from exc
+ # footnotes are repeated for each reference
+ footnode.walkabout(self) # type: ignore[union-attr]
+ raise nodes.SkipChildren
+
+ def visit_citation(self, node: Element) -> None:
+ self.body.append('\n')
+ for id in node.get('ids'):
+ self.add_anchor(id, node)
+ self.escape_newlines += 1
+
+ def depart_citation(self, node: Element) -> None:
+ self.escape_newlines -= 1
+
+ def visit_citation_reference(self, node: Element) -> None:
+ self.body.append('@w{[')
+
+ def depart_citation_reference(self, node: Element) -> None:
+ self.body.append(']}')
+
+ # -- Lists
+
+ def visit_bullet_list(self, node: Element) -> None:
+ bullet = node.get('bullet', '*')
+ self.body.append('\n\n@itemize %s\n' % bullet)
+
+ def depart_bullet_list(self, node: Element) -> None:
+ self.ensure_eol()
+ self.body.append('@end itemize\n')
+
+ def visit_enumerated_list(self, node: Element) -> None:
+ # doesn't support Roman numerals
+ enum = node.get('enumtype', 'arabic')
+ starters = {'arabic': '',
+ 'loweralpha': 'a',
+ 'upperalpha': 'A'}
+ start = node.get('start', starters.get(enum, ''))
+ self.body.append('\n\n@enumerate %s\n' % start)
+
+ def depart_enumerated_list(self, node: Element) -> None:
+ self.ensure_eol()
+ self.body.append('@end enumerate\n')
+
+ def visit_list_item(self, node: Element) -> None:
+ self.body.append('\n@item ')
+
+ def depart_list_item(self, node: Element) -> None:
+ pass
+
+ # -- Option List
+
+ def visit_option_list(self, node: Element) -> None:
+ self.body.append('\n\n@table @option\n')
+
+ def depart_option_list(self, node: Element) -> None:
+ self.ensure_eol()
+ self.body.append('@end table\n')
+
+ def visit_option_list_item(self, node: Element) -> None:
+ pass
+
+ def depart_option_list_item(self, node: Element) -> None:
+ pass
+
+ def visit_option_group(self, node: Element) -> None:
+ self.at_item_x = '@item'
+
+ def depart_option_group(self, node: Element) -> None:
+ pass
+
+ def visit_option(self, node: Element) -> None:
+ self.escape_hyphens += 1
+ self.body.append('\n%s ' % self.at_item_x)
+ self.at_item_x = '@itemx'
+
+ def depart_option(self, node: Element) -> None:
+ self.escape_hyphens -= 1
+
+ def visit_option_string(self, node: Element) -> None:
+ pass
+
+ def depart_option_string(self, node: Element) -> None:
+ pass
+
+ def visit_option_argument(self, node: Element) -> None:
+ self.body.append(node.get('delimiter', ' '))
+
+ def depart_option_argument(self, node: Element) -> None:
+ pass
+
+ def visit_description(self, node: Element) -> None:
+ self.body.append('\n')
+
+ def depart_description(self, node: Element) -> None:
+ pass
+
+ # -- Definitions
+
+ def visit_definition_list(self, node: Element) -> None:
+ self.body.append('\n\n@table @asis\n')
+
+ def depart_definition_list(self, node: Element) -> None:
+ self.ensure_eol()
+ self.body.append('@end table\n')
+
+ def visit_definition_list_item(self, node: Element) -> None:
+ self.at_item_x = '@item'
+
+ def depart_definition_list_item(self, node: Element) -> None:
+ pass
+
+ def visit_term(self, node: Element) -> None:
+ for id in node.get('ids'):
+ self.add_anchor(id, node)
+ # anchors and indexes need to go in front
+ for n in node[::]:
+ if isinstance(n, (addnodes.index, nodes.target)):
+ n.walkabout(self)
+ node.remove(n)
+ self.body.append('\n%s ' % self.at_item_x)
+ self.at_item_x = '@itemx'
+
+ def depart_term(self, node: Element) -> None:
+ pass
+
+ def visit_classifier(self, node: Element) -> None:
+ self.body.append(' : ')
+
+ def depart_classifier(self, node: Element) -> None:
+ pass
+
+ def visit_definition(self, node: Element) -> None:
+ self.body.append('\n')
+
+ def depart_definition(self, node: Element) -> None:
+ pass
+
+ # -- Tables
+
+ def visit_table(self, node: Element) -> None:
+ self.entry_sep = '@item'
+
+ def depart_table(self, node: Element) -> None:
+ self.body.append('\n@end multitable\n\n')
+
+ def visit_tabular_col_spec(self, node: Element) -> None:
+ pass
+
+ def depart_tabular_col_spec(self, node: Element) -> None:
+ pass
+
+ def visit_colspec(self, node: Element) -> None:
+ self.colwidths.append(node['colwidth'])
+ if len(self.colwidths) != self.n_cols:
+ return
+ self.body.append('\n\n@multitable ')
+ for n in self.colwidths:
+ self.body.append('{%s} ' % ('x' * (n + 2)))
+
+ def depart_colspec(self, node: Element) -> None:
+ pass
+
+ def visit_tgroup(self, node: Element) -> None:
+ self.colwidths = []
+ self.n_cols = node['cols']
+
+ def depart_tgroup(self, node: Element) -> None:
+ pass
+
+ def visit_thead(self, node: Element) -> None:
+ self.entry_sep = '@headitem'
+
+ def depart_thead(self, node: Element) -> None:
+ pass
+
+ def visit_tbody(self, node: Element) -> None:
+ pass
+
+ def depart_tbody(self, node: Element) -> None:
+ pass
+
+ def visit_row(self, node: Element) -> None:
+ pass
+
+ def depart_row(self, node: Element) -> None:
+ self.entry_sep = '@item'
+
+ def visit_entry(self, node: Element) -> None:
+ self.body.append('\n%s\n' % self.entry_sep)
+ self.entry_sep = '@tab'
+
+ def depart_entry(self, node: Element) -> None:
+ for _i in range(node.get('morecols', 0)):
+ self.body.append('\n@tab\n')
+
+ # -- Field Lists
+
+ def visit_field_list(self, node: Element) -> None:
+ pass
+
+ def depart_field_list(self, node: Element) -> None:
+ pass
+
+ def visit_field(self, node: Element) -> None:
+ self.body.append('\n')
+
+ def depart_field(self, node: Element) -> None:
+ self.body.append('\n')
+
+ def visit_field_name(self, node: Element) -> None:
+ self.ensure_eol()
+ self.body.append('@*')
+
+ def depart_field_name(self, node: Element) -> None:
+ self.body.append(': ')
+
+ def visit_field_body(self, node: Element) -> None:
+ pass
+
+ def depart_field_body(self, node: Element) -> None:
+ pass
+
+ # -- Admonitions
+
+ def visit_admonition(self, node: Element, name: str = '') -> None:
+ if not name:
+ title = cast(nodes.title, node[0])
+ name = self.escape(title.astext())
+ self.body.append('\n@cartouche\n@quotation %s ' % name)
+
+ def _visit_named_admonition(self, node: Element) -> None:
+ label = admonitionlabels[node.tagname]
+ self.body.append('\n@cartouche\n@quotation %s ' % label)
+
+ def depart_admonition(self, node: Element) -> None:
+ self.ensure_eol()
+ self.body.append('@end quotation\n'
+ '@end cartouche\n')
+
+ visit_attention = _visit_named_admonition
+ depart_attention = depart_admonition
+ visit_caution = _visit_named_admonition
+ depart_caution = depart_admonition
+ visit_danger = _visit_named_admonition
+ depart_danger = depart_admonition
+ visit_error = _visit_named_admonition
+ depart_error = depart_admonition
+ visit_hint = _visit_named_admonition
+ depart_hint = depart_admonition
+ visit_important = _visit_named_admonition
+ depart_important = depart_admonition
+ visit_note = _visit_named_admonition
+ depart_note = depart_admonition
+ visit_tip = _visit_named_admonition
+ depart_tip = depart_admonition
+ visit_warning = _visit_named_admonition
+ depart_warning = depart_admonition
+
+ # -- Misc
+
+ def visit_docinfo(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_generated(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_header(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_footer(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_container(self, node: Element) -> None:
+ if node.get('literal_block'):
+ self.body.append('\n\n@float LiteralBlock\n')
+
+ def depart_container(self, node: Element) -> None:
+ if node.get('literal_block'):
+ self.body.append('\n@end float\n\n')
+
+ def visit_decoration(self, node: Element) -> None:
+ pass
+
+ def depart_decoration(self, node: Element) -> None:
+ pass
+
+ def visit_topic(self, node: Element) -> None:
+ # ignore TOC's since we have to have a "menu" anyway
+ if 'contents' in node.get('classes', []):
+ raise nodes.SkipNode
+ title = cast(nodes.title, node[0])
+ self.visit_rubric(title)
+ self.body.append('%s\n' % self.escape(title.astext()))
+ self.depart_rubric(title)
+
+ def depart_topic(self, node: Element) -> None:
+ pass
+
+ def visit_transition(self, node: Element) -> None:
+ self.body.append('\n\n%s\n\n' % ('_' * 66))
+
+ def depart_transition(self, node: Element) -> None:
+ pass
+
+ def visit_attribution(self, node: Element) -> None:
+ self.body.append('\n\n@center --- ')
+
+ def depart_attribution(self, node: Element) -> None:
+ self.body.append('\n\n')
+
+ def visit_raw(self, node: Element) -> None:
+ format = node.get('format', '').split()
+ if 'texinfo' in format or 'texi' in format:
+ self.body.append(node.astext())
+ raise nodes.SkipNode
+
+ def visit_figure(self, node: Element) -> None:
+ self.body.append('\n\n@float Figure\n')
+
+ def depart_figure(self, node: Element) -> None:
+ self.body.append('\n@end float\n\n')
+
+ def visit_caption(self, node: Element) -> None:
+ if (isinstance(node.parent, nodes.figure) or
+ (isinstance(node.parent, nodes.container) and
+ node.parent.get('literal_block'))):
+ self.body.append('\n@caption{')
+ else:
+ logger.warning(__('caption not inside a figure.'),
+ location=node)
+
+ def depart_caption(self, node: Element) -> None:
+ if (isinstance(node.parent, nodes.figure) or
+ (isinstance(node.parent, nodes.container) and
+ node.parent.get('literal_block'))):
+ self.body.append('}\n')
+
+ def visit_image(self, node: Element) -> None:
+ if node['uri'] in self.builder.images:
+ uri = self.builder.images[node['uri']]
+ else:
+ # missing image!
+ if self.ignore_missing_images:
+ return
+ uri = node['uri']
+ if uri.find('://') != -1:
+ # ignore remote images
+ return
+ name, ext = path.splitext(uri)
+ # width and height ignored in non-tex output
+ width = self.tex_image_length(node.get('width', ''))
+ height = self.tex_image_length(node.get('height', ''))
+ alt = self.escape_arg(node.get('alt', ''))
+ filename = f"{self.elements['filename'][:-5]}-figures/{name}" # type: ignore[index]
+ self.body.append('\n@image{%s,%s,%s,%s,%s}\n' %
+ (filename, width, height, alt, ext[1:]))
+
+ def depart_image(self, node: Element) -> None:
+ pass
+
+ def visit_compound(self, node: Element) -> None:
+ pass
+
+ def depart_compound(self, node: Element) -> None:
+ pass
+
+ def visit_sidebar(self, node: Element) -> None:
+ self.visit_topic(node)
+
+ def depart_sidebar(self, node: Element) -> None:
+ self.depart_topic(node)
+
+ def visit_label(self, node: Element) -> None:
+ # label numbering is automatically generated by Texinfo
+ if self.in_footnote:
+ raise nodes.SkipNode
+ self.body.append('@w{(')
+
+ def depart_label(self, node: Element) -> None:
+ self.body.append(')} ')
+
+ def visit_legend(self, node: Element) -> None:
+ pass
+
+ def depart_legend(self, node: Element) -> None:
+ pass
+
+ def visit_substitution_reference(self, node: Element) -> None:
+ pass
+
+ def depart_substitution_reference(self, node: Element) -> None:
+ pass
+
+ def visit_substitution_definition(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_system_message(self, node: Element) -> None:
+ self.body.append('\n@verbatim\n'
+ '<SYSTEM MESSAGE: %s>\n'
+ '@end verbatim\n' % node.astext())
+ raise nodes.SkipNode
+
+ def visit_comment(self, node: Element) -> None:
+ self.body.append('\n')
+ for line in node.astext().splitlines():
+ self.body.append('@c %s\n' % line)
+ raise nodes.SkipNode
+
+ def visit_problematic(self, node: Element) -> None:
+ self.body.append('>>')
+
+ def depart_problematic(self, node: Element) -> None:
+ self.body.append('<<')
+
+ def unimplemented_visit(self, node: Element) -> None:
+ logger.warning(__("unimplemented node type: %r"), node,
+ location=node)
+
+ def unknown_departure(self, node: Node) -> None:
+ pass
+
+ # -- Sphinx specific
+
+ def visit_productionlist(self, node: Element) -> None:
+ self.visit_literal_block(None)
+ names = []
+ productionlist = cast(Iterable[addnodes.production], node)
+ for production in productionlist:
+ names.append(production['tokenname'])
+ maxlen = max(len(name) for name in names)
+ for production in productionlist:
+ if production['tokenname']:
+ for id in production.get('ids'):
+ self.add_anchor(id, production)
+ s = production['tokenname'].ljust(maxlen) + ' ::='
+ else:
+ s = '%s ' % (' ' * maxlen)
+ self.body.append(self.escape(s))
+ self.body.append(self.escape(production.astext() + '\n'))
+ self.depart_literal_block(None)
+ raise nodes.SkipNode
+
+ def visit_production(self, node: Element) -> None:
+ pass
+
+ def depart_production(self, node: Element) -> None:
+ pass
+
+ def visit_literal_emphasis(self, node: Element) -> None:
+ self.body.append('@code{')
+
+ def depart_literal_emphasis(self, node: Element) -> None:
+ self.body.append('}')
+
+ def visit_literal_strong(self, node: Element) -> None:
+ self.body.append('@code{')
+
+ def depart_literal_strong(self, node: Element) -> None:
+ self.body.append('}')
+
+ def visit_index(self, node: Element) -> None:
+ # terminate the line but don't prevent paragraph breaks
+ if isinstance(node.parent, nodes.paragraph):
+ self.ensure_eol()
+ else:
+ self.body.append('\n')
+ for (_entry_type, value, _target_id, _main, _category_key) in node['entries']:
+ text = self.escape_menu(value)
+ self.body.append('@geindex %s\n' % text)
+
+ def visit_versionmodified(self, node: Element) -> None:
+ self.body.append('\n')
+
+ def depart_versionmodified(self, node: Element) -> None:
+ self.body.append('\n')
+
+ def visit_start_of_file(self, node: Element) -> None:
+ # add a document target
+ self.next_section_ids.add(':doc')
+ self.curfilestack.append(node['docname'])
+ self.footnotestack.append(self.collect_footnotes(node))
+
+ def depart_start_of_file(self, node: Element) -> None:
+ self.curfilestack.pop()
+ self.footnotestack.pop()
+
+ def visit_centered(self, node: Element) -> None:
+ txt = self.escape_arg(node.astext())
+ self.body.append('\n\n@center %s\n\n' % txt)
+ raise nodes.SkipNode
+
+ def visit_seealso(self, node: Element) -> None:
+ self.body.append('\n\n@subsubheading %s\n\n' %
+ admonitionlabels['seealso'])
+
+ def depart_seealso(self, node: Element) -> None:
+ self.body.append('\n')
+
+ def visit_meta(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_glossary(self, node: Element) -> None:
+ pass
+
+ def depart_glossary(self, node: Element) -> None:
+ pass
+
+ def visit_acks(self, node: Element) -> None:
+ bullet_list = cast(nodes.bullet_list, node[0])
+ list_items = cast(Iterable[nodes.list_item], bullet_list)
+ self.body.append('\n\n')
+ self.body.append(', '.join(n.astext() for n in list_items) + '.')
+ self.body.append('\n\n')
+ raise nodes.SkipNode
+
+ #############################################################
+ # Domain-specific object descriptions
+ #############################################################
+
+ # Top-level nodes for descriptions
+ ##################################
+
+ def visit_desc(self, node: addnodes.desc) -> None:
+ self.descs.append(node)
+ self.at_deffnx = '@deffn'
+
+ def depart_desc(self, node: addnodes.desc) -> None:
+ self.descs.pop()
+ self.ensure_eol()
+ self.body.append('@end deffn\n')
+
+ def visit_desc_signature(self, node: Element) -> None:
+ self.escape_hyphens += 1
+ objtype = node.parent['objtype']
+ if objtype != 'describe':
+ for id in node.get('ids'):
+ self.add_anchor(id, node)
+ # use the full name of the objtype for the category
+ try:
+ domain = self.builder.env.get_domain(node.parent['domain'])
+ name = domain.get_type_name(domain.object_types[objtype],
+ self.config.primary_domain == domain.name)
+ except (KeyError, ExtensionError):
+ name = objtype
+ # by convention, the deffn category should be capitalized like a title
+ category = self.escape_arg(smart_capwords(name))
+ self.body.append(f'\n{self.at_deffnx} {{{category}}} ')
+ self.at_deffnx = '@deffnx'
+ self.desc_type_name: str | None = name
+
+ def depart_desc_signature(self, node: Element) -> None:
+ self.body.append("\n")
+ self.escape_hyphens -= 1
+ self.desc_type_name = None
+
+ def visit_desc_signature_line(self, node: Element) -> None:
+ pass
+
+ def depart_desc_signature_line(self, node: Element) -> None:
+ pass
+
+ def visit_desc_content(self, node: Element) -> None:
+ pass
+
+ def depart_desc_content(self, node: Element) -> None:
+ pass
+
+ def visit_desc_inline(self, node: Element) -> None:
+ pass
+
+ def depart_desc_inline(self, node: Element) -> None:
+ pass
+
+ # Nodes for high-level structure in signatures
+ ##############################################
+
+ def visit_desc_name(self, node: Element) -> None:
+ pass
+
+ def depart_desc_name(self, node: Element) -> None:
+ pass
+
+ def visit_desc_addname(self, node: Element) -> None:
+ pass
+
+ def depart_desc_addname(self, node: Element) -> None:
+ pass
+
+ def visit_desc_type(self, node: Element) -> None:
+ pass
+
+ def depart_desc_type(self, node: Element) -> None:
+ pass
+
+ def visit_desc_returns(self, node: Element) -> None:
+ self.body.append(' -> ')
+
+ def depart_desc_returns(self, node: Element) -> None:
+ pass
+
+ def visit_desc_parameterlist(self, node: Element) -> None:
+ self.body.append(' (')
+ self.first_param = 1
+
+ def depart_desc_parameterlist(self, node: Element) -> None:
+ self.body.append(')')
+
+ def visit_desc_type_parameter_list(self, node: Element) -> None:
+ self.body.append(' [')
+ self.first_param = 1
+
+ def depart_desc_type_parameter_list(self, node: Element) -> None:
+ self.body.append(']')
+
+ def visit_desc_parameter(self, node: Element) -> None:
+ if not self.first_param:
+ self.body.append(', ')
+ else:
+ self.first_param = 0
+ text = self.escape(node.astext())
+ # replace no-break spaces with normal ones
+ text = text.replace(' ', '@w{ }')
+ self.body.append(text)
+ raise nodes.SkipNode
+
+ def visit_desc_type_parameter(self, node: Element) -> None:
+ self.visit_desc_parameter(node)
+
+ def visit_desc_optional(self, node: Element) -> None:
+ self.body.append('[')
+
+ def depart_desc_optional(self, node: Element) -> None:
+ self.body.append(']')
+
+ def visit_desc_annotation(self, node: Element) -> None:
+ # Try to avoid duplicating info already displayed by the deffn category.
+ # e.g.
+ # @deffn {Class} Foo
+ # -- instead of --
+ # @deffn {Class} class Foo
+ txt = node.astext().strip()
+ if ((self.descs and txt == self.descs[-1]['objtype']) or
+ (self.desc_type_name and txt in self.desc_type_name.split())):
+ raise nodes.SkipNode
+
+ def depart_desc_annotation(self, node: Element) -> None:
+ pass
+
+ ##############################################
+
+ def visit_inline(self, node: Element) -> None:
+ pass
+
+ def depart_inline(self, node: Element) -> None:
+ pass
+
+ def visit_abbreviation(self, node: Element) -> None:
+ abbr = node.astext()
+ self.body.append('@abbr{')
+ if node.hasattr('explanation') and abbr not in self.handled_abbrs:
+ self.context.append(',%s}' % self.escape_arg(node['explanation']))
+ self.handled_abbrs.add(abbr)
+ else:
+ self.context.append('}')
+
+ def depart_abbreviation(self, node: Element) -> None:
+ self.body.append(self.context.pop())
+
+ def visit_manpage(self, node: Element) -> None:
+ return self.visit_literal_emphasis(node)
+
+ def depart_manpage(self, node: Element) -> None:
+ return self.depart_literal_emphasis(node)
+
+ def visit_download_reference(self, node: Element) -> None:
+ pass
+
+ def depart_download_reference(self, node: Element) -> None:
+ pass
+
+ def visit_hlist(self, node: Element) -> None:
+ self.visit_bullet_list(node)
+
+ def depart_hlist(self, node: Element) -> None:
+ self.depart_bullet_list(node)
+
+ def visit_hlistcol(self, node: Element) -> None:
+ pass
+
+ def depart_hlistcol(self, node: Element) -> None:
+ pass
+
+ def visit_pending_xref(self, node: Element) -> None:
+ pass
+
+ def depart_pending_xref(self, node: Element) -> None:
+ pass
+
+ def visit_math(self, node: Element) -> None:
+ self.body.append('@math{' + self.escape_arg(node.astext()) + '}')
+ raise nodes.SkipNode
+
+ def visit_math_block(self, node: Element) -> None:
+ if node.get('label'):
+ self.add_anchor(node['label'], node)
+ self.body.append('\n\n@example\n%s\n@end example\n\n' %
+ self.escape_arg(node.astext()))
+ raise nodes.SkipNode
diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py
new file mode 100644
index 0000000..63df2d7
--- /dev/null
+++ b/sphinx/writers/text.py
@@ -0,0 +1,1305 @@
+"""Custom docutils writer for plain text."""
+from __future__ import annotations
+
+import math
+import os
+import re
+import textwrap
+from collections.abc import Generator, Iterable, Sequence
+from itertools import chain, groupby
+from typing import TYPE_CHECKING, Any, cast
+
+from docutils import nodes, writers
+from docutils.utils import column_width
+
+from sphinx import addnodes
+from sphinx.locale import _, admonitionlabels
+from sphinx.util.docutils import SphinxTranslator
+
+if TYPE_CHECKING:
+ from docutils.nodes import Element, Text
+
+ from sphinx.builders.text import TextBuilder
+
+
+class Cell:
+ """Represents a cell in a table.
+ It can span multiple columns or multiple lines.
+ """
+ def __init__(self, text: str = "", rowspan: int = 1, colspan: int = 1) -> None:
+ self.text = text
+ self.wrapped: list[str] = []
+ self.rowspan = rowspan
+ self.colspan = colspan
+ self.col: int | None = None
+ self.row: int | None = None
+
+ def __repr__(self) -> str:
+ return f"<Cell {self.text!r} {self.row}v{self.rowspan}/{self.col}>{self.colspan}>"
+
+ def __hash__(self) -> int:
+ return hash((self.col, self.row))
+
+ def __bool__(self) -> bool:
+ return self.text != '' and self.col is not None and self.row is not None
+
+ def wrap(self, width: int) -> None:
+ self.wrapped = my_wrap(self.text, width)
+
+
+class Table:
+ """Represents a table, handling cells that can span multiple lines
+ or rows, like::
+
+ +-----------+-----+
+ | AAA | BBB |
+ +-----+-----+ |
+ | | XXX | |
+ | +-----+-----+
+ | DDD | CCC |
+ +-----+-----------+
+
+ This class can be used in two ways, either:
+
+ - With absolute positions: call ``table[line, col] = Cell(...)``,
+ this overwrites any existing cell(s) at these positions.
+
+ - With relative positions: call the ``add_row()`` and
+ ``add_cell(Cell(...))`` as needed.
+
+ Cells spanning multiple rows or multiple columns (having a
+ colspan or rowspan greater than one) are automatically referenced
+ by all the table cells they cover. This is a useful
+ representation as we can simply check
+ ``if self[x, y] is self[x, y+1]`` to recognize a rowspan.
+
+ Colwidth is not automatically computed, it has to be given, either
+ at construction time, or during the table construction.
+
+ Example usage::
+
+ table = Table([6, 6])
+ table.add_cell(Cell("foo"))
+ table.add_cell(Cell("bar"))
+ table.set_separator()
+ table.add_row()
+ table.add_cell(Cell("FOO"))
+ table.add_cell(Cell("BAR"))
+ print(table)
+ +--------+--------+
+ | foo | bar |
+ |========|========|
+ | FOO | BAR |
+ +--------+--------+
+
+ """
+ def __init__(self, colwidth: list[int] | None = None) -> None:
+ self.lines: list[list[Cell]] = []
+ self.separator = 0
+ self.colwidth: list[int] = (colwidth if colwidth is not None else [])
+ self.current_line = 0
+ self.current_col = 0
+
+ def add_row(self) -> None:
+ """Add a row to the table, to use with ``add_cell()``. It is not needed
+ to call ``add_row()`` before the first ``add_cell()``.
+ """
+ self.current_line += 1
+ self.current_col = 0
+
+ def set_separator(self) -> None:
+ """Sets the separator below the current line."""
+ self.separator = len(self.lines)
+
+ def add_cell(self, cell: Cell) -> None:
+ """Add a cell to the current line, to use with ``add_row()``. To add
+ a cell spanning multiple lines or rows, simply set the
+ ``cell.colspan`` or ``cell.rowspan`` BEFORE inserting it into
+ the table.
+ """
+ while self[self.current_line, self.current_col]:
+ self.current_col += 1
+ self[self.current_line, self.current_col] = cell
+ self.current_col += cell.colspan
+
+ def __getitem__(self, pos: tuple[int, int]) -> Cell:
+ line, col = pos
+ self._ensure_has_line(line + 1)
+ self._ensure_has_column(col + 1)
+ return self.lines[line][col]
+
+ def __setitem__(self, pos: tuple[int, int], cell: Cell) -> None:
+ line, col = pos
+ self._ensure_has_line(line + cell.rowspan)
+ self._ensure_has_column(col + cell.colspan)
+ for dline in range(cell.rowspan):
+ for dcol in range(cell.colspan):
+ self.lines[line + dline][col + dcol] = cell
+ cell.row = line
+ cell.col = col
+
+ def _ensure_has_line(self, line: int) -> None:
+ while len(self.lines) < line:
+ self.lines.append([])
+
+ def _ensure_has_column(self, col: int) -> None:
+ for line in self.lines:
+ while len(line) < col:
+ line.append(Cell())
+
+ def __repr__(self) -> str:
+ return "\n".join(repr(line) for line in self.lines)
+
+ def cell_width(self, cell: Cell, source: list[int]) -> int:
+ """Give the cell width, according to the given source (either
+ ``self.colwidth`` or ``self.measured_widths``).
+ This takes into account cells spanning multiple columns.
+ """
+ if cell.row is None or cell.col is None:
+ msg = 'Cell co-ordinates have not been set'
+ raise ValueError(msg)
+ width = 0
+ for i in range(self[cell.row, cell.col].colspan):
+ width += source[cell.col + i]
+ return width + (cell.colspan - 1) * 3
+
+ @property
+ def cells(self) -> Generator[Cell, None, None]:
+ seen: set[Cell] = set()
+ for line in self.lines:
+ for cell in line:
+ if cell and cell not in seen:
+ yield cell
+ seen.add(cell)
+
+ def rewrap(self) -> None:
+ """Call ``cell.wrap()`` on all cells, and measure each column width
+ after wrapping (result written in ``self.measured_widths``).
+ """
+ self.measured_widths = self.colwidth[:]
+ for cell in self.cells:
+ cell.wrap(width=self.cell_width(cell, self.colwidth))
+ if not cell.wrapped:
+ continue
+ if cell.row is None or cell.col is None:
+ msg = 'Cell co-ordinates have not been set'
+ raise ValueError(msg)
+ width = math.ceil(max(column_width(x) for x in cell.wrapped) / cell.colspan)
+ for col in range(cell.col, cell.col + cell.colspan):
+ self.measured_widths[col] = max(self.measured_widths[col], width)
+
+ def physical_lines_for_line(self, line: list[Cell]) -> int:
+ """For a given line, compute the number of physical lines it spans
+ due to text wrapping.
+ """
+ physical_lines = 1
+ for cell in line:
+ physical_lines = max(physical_lines, len(cell.wrapped))
+ return physical_lines
+
+ def __str__(self) -> str:
+ out = []
+ self.rewrap()
+
+ def writesep(char: str = "-", lineno: int | None = None) -> str:
+ """Called on the line *before* lineno.
+ Called with no *lineno* for the last sep.
+ """
+ out: list[str] = []
+ for colno, width in enumerate(self.measured_widths):
+ if (
+ lineno is not None and
+ lineno > 0 and
+ self[lineno, colno] is self[lineno - 1, colno]
+ ):
+ out.append(" " * (width + 2))
+ else:
+ out.append(char * (width + 2))
+ head = "+" if out[0][0] == "-" else "|"
+ tail = "+" if out[-1][0] == "-" else "|"
+ glue = [
+ "+" if left[0] == "-" or right[0] == "-" else "|"
+ for left, right in zip(out, out[1:])
+ ]
+ glue.append(tail)
+ return head + "".join(chain.from_iterable(zip(out, glue)))
+
+ for lineno, line in enumerate(self.lines):
+ if self.separator and lineno == self.separator:
+ out.append(writesep("=", lineno))
+ else:
+ out.append(writesep("-", lineno))
+ for physical_line in range(self.physical_lines_for_line(line)):
+ linestr = ["|"]
+ for colno, cell in enumerate(line):
+ if cell.col != colno:
+ continue
+ if lineno != cell.row: # NoQA: SIM114
+ physical_text = ""
+ elif physical_line >= len(cell.wrapped):
+ physical_text = ""
+ else:
+ physical_text = cell.wrapped[physical_line]
+ adjust_len = len(physical_text) - column_width(physical_text)
+ linestr.append(
+ " " +
+ physical_text.ljust(
+ self.cell_width(cell, self.measured_widths) + 1 + adjust_len,
+ ) + "|",
+ )
+ out.append("".join(linestr))
+ out.append(writesep("-"))
+ return "\n".join(out)
+
+
+class TextWrapper(textwrap.TextWrapper):
+ """Custom subclass that uses a different word separator regex."""
+
+ wordsep_re = re.compile(
+ r'(\s+|' # any whitespace
+ r'(?<=\s)(?::[a-z-]+:)?`\S+|' # interpreted text start
+ r'[^\s\w]*\w+[a-zA-Z]-(?=\w+[a-zA-Z])|' # hyphenated words
+ r'(?<=[\w\!\"\'\&\.\,\?])-{2,}(?=\w))') # em-dash
+
+ def _wrap_chunks(self, chunks: list[str]) -> list[str]:
+ """_wrap_chunks(chunks : [string]) -> [string]
+
+ The original _wrap_chunks uses len() to calculate width.
+ This method respects wide/fullwidth characters for width adjustment.
+ """
+ lines: list[str] = []
+ if self.width <= 0:
+ raise ValueError("invalid width %r (must be > 0)" % self.width)
+
+ chunks.reverse()
+
+ while chunks:
+ cur_line = []
+ cur_len = 0
+
+ if lines:
+ indent = self.subsequent_indent
+ else:
+ indent = self.initial_indent
+
+ width = self.width - column_width(indent)
+
+ if self.drop_whitespace and chunks[-1].strip() == '' and lines:
+ del chunks[-1]
+
+ while chunks:
+ l = column_width(chunks[-1])
+
+ if cur_len + l <= width:
+ cur_line.append(chunks.pop())
+ cur_len += l
+
+ else:
+ break
+
+ if chunks and column_width(chunks[-1]) > width:
+ self._handle_long_word(chunks, cur_line, cur_len, width)
+
+ if self.drop_whitespace and cur_line and cur_line[-1].strip() == '':
+ del cur_line[-1]
+
+ if cur_line:
+ lines.append(indent + ''.join(cur_line))
+
+ return lines
+
+ def _break_word(self, word: str, space_left: int) -> tuple[str, str]:
+ """_break_word(word : string, space_left : int) -> (string, string)
+
+ Break line by unicode width instead of len(word).
+ """
+ total = 0
+ for i, c in enumerate(word):
+ total += column_width(c)
+ if total > space_left:
+ return word[:i - 1], word[i - 1:]
+ return word, ''
+
+ def _split(self, text: str) -> list[str]:
+ """_split(text : string) -> [string]
+
+ Override original method that only split by 'wordsep_re'.
+ This '_split' splits wide-characters into chunks by one character.
+ """
+ def split(t: str) -> list[str]:
+ return super(TextWrapper, self)._split(t)
+ chunks: list[str] = []
+ for chunk in split(text):
+ for w, g in groupby(chunk, column_width):
+ if w == 1:
+ chunks.extend(split(''.join(g)))
+ else:
+ chunks.extend(list(g))
+ return chunks
+
+ def _handle_long_word(self, reversed_chunks: list[str], cur_line: list[str],
+ cur_len: int, width: int) -> None:
+ """_handle_long_word(chunks : [string],
+ cur_line : [string],
+ cur_len : int, width : int)
+
+ Override original method for using self._break_word() instead of slice.
+ """
+ space_left = max(width - cur_len, 1)
+ if self.break_long_words:
+ l, r = self._break_word(reversed_chunks[-1], space_left)
+ cur_line.append(l)
+ reversed_chunks[-1] = r
+
+ elif not cur_line:
+ cur_line.append(reversed_chunks.pop())
+
+
+MAXWIDTH = 70
+STDINDENT = 3
+
+
+def my_wrap(text: str, width: int = MAXWIDTH, **kwargs: Any) -> list[str]:
+ w = TextWrapper(width=width, **kwargs)
+ return w.wrap(text)
+
+
+class TextWriter(writers.Writer):
+ supported = ('text',)
+ settings_spec = ('No options here.', '', ())
+ settings_defaults: dict[str, Any] = {}
+
+ output: str
+
+ def __init__(self, builder: TextBuilder) -> None:
+ super().__init__()
+ self.builder = builder
+
+ def translate(self) -> None:
+ visitor = self.builder.create_translator(self.document, self.builder)
+ self.document.walkabout(visitor)
+ self.output = cast(TextTranslator, visitor).body
+
+
+class TextTranslator(SphinxTranslator):
+ builder: TextBuilder
+
+ def __init__(self, document: nodes.document, builder: TextBuilder) -> None:
+ super().__init__(document, builder)
+
+ newlines = self.config.text_newlines
+ if newlines == 'windows':
+ self.nl = '\r\n'
+ elif newlines == 'native':
+ self.nl = os.linesep
+ else:
+ self.nl = '\n'
+ self.sectionchars = self.config.text_sectionchars
+ self.add_secnumbers = self.config.text_add_secnumbers
+ self.secnumber_suffix = self.config.text_secnumber_suffix
+ self.states: list[list[tuple[int, str | list[str]]]] = [[]]
+ self.stateindent = [0]
+ self.list_counter: list[int] = []
+ self.sectionlevel = 0
+ self.lineblocklevel = 0
+ self.table: Table
+
+ self.context: list[str] = []
+ """Heterogeneous stack.
+
+ Used by visit_* and depart_* functions in conjunction with the tree
+ traversal. Make sure that the pops correspond to the pushes.
+ """
+
+ def add_text(self, text: str) -> None:
+ self.states[-1].append((-1, text))
+
+ def new_state(self, indent: int = STDINDENT) -> None:
+ self.states.append([])
+ self.stateindent.append(indent)
+
+ def end_state(
+ self, wrap: bool = True, end: Sequence[str] | None = ('',), first: str | None = None,
+ ) -> None:
+ content = self.states.pop()
+ maxindent = sum(self.stateindent)
+ indent = self.stateindent.pop()
+ result: list[tuple[int, list[str]]] = []
+ toformat: list[str] = []
+
+ def do_format() -> None:
+ if not toformat:
+ return
+ if wrap:
+ res = my_wrap(''.join(toformat), width=MAXWIDTH - maxindent)
+ else:
+ res = ''.join(toformat).splitlines()
+ if end:
+ res += end
+ result.append((indent, res))
+ for itemindent, item in content:
+ if itemindent == -1:
+ toformat.append(item) # type: ignore[arg-type]
+ else:
+ do_format()
+ result.append((indent + itemindent, item)) # type: ignore[arg-type]
+ toformat = []
+ do_format()
+ if first is not None and result:
+ # insert prefix into first line (ex. *, [1], See also, etc.)
+ newindent = result[0][0] - indent
+ if result[0][1] == ['']:
+ result.insert(0, (newindent, [first]))
+ else:
+ text = first + result[0][1].pop(0)
+ result.insert(0, (newindent, [text]))
+
+ self.states[-1].extend(result)
+
+ def visit_document(self, node: Element) -> None:
+ self.new_state(0)
+
+ def depart_document(self, node: Element) -> None:
+ self.end_state()
+ self.body = self.nl.join(line and (' ' * indent + line)
+ for indent, lines in self.states[0]
+ for line in lines)
+ # XXX header/footer?
+
+ def visit_section(self, node: Element) -> None:
+ self._title_char = self.sectionchars[self.sectionlevel]
+ self.sectionlevel += 1
+
+ def depart_section(self, node: Element) -> None:
+ self.sectionlevel -= 1
+
+ def visit_topic(self, node: Element) -> None:
+ self.new_state(0)
+
+ def depart_topic(self, node: Element) -> None:
+ self.end_state()
+
+ visit_sidebar = visit_topic
+ depart_sidebar = depart_topic
+
+ def visit_rubric(self, node: Element) -> None:
+ self.new_state(0)
+ self.add_text('-[ ')
+
+ def depart_rubric(self, node: Element) -> None:
+ self.add_text(' ]-')
+ self.end_state()
+
+ def visit_compound(self, node: Element) -> None:
+ pass
+
+ def depart_compound(self, node: Element) -> None:
+ pass
+
+ def visit_glossary(self, node: Element) -> None:
+ pass
+
+ def depart_glossary(self, node: Element) -> None:
+ pass
+
+ def visit_title(self, node: Element) -> None:
+ if isinstance(node.parent, nodes.Admonition):
+ self.add_text(node.astext() + ': ')
+ raise nodes.SkipNode
+ self.new_state(0)
+
+ def get_section_number_string(self, node: Element) -> str:
+ if isinstance(node.parent, nodes.section):
+ anchorname = '#' + node.parent['ids'][0]
+ numbers = self.builder.secnumbers.get(anchorname)
+ if numbers is None:
+ numbers = self.builder.secnumbers.get('')
+ if numbers is not None:
+ return '.'.join(map(str, numbers)) + self.secnumber_suffix
+ return ''
+
+ def depart_title(self, node: Element) -> None:
+ if isinstance(node.parent, nodes.section):
+ char = self._title_char
+ else:
+ char = '^'
+ text = ''
+ text = ''.join(x[1] for x in self.states.pop() if x[0] == -1) # type: ignore[misc]
+ if self.add_secnumbers:
+ text = self.get_section_number_string(node) + text
+ self.stateindent.pop()
+ title = ['', text, '%s' % (char * column_width(text)), '']
+ if len(self.states) == 2 and len(self.states[-1]) == 0:
+ # remove an empty line before title if it is first section title in the document
+ title.pop(0)
+ self.states[-1].append((0, title))
+
+ def visit_subtitle(self, node: Element) -> None:
+ pass
+
+ def depart_subtitle(self, node: Element) -> None:
+ pass
+
+ def visit_attribution(self, node: Element) -> None:
+ self.add_text('-- ')
+
+ def depart_attribution(self, node: Element) -> None:
+ pass
+
+ #############################################################
+ # Domain-specific object descriptions
+ #############################################################
+
+ # Top-level nodes
+ #################
+
+ def visit_desc(self, node: Element) -> None:
+ pass
+
+ def depart_desc(self, node: Element) -> None:
+ pass
+
+ def visit_desc_signature(self, node: Element) -> None:
+ self.new_state(0)
+
+ def depart_desc_signature(self, node: Element) -> None:
+ # XXX: wrap signatures in a way that makes sense
+ self.end_state(wrap=False, end=None)
+
+ def visit_desc_signature_line(self, node: Element) -> None:
+ pass
+
+ def depart_desc_signature_line(self, node: Element) -> None:
+ self.add_text('\n')
+
+ def visit_desc_content(self, node: Element) -> None:
+ self.new_state()
+ self.add_text(self.nl)
+
+ def depart_desc_content(self, node: Element) -> None:
+ self.end_state()
+
+ def visit_desc_inline(self, node: Element) -> None:
+ pass
+
+ def depart_desc_inline(self, node: Element) -> None:
+ pass
+
+ # Nodes for high-level structure in signatures
+ ##############################################
+
+ def visit_desc_name(self, node: Element) -> None:
+ pass
+
+ def depart_desc_name(self, node: Element) -> None:
+ pass
+
+ def visit_desc_addname(self, node: Element) -> None:
+ pass
+
+ def depart_desc_addname(self, node: Element) -> None:
+ pass
+
+ def visit_desc_type(self, node: Element) -> None:
+ pass
+
+ def depart_desc_type(self, node: Element) -> None:
+ pass
+
+ def visit_desc_returns(self, node: Element) -> None:
+ self.add_text(' -> ')
+
+ def depart_desc_returns(self, node: Element) -> None:
+ pass
+
+ def _visit_sig_parameter_list(
+ self,
+ node: Element,
+ parameter_group: type[Element],
+ sig_open_paren: str,
+ sig_close_paren: str,
+ ) -> None:
+ """Visit a signature parameters or type parameters list.
+
+ The *parameter_group* value is the type of a child node acting as a required parameter
+ or as a set of contiguous optional parameters.
+ """
+ self.add_text(sig_open_paren)
+ self.is_first_param = True
+ self.optional_param_level = 0
+ self.params_left_at_level = 0
+ self.param_group_index = 0
+ # Counts as what we call a parameter group are either a required parameter, or a
+ # set of contiguous optional ones.
+ self.list_is_required_param = [isinstance(c, parameter_group) for c in node.children]
+ self.required_params_left = sum(self.list_is_required_param)
+ self.param_separator = ', '
+ self.multi_line_parameter_list = node.get('multi_line_parameter_list', False)
+ if self.multi_line_parameter_list:
+ self.param_separator = self.param_separator.rstrip()
+ self.context.append(sig_close_paren)
+
+ def _depart_sig_parameter_list(self, node: Element) -> None:
+ sig_close_paren = self.context.pop()
+ self.add_text(sig_close_paren)
+
+ def visit_desc_parameterlist(self, node: Element) -> None:
+ self._visit_sig_parameter_list(node, addnodes.desc_parameter, '(', ')')
+
+ def depart_desc_parameterlist(self, node: Element) -> None:
+ self._depart_sig_parameter_list(node)
+
+ def visit_desc_type_parameter_list(self, node: Element) -> None:
+ self._visit_sig_parameter_list(node, addnodes.desc_type_parameter, '[', ']')
+
+ def depart_desc_type_parameter_list(self, node: Element) -> None:
+ self._depart_sig_parameter_list(node)
+
+ def visit_desc_parameter(self, node: Element) -> None:
+ on_separate_line = self.multi_line_parameter_list
+ if on_separate_line and not (self.is_first_param and self.optional_param_level > 0):
+ self.new_state()
+ if self.is_first_param:
+ self.is_first_param = False
+ elif not on_separate_line and not self.required_params_left:
+ self.add_text(self.param_separator)
+ if self.optional_param_level == 0:
+ self.required_params_left -= 1
+ else:
+ self.params_left_at_level -= 1
+
+ self.add_text(node.astext())
+
+ is_required = self.list_is_required_param[self.param_group_index]
+ if on_separate_line:
+ is_last_group = self.param_group_index + 1 == len(self.list_is_required_param)
+ next_is_required = (
+ not is_last_group
+ and self.list_is_required_param[self.param_group_index + 1]
+ )
+ opt_param_left_at_level = self.params_left_at_level > 0
+ if opt_param_left_at_level or is_required and (is_last_group or next_is_required):
+ self.add_text(self.param_separator)
+ self.end_state(wrap=False, end=None)
+
+ elif self.required_params_left:
+ self.add_text(self.param_separator)
+
+ if is_required:
+ self.param_group_index += 1
+ raise nodes.SkipNode
+
+ def visit_desc_type_parameter(self, node: Element) -> None:
+ self.visit_desc_parameter(node)
+
+ def visit_desc_optional(self, node: Element) -> None:
+ self.params_left_at_level = sum([isinstance(c, addnodes.desc_parameter)
+ for c in node.children])
+ self.optional_param_level += 1
+ self.max_optional_param_level = self.optional_param_level
+ if self.multi_line_parameter_list:
+ # If the first parameter is optional, start a new line and open the bracket.
+ if self.is_first_param:
+ self.new_state()
+ self.add_text('[')
+ # Else, if there remains at least one required parameter, append the
+ # parameter separator, open a new bracket, and end the line.
+ elif self.required_params_left:
+ self.add_text(self.param_separator)
+ self.add_text('[')
+ self.end_state(wrap=False, end=None)
+ # Else, open a new bracket, append the parameter separator, and end the
+ # line.
+ else:
+ self.add_text('[')
+ self.add_text(self.param_separator)
+ self.end_state(wrap=False, end=None)
+ else:
+ self.add_text('[')
+
+ def depart_desc_optional(self, node: Element) -> None:
+ self.optional_param_level -= 1
+ if self.multi_line_parameter_list:
+ # If it's the first time we go down one level, add the separator before the
+ # bracket.
+ if self.optional_param_level == self.max_optional_param_level - 1:
+ self.add_text(self.param_separator)
+ self.add_text(']')
+ # End the line if we have just closed the last bracket of this group of
+ # optional parameters.
+ if self.optional_param_level == 0:
+ self.end_state(wrap=False, end=None)
+
+ else:
+ self.add_text(']')
+ if self.optional_param_level == 0:
+ self.param_group_index += 1
+
+ def visit_desc_annotation(self, node: Element) -> None:
+ pass
+
+ def depart_desc_annotation(self, node: Element) -> None:
+ pass
+
+ ##############################################
+
+ def visit_figure(self, node: Element) -> None:
+ self.new_state()
+
+ def depart_figure(self, node: Element) -> None:
+ self.end_state()
+
+ def visit_caption(self, node: Element) -> None:
+ pass
+
+ def depart_caption(self, node: Element) -> None:
+ pass
+
+ def visit_productionlist(self, node: Element) -> None:
+ self.new_state()
+ names = []
+ productionlist = cast(Iterable[addnodes.production], node)
+ for production in productionlist:
+ names.append(production['tokenname'])
+ maxlen = max(len(name) for name in names)
+ lastname = None
+ for production in productionlist:
+ if production['tokenname']:
+ self.add_text(production['tokenname'].ljust(maxlen) + ' ::=')
+ lastname = production['tokenname']
+ elif lastname is not None:
+ self.add_text('%s ' % (' ' * len(lastname)))
+ self.add_text(production.astext() + self.nl)
+ self.end_state(wrap=False)
+ raise nodes.SkipNode
+
+ def visit_footnote(self, node: Element) -> None:
+ label = cast(nodes.label, node[0])
+ self._footnote = label.astext().strip()
+ self.new_state(len(self._footnote) + 3)
+
+ def depart_footnote(self, node: Element) -> None:
+ self.end_state(first='[%s] ' % self._footnote)
+
+ def visit_citation(self, node: Element) -> None:
+ if len(node) and isinstance(node[0], nodes.label):
+ self._citlabel = node[0].astext()
+ else:
+ self._citlabel = ''
+ self.new_state(len(self._citlabel) + 3)
+
+ def depart_citation(self, node: Element) -> None:
+ self.end_state(first='[%s] ' % self._citlabel)
+
+ def visit_label(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_legend(self, node: Element) -> None:
+ pass
+
+ def depart_legend(self, node: Element) -> None:
+ pass
+
+ # XXX: option list could use some better styling
+
+ def visit_option_list(self, node: Element) -> None:
+ pass
+
+ def depart_option_list(self, node: Element) -> None:
+ pass
+
+ def visit_option_list_item(self, node: Element) -> None:
+ self.new_state(0)
+
+ def depart_option_list_item(self, node: Element) -> None:
+ self.end_state()
+
+ def visit_option_group(self, node: Element) -> None:
+ self._firstoption = True
+
+ def depart_option_group(self, node: Element) -> None:
+ self.add_text(' ')
+
+ def visit_option(self, node: Element) -> None:
+ if self._firstoption:
+ self._firstoption = False
+ else:
+ self.add_text(', ')
+
+ def depart_option(self, node: Element) -> None:
+ pass
+
+ def visit_option_string(self, node: Element) -> None:
+ pass
+
+ def depart_option_string(self, node: Element) -> None:
+ pass
+
+ def visit_option_argument(self, node: Element) -> None:
+ self.add_text(node['delimiter'])
+
+ def depart_option_argument(self, node: Element) -> None:
+ pass
+
+ def visit_description(self, node: Element) -> None:
+ pass
+
+ def depart_description(self, node: Element) -> None:
+ pass
+
+ def visit_tabular_col_spec(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_colspec(self, node: Element) -> None:
+ self.table.colwidth.append(node["colwidth"])
+ raise nodes.SkipNode
+
+ def visit_tgroup(self, node: Element) -> None:
+ pass
+
+ def depart_tgroup(self, node: Element) -> None:
+ pass
+
+ def visit_thead(self, node: Element) -> None:
+ pass
+
+ def depart_thead(self, node: Element) -> None:
+ pass
+
+ def visit_tbody(self, node: Element) -> None:
+ self.table.set_separator()
+
+ def depart_tbody(self, node: Element) -> None:
+ pass
+
+ def visit_row(self, node: Element) -> None:
+ if self.table.lines:
+ self.table.add_row()
+
+ def depart_row(self, node: Element) -> None:
+ pass
+
+ def visit_entry(self, node: Element) -> None:
+ self.entry = Cell(
+ rowspan=node.get("morerows", 0) + 1, colspan=node.get("morecols", 0) + 1,
+ )
+ self.new_state(0)
+
+ def depart_entry(self, node: Element) -> None:
+ text = self.nl.join(self.nl.join(x[1]) for x in self.states.pop())
+ self.stateindent.pop()
+ self.entry.text = text
+ self.table.add_cell(self.entry)
+ del self.entry
+
+ def visit_table(self, node: Element) -> None:
+ if hasattr(self, 'table'):
+ msg = 'Nested tables are not supported.'
+ raise NotImplementedError(msg)
+ self.new_state(0)
+ self.table = Table()
+
+ def depart_table(self, node: Element) -> None:
+ self.add_text(str(self.table))
+ del self.table
+ self.end_state(wrap=False)
+
+ def visit_acks(self, node: Element) -> None:
+ bullet_list = cast(nodes.bullet_list, node[0])
+ list_items = cast(Iterable[nodes.list_item], bullet_list)
+ self.new_state(0)
+ self.add_text(', '.join(n.astext() for n in list_items) + '.')
+ self.end_state()
+ raise nodes.SkipNode
+
+ def visit_image(self, node: Element) -> None:
+ if 'alt' in node.attributes:
+ self.add_text(_('[image: %s]') % node['alt'])
+ self.add_text(_('[image]'))
+ raise nodes.SkipNode
+
+ def visit_transition(self, node: Element) -> None:
+ indent = sum(self.stateindent)
+ self.new_state(0)
+ self.add_text('=' * (MAXWIDTH - indent))
+ self.end_state()
+ raise nodes.SkipNode
+
+ def visit_bullet_list(self, node: Element) -> None:
+ self.list_counter.append(-1)
+
+ def depart_bullet_list(self, node: Element) -> None:
+ self.list_counter.pop()
+
+ def visit_enumerated_list(self, node: Element) -> None:
+ self.list_counter.append(node.get('start', 1) - 1)
+
+ def depart_enumerated_list(self, node: Element) -> None:
+ self.list_counter.pop()
+
+ def visit_definition_list(self, node: Element) -> None:
+ self.list_counter.append(-2)
+
+ def depart_definition_list(self, node: Element) -> None:
+ self.list_counter.pop()
+
+ def visit_list_item(self, node: Element) -> None:
+ if self.list_counter[-1] == -1:
+ # bullet list
+ self.new_state(2)
+ elif self.list_counter[-1] == -2:
+ # definition list
+ pass
+ else:
+ # enumerated list
+ self.list_counter[-1] += 1
+ self.new_state(len(str(self.list_counter[-1])) + 2)
+
+ def depart_list_item(self, node: Element) -> None:
+ if self.list_counter[-1] == -1:
+ self.end_state(first='* ')
+ elif self.list_counter[-1] == -2:
+ pass
+ else:
+ self.end_state(first='%s. ' % self.list_counter[-1])
+
+ def visit_definition_list_item(self, node: Element) -> None:
+ self._classifier_count_in_li = len(list(node.findall(nodes.classifier)))
+
+ def depart_definition_list_item(self, node: Element) -> None:
+ pass
+
+ def visit_term(self, node: Element) -> None:
+ self.new_state(0)
+
+ def depart_term(self, node: Element) -> None:
+ if not self._classifier_count_in_li:
+ self.end_state(end=None)
+
+ def visit_classifier(self, node: Element) -> None:
+ self.add_text(' : ')
+
+ def depart_classifier(self, node: Element) -> None:
+ self._classifier_count_in_li -= 1
+ if not self._classifier_count_in_li:
+ self.end_state(end=None)
+
+ def visit_definition(self, node: Element) -> None:
+ self.new_state()
+
+ def depart_definition(self, node: Element) -> None:
+ self.end_state()
+
+ def visit_field_list(self, node: Element) -> None:
+ pass
+
+ def depart_field_list(self, node: Element) -> None:
+ pass
+
+ def visit_field(self, node: Element) -> None:
+ pass
+
+ def depart_field(self, node: Element) -> None:
+ pass
+
+ def visit_field_name(self, node: Element) -> None:
+ self.new_state(0)
+
+ def depart_field_name(self, node: Element) -> None:
+ self.add_text(':')
+ self.end_state(end=None)
+
+ def visit_field_body(self, node: Element) -> None:
+ self.new_state()
+
+ def depart_field_body(self, node: Element) -> None:
+ self.end_state()
+
+ def visit_centered(self, node: Element) -> None:
+ pass
+
+ def depart_centered(self, node: Element) -> None:
+ pass
+
+ def visit_hlist(self, node: Element) -> None:
+ pass
+
+ def depart_hlist(self, node: Element) -> None:
+ pass
+
+ def visit_hlistcol(self, node: Element) -> None:
+ pass
+
+ def depart_hlistcol(self, node: Element) -> None:
+ pass
+
+ def visit_admonition(self, node: Element) -> None:
+ self.new_state(0)
+
+ def depart_admonition(self, node: Element) -> None:
+ self.end_state()
+
+ def _visit_admonition(self, node: Element) -> None:
+ self.new_state(2)
+
+ def _depart_admonition(self, node: Element) -> None:
+ label = admonitionlabels[node.tagname]
+ indent = sum(self.stateindent) + len(label)
+ if (len(self.states[-1]) == 1 and
+ self.states[-1][0][0] == 0 and
+ MAXWIDTH - indent >= sum(len(s) for s in self.states[-1][0][1])):
+ # short text: append text after admonition label
+ self.stateindent[-1] += len(label)
+ self.end_state(first=label + ': ')
+ else:
+ # long text: append label before the block
+ self.states[-1].insert(0, (0, [self.nl]))
+ self.end_state(first=label + ':')
+
+ visit_attention = _visit_admonition
+ depart_attention = _depart_admonition
+ visit_caution = _visit_admonition
+ depart_caution = _depart_admonition
+ visit_danger = _visit_admonition
+ depart_danger = _depart_admonition
+ visit_error = _visit_admonition
+ depart_error = _depart_admonition
+ visit_hint = _visit_admonition
+ depart_hint = _depart_admonition
+ visit_important = _visit_admonition
+ depart_important = _depart_admonition
+ visit_note = _visit_admonition
+ depart_note = _depart_admonition
+ visit_tip = _visit_admonition
+ depart_tip = _depart_admonition
+ visit_warning = _visit_admonition
+ depart_warning = _depart_admonition
+ visit_seealso = _visit_admonition
+ depart_seealso = _depart_admonition
+
+ def visit_versionmodified(self, node: Element) -> None:
+ self.new_state(0)
+
+ def depart_versionmodified(self, node: Element) -> None:
+ self.end_state()
+
+ def visit_literal_block(self, node: Element) -> None:
+ self.new_state()
+
+ def depart_literal_block(self, node: Element) -> None:
+ self.end_state(wrap=False)
+
+ def visit_doctest_block(self, node: Element) -> None:
+ self.new_state(0)
+
+ def depart_doctest_block(self, node: Element) -> None:
+ self.end_state(wrap=False)
+
+ def visit_line_block(self, node: Element) -> None:
+ self.new_state()
+ self.lineblocklevel += 1
+
+ def depart_line_block(self, node: Element) -> None:
+ self.lineblocklevel -= 1
+ self.end_state(wrap=False, end=None)
+ if not self.lineblocklevel:
+ self.add_text('\n')
+
+ def visit_line(self, node: Element) -> None:
+ pass
+
+ def depart_line(self, node: Element) -> None:
+ self.add_text('\n')
+
+ def visit_block_quote(self, node: Element) -> None:
+ self.new_state()
+
+ def depart_block_quote(self, node: Element) -> None:
+ self.end_state()
+
+ def visit_compact_paragraph(self, node: Element) -> None:
+ pass
+
+ def depart_compact_paragraph(self, node: Element) -> None:
+ pass
+
+ def visit_paragraph(self, node: Element) -> None:
+ if not isinstance(node.parent, nodes.Admonition) or \
+ isinstance(node.parent, addnodes.seealso):
+ self.new_state(0)
+
+ def depart_paragraph(self, node: Element) -> None:
+ if not isinstance(node.parent, nodes.Admonition) or \
+ isinstance(node.parent, addnodes.seealso):
+ self.end_state()
+
+ def visit_target(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_index(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_toctree(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_substitution_definition(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_pending_xref(self, node: Element) -> None:
+ pass
+
+ def depart_pending_xref(self, node: Element) -> None:
+ pass
+
+ def visit_reference(self, node: Element) -> None:
+ if self.add_secnumbers:
+ numbers = node.get("secnumber")
+ if numbers is not None:
+ self.add_text('.'.join(map(str, numbers)) + self.secnumber_suffix)
+
+ def depart_reference(self, node: Element) -> None:
+ pass
+
+ def visit_number_reference(self, node: Element) -> None:
+ text = nodes.Text(node.get('title', '#'))
+ self.visit_Text(text)
+ raise nodes.SkipNode
+
+ def visit_download_reference(self, node: Element) -> None:
+ pass
+
+ def depart_download_reference(self, node: Element) -> None:
+ pass
+
+ def visit_emphasis(self, node: Element) -> None:
+ self.add_text('*')
+
+ def depart_emphasis(self, node: Element) -> None:
+ self.add_text('*')
+
+ def visit_literal_emphasis(self, node: Element) -> None:
+ self.add_text('*')
+
+ def depart_literal_emphasis(self, node: Element) -> None:
+ self.add_text('*')
+
+ def visit_strong(self, node: Element) -> None:
+ self.add_text('**')
+
+ def depart_strong(self, node: Element) -> None:
+ self.add_text('**')
+
+ def visit_literal_strong(self, node: Element) -> None:
+ self.add_text('**')
+
+ def depart_literal_strong(self, node: Element) -> None:
+ self.add_text('**')
+
+ def visit_abbreviation(self, node: Element) -> None:
+ self.add_text('')
+
+ def depart_abbreviation(self, node: Element) -> None:
+ if node.hasattr('explanation'):
+ self.add_text(' (%s)' % node['explanation'])
+
+ def visit_manpage(self, node: Element) -> None:
+ return self.visit_literal_emphasis(node)
+
+ def depart_manpage(self, node: Element) -> None:
+ return self.depart_literal_emphasis(node)
+
+ def visit_title_reference(self, node: Element) -> None:
+ self.add_text('*')
+
+ def depart_title_reference(self, node: Element) -> None:
+ self.add_text('*')
+
+ def visit_literal(self, node: Element) -> None:
+ self.add_text('"')
+
+ def depart_literal(self, node: Element) -> None:
+ self.add_text('"')
+
+ def visit_subscript(self, node: Element) -> None:
+ self.add_text('_')
+
+ def depart_subscript(self, node: Element) -> None:
+ pass
+
+ def visit_superscript(self, node: Element) -> None:
+ self.add_text('^')
+
+ def depart_superscript(self, node: Element) -> None:
+ pass
+
+ def visit_footnote_reference(self, node: Element) -> None:
+ self.add_text('[%s]' % node.astext())
+ raise nodes.SkipNode
+
+ def visit_citation_reference(self, node: Element) -> None:
+ self.add_text('[%s]' % node.astext())
+ raise nodes.SkipNode
+
+ def visit_Text(self, node: Text) -> None:
+ self.add_text(node.astext())
+
+ def depart_Text(self, node: Text) -> None:
+ pass
+
+ def visit_generated(self, node: Element) -> None:
+ pass
+
+ def depart_generated(self, node: Element) -> None:
+ pass
+
+ def visit_inline(self, node: Element) -> None:
+ if 'xref' in node['classes'] or 'term' in node['classes']:
+ self.add_text('*')
+
+ def depart_inline(self, node: Element) -> None:
+ if 'xref' in node['classes'] or 'term' in node['classes']:
+ self.add_text('*')
+
+ def visit_container(self, node: Element) -> None:
+ pass
+
+ def depart_container(self, node: Element) -> None:
+ pass
+
+ def visit_problematic(self, node: Element) -> None:
+ self.add_text('>>')
+
+ def depart_problematic(self, node: Element) -> None:
+ self.add_text('<<')
+
+ def visit_system_message(self, node: Element) -> None:
+ self.new_state(0)
+ self.add_text('<SYSTEM MESSAGE: %s>' % node.astext())
+ self.end_state()
+ raise nodes.SkipNode
+
+ def visit_comment(self, node: Element) -> None:
+ raise nodes.SkipNode
+
+ def visit_meta(self, node: Element) -> None:
+ # only valid for HTML
+ raise nodes.SkipNode
+
+ def visit_raw(self, node: Element) -> None:
+ if 'text' in node.get('format', '').split():
+ self.new_state(0)
+ self.add_text(node.astext())
+ self.end_state(wrap = False)
+ raise nodes.SkipNode
+
+ def visit_math(self, node: Element) -> None:
+ pass
+
+ def depart_math(self, node: Element) -> None:
+ pass
+
+ def visit_math_block(self, node: Element) -> None:
+ self.new_state()
+
+ def depart_math_block(self, node: Element) -> None:
+ self.end_state()
diff --git a/sphinx/writers/xml.py b/sphinx/writers/xml.py
new file mode 100644
index 0000000..38aa763
--- /dev/null
+++ b/sphinx/writers/xml.py
@@ -0,0 +1,52 @@
+"""Docutils-native XML and pseudo-XML writers."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from docutils.writers.docutils_xml import Writer as BaseXMLWriter
+
+if TYPE_CHECKING:
+ from sphinx.builders import Builder
+
+
+class XMLWriter(BaseXMLWriter):
+ output: str
+
+ def __init__(self, builder: Builder) -> None:
+ super().__init__()
+ self.builder = builder
+
+ # A lambda function to generate translator lazily
+ self.translator_class = lambda document: self.builder.create_translator(document)
+
+ def translate(self, *args: Any, **kwargs: Any) -> None:
+ self.document.settings.newlines = \
+ self.document.settings.indents = \
+ self.builder.env.config.xml_pretty
+ self.document.settings.xml_declaration = True
+ self.document.settings.doctype_declaration = True
+ return super().translate()
+
+
+class PseudoXMLWriter(BaseXMLWriter):
+
+ supported = ('pprint', 'pformat', 'pseudoxml')
+ """Formats this writer supports."""
+
+ config_section = 'pseudoxml writer'
+ config_section_dependencies = ('writers',)
+
+ output: str
+ """Final translated form of `document`."""
+
+ def __init__(self, builder: Builder) -> None:
+ super().__init__()
+ self.builder = builder
+
+ def translate(self) -> None:
+ self.output = self.document.pformat()
+
+ def supports(self, format: str) -> bool:
+ """This writer supports all format-specific elements."""
+ return True