"""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:
# https://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 ... 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('')
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' ')
self.body.append(' {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:
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(' ')
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