From cf7da1843c45a4c2df7a749f7886a2d2ba0ee92a Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Mon, 15 Apr 2024 19:25:40 +0200 Subject: Adding upstream version 7.2.6. Signed-off-by: Daniel Baumann --- sphinx/writers/__init__.py | 1 + sphinx/writers/html.py | 44 + sphinx/writers/html5.py | 936 ++++++++++++++++++ sphinx/writers/latex.py | 2266 ++++++++++++++++++++++++++++++++++++++++++++ sphinx/writers/manpage.py | 473 +++++++++ sphinx/writers/texinfo.py | 1572 ++++++++++++++++++++++++++++++ sphinx/writers/text.py | 1305 +++++++++++++++++++++++++ sphinx/writers/xml.py | 52 + 8 files changed, 6649 insertions(+) create mode 100644 sphinx/writers/__init__.py create mode 100644 sphinx/writers/html.py create mode 100644 sphinx/writers/html5.py create mode 100644 sphinx/writers/latex.py create mode 100644 sphinx/writers/manpage.py create mode 100644 sphinx/writers/texinfo.py create mode 100644 sphinx/writers/text.py create mode 100644 sphinx/writers/xml.py (limited to 'sphinx/writers') 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 ... will be + # converted to ... 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('' % 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('\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('\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('
') + + 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('') + + 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('') + + # 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('') + + 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('') + + 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(' ') + self.body.append('') + self.body.append(' ') + + def depart_desc_returns(self, node: Element) -> None: + self.body.append('') + + 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'{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 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('\n\n') + sig_close_paren = self.context.pop() + self.body.append(f'{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) + + # 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('') + + def depart_desc_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) + self.body.append('\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('[') + # 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('[') + self.body.append('\n') + # Else, open a new bracket, append the parameter separator, + # and end the line. + else: + self.body.append('[') + self.body.append(self.param_separator) + self.body.append('\n') + else: + self.body.append('[') + + 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(']') + # 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('\n') + else: + 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(self.starttag(node, 'em', '', CLASS='property')) + + def depart_desc_annotation(self, node: Element) -> None: + self.body.append('') + + ############################################## + + 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('\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('\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('%s' % + ('.'.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('') + 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('') + + 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'{icon}', + ) + + # overwritten + def visit_bullet_list(self, node: Element) -> None: + if len(node) == 1 and isinstance(node[0], addnodes.toctree): + # avoid emitting empty + raise nodes.SkipNode + super().visit_bullet_list(node) + + # overwritten + def visit_definition(self, node: Element) -> None: + # don't insert here. + self.body.append(self.starttag(node, 'dd', '')) + + # overwritten + def depart_definition(self, node: Element) -> None: + self.body.append('\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('') + + next_node: Node = node.next_node(descend=False, siblings=True) + if not isinstance(next_node, nodes.classifier): + # close `
` tag at the tail of classifiers + self.body.append('
') + + # 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('') + + # 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('') + self.context.append('

\n') + else: + super().visit_title(node) + self.add_secnumber(node) + self.add_fignumber(node.parent) + if isinstance(node.parent, nodes.table): + self.body.append('') + + 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('{}'.format( + _('Link to this heading'), + self.config.html_permalinks_icon)) + elif isinstance(node.parent, nodes.table): + self.body.append('') + self.add_permalink_ref(node.parent, _('Link to this table')) + elif isinstance(node.parent, nodes.table): + self.body.append('') + + 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 + '\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('
') + 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('') + + # 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('
\n') + else: + super().depart_caption(node) + + def visit_doctest_block(self, node: Element) -> None: + self.visit_literal_block(node) + + # overwritten to add the
(for XHTML compliance) + def visit_block_quote(self, node: Element) -> None: + self.body.append(self.starttag(node, 'blockquote') + '
') + + def depart_block_quote(self, node: Element) -> None: + self.body.append('
\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() + "") + raise nodes.SkipNode + + def depart_literal(self, node: Element) -> None: + if 'kbd' in node['classes']: + self.body.append('') + else: + self.protect_literal_text -= 1 + self.body.append('') + + 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 + ' ::= ') + elif lastname is not None: + self.body.append('%s ' % (' ' * len(lastname))) + production.walkabout(self) + self.body.append('\n') + self.body.append('\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") + + '') + + def depart_centered(self, node: Element) -> None: + self.body.append('

') + + 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('
') + 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('') + 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('') + + def depart_hlist(self, node: Element) -> None: + self.body.append('
\n') + + def visit_hlistcol(self, node: Element) -> None: + self.body.append('') + + def depart_hlistcol(self, node: Element) -> None: + self.body.append('') + + # 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('%s' % 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(' ' * (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('') + + 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('[') 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: + foo=1 + &bar=2 + After: + foo=var + &bar=2 + """ + 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 '' + elements['title'] = self.escape_id(title) or '' + # 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 '' + 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' + '\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"{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('' % 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 -- cgit v1.2.3