"""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