diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 7ea70fa4d..2fb1b3655 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -43,7 +43,8 @@ from sphinx.util.inventory import InventoryFile from sphinx.util.matching import DOTFILES, Matcher, patmatch from sphinx.util.osutil import copyfile, ensuredir, os_path, relative_uri from sphinx.util.tags import Tags -from sphinx.writers.html import HTMLTranslator, HTMLWriter +from sphinx.writers._html4 import HTML4Translator +from sphinx.writers.html import HTMLWriter from sphinx.writers.html5 import HTML5Translator #: the filename for the inventory of objects @@ -372,7 +373,7 @@ class StandaloneHTMLBuilder(Builder): @property def default_translator_class(self) -> Type[nodes.NodeVisitor]: # type: ignore if self.config.html4_writer: - return HTMLTranslator # RemovedInSphinx70Warning + return HTML4Translator # RemovedInSphinx70Warning else: return HTML5Translator @@ -1326,8 +1327,12 @@ import sphinx.builders.singlehtml # noqa: E402,F401 deprecated_alias('sphinx.builders.html', { 'html5_ready': True, + 'HTMLTranslator': HTML4Translator, }, - RemovedInSphinx70Warning) + RemovedInSphinx70Warning, + { + 'HTMLTranslator': 'sphinx.writers.html.HTML5Translator', + }) def setup(app: Sphinx) -> Dict[str, Any]: diff --git a/sphinx/ext/autosummary/__init__.py b/sphinx/ext/autosummary/__init__.py index 3cb2e54a7..268fb65a2 100644 --- a/sphinx/ext/autosummary/__init__.py +++ b/sphinx/ext/autosummary/__init__.py @@ -84,7 +84,7 @@ from sphinx.util.docutils import (NullReporter, SphinxDirective, SphinxRole, new from sphinx.util.inspect import signature_from_str from sphinx.util.matching import Matcher from sphinx.util.typing import OptionSpec -from sphinx.writers.html import HTMLTranslator +from sphinx.writers.html import HTML5Translator logger = logging.getLogger(__name__) @@ -116,7 +116,7 @@ class autosummary_table(nodes.comment): pass -def autosummary_table_visit_html(self: HTMLTranslator, node: autosummary_table) -> None: +def autosummary_table_visit_html(self: HTML5Translator, node: autosummary_table) -> None: """Make the first column of the table non-breaking.""" try: table = cast(nodes.table, node[0]) diff --git a/sphinx/ext/graphviz.py b/sphinx/ext/graphviz.py index 74291163f..ed7278f5c 100644 --- a/sphinx/ext/graphviz.py +++ b/sphinx/ext/graphviz.py @@ -23,7 +23,7 @@ from sphinx.util.i18n import search_image_for_language from sphinx.util.nodes import set_source_info from sphinx.util.osutil import ensuredir from sphinx.util.typing import OptionSpec -from sphinx.writers.html import HTMLTranslator +from sphinx.writers.html import HTML5Translator from sphinx.writers.latex import LaTeXTranslator from sphinx.writers.manpage import ManualPageTranslator from sphinx.writers.texinfo import TexinfoTranslator @@ -262,7 +262,7 @@ def render_dot(self: SphinxTranslator, code: str, options: Dict, format: str, '[stdout]\n%r') % (exc.stderr, exc.stdout)) from exc -def render_dot_html(self: HTMLTranslator, node: graphviz, code: str, options: Dict, +def render_dot_html(self: HTML5Translator, node: graphviz, code: str, options: Dict, prefix: str = 'graphviz', imgcls: Optional[str] = None, alt: Optional[str] = None, filename: Optional[str] = None ) -> Tuple[str, str]: @@ -315,7 +315,7 @@ def render_dot_html(self: HTMLTranslator, node: graphviz, code: str, options: Di raise nodes.SkipNode -def html_visit_graphviz(self: HTMLTranslator, node: graphviz) -> None: +def html_visit_graphviz(self: HTML5Translator, node: graphviz) -> None: render_dot_html(self, node, node['code'], node['options'], filename=node.get('filename')) diff --git a/sphinx/ext/imgmath.py b/sphinx/ext/imgmath.py index ac525d451..a5946aa01 100644 --- a/sphinx/ext/imgmath.py +++ b/sphinx/ext/imgmath.py @@ -24,7 +24,7 @@ from sphinx.util.math import get_node_equation_number, wrap_displaymath from sphinx.util.osutil import ensuredir from sphinx.util.png import read_png_depth, write_png_depth from sphinx.util.template import LaTeXRenderer -from sphinx.writers.html import HTMLTranslator +from sphinx.writers.html import HTML5Translator logger = logging.getLogger(__name__) @@ -201,7 +201,7 @@ def convert_dvi_to_svg(dvipath: str, builder: Builder, out_path: str) -> Optiona def render_math( - self: HTMLTranslator, + self: HTML5Translator, math: str, ) -> Tuple[Optional[str], Optional[int]]: """Render the LaTeX math expression *math* using latex and dvipng or @@ -291,13 +291,13 @@ def clean_up_files(app: Sphinx, exc: Exception) -> None: pass -def get_tooltip(self: HTMLTranslator, node: Element) -> str: +def get_tooltip(self: HTML5Translator, node: Element) -> str: if self.builder.config.imgmath_add_tooltips: return ' alt="%s"' % self.encode(node.astext()).strip() return '' -def html_visit_math(self: HTMLTranslator, node: nodes.math) -> None: +def html_visit_math(self: HTML5Translator, node: nodes.math) -> None: try: rendered_path, depth = render_math(self, '$' + node.astext() + '$') except MathExtError as exc: @@ -326,7 +326,7 @@ def html_visit_math(self: HTMLTranslator, node: nodes.math) -> None: raise nodes.SkipNode -def html_visit_displaymath(self: HTMLTranslator, node: nodes.math_block) -> None: +def html_visit_displaymath(self: HTML5Translator, node: nodes.math_block) -> None: if node['nowrap']: latex = node.astext() else: diff --git a/sphinx/ext/inheritance_diagram.py b/sphinx/ext/inheritance_diagram.py index 5a1321328..38fc9255a 100644 --- a/sphinx/ext/inheritance_diagram.py +++ b/sphinx/ext/inheritance_diagram.py @@ -47,7 +47,7 @@ from sphinx.ext.graphviz import (figure_wrapper, graphviz, render_dot_html, rend from sphinx.util import md5 from sphinx.util.docutils import SphinxDirective from sphinx.util.typing import OptionSpec -from sphinx.writers.html import HTMLTranslator +from sphinx.writers.html import HTML5Translator from sphinx.writers.latex import LaTeXTranslator from sphinx.writers.texinfo import TexinfoTranslator @@ -387,7 +387,7 @@ def get_graph_hash(node: inheritance_diagram) -> str: return md5(encoded).hexdigest()[-10:] -def html_visit_inheritance_diagram(self: HTMLTranslator, node: inheritance_diagram) -> None: +def html_visit_inheritance_diagram(self: HTML5Translator, node: inheritance_diagram) -> None: """ Output the graph for HTML. This will insert a PNG with clickable image map. diff --git a/sphinx/ext/mathjax.py b/sphinx/ext/mathjax.py index e87e9ea64..9bdfc09c2 100644 --- a/sphinx/ext/mathjax.py +++ b/sphinx/ext/mathjax.py @@ -16,7 +16,7 @@ from sphinx.domains.math import MathDomain from sphinx.errors import ExtensionError from sphinx.locale import _ from sphinx.util.math import get_node_equation_number -from sphinx.writers.html import HTMLTranslator +from sphinx.writers.html import HTML5Translator # more information for mathjax secure url is here: # https://docs.mathjax.org/en/latest/start.html#secure-access-to-the-cdn @@ -25,7 +25,7 @@ MATHJAX_URL = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js' logger = sphinx.util.logging.getLogger(__name__) -def html_visit_math(self: HTMLTranslator, node: nodes.math) -> None: +def html_visit_math(self: HTML5Translator, node: nodes.math) -> None: self.body.append(self.starttag(node, 'span', '', CLASS='math notranslate nohighlight')) self.body.append(self.builder.config.mathjax_inline[0] + self.encode(node.astext()) + @@ -33,7 +33,7 @@ def html_visit_math(self: HTMLTranslator, node: nodes.math) -> None: raise nodes.SkipNode -def html_visit_displaymath(self: HTMLTranslator, node: nodes.math_block) -> None: +def html_visit_displaymath(self: HTML5Translator, node: nodes.math_block) -> None: self.body.append(self.starttag(node, 'div', CLASS='math notranslate nohighlight')) if node['nowrap']: self.body.append(self.encode(node.astext())) diff --git a/sphinx/ext/todo.py b/sphinx/ext/todo.py index 79d1c0734..e35cbdba4 100644 --- a/sphinx/ext/todo.py +++ b/sphinx/ext/todo.py @@ -22,7 +22,7 @@ from sphinx.locale import _, __ from sphinx.util import logging, texescape from sphinx.util.docutils import SphinxDirective, new_document from sphinx.util.typing import OptionSpec -from sphinx.writers.html import HTMLTranslator +from sphinx.writers.html import HTML5Translator from sphinx.writers.latex import LaTeXTranslator logger = logging.getLogger(__name__) @@ -188,14 +188,14 @@ class TodoListProcessor: self.document.remove(todo) -def visit_todo_node(self: HTMLTranslator, node: todo_node) -> None: +def visit_todo_node(self: HTML5Translator, node: todo_node) -> None: if self.config.todo_include_todos: self.visit_admonition(node) else: raise nodes.SkipNode -def depart_todo_node(self: HTMLTranslator, node: todo_node) -> None: +def depart_todo_node(self: HTML5Translator, node: todo_node) -> None: self.depart_admonition(node) diff --git a/sphinx/util/math.py b/sphinx/util/math.py index 0c42a00ed..121c606c5 100644 --- a/sphinx/util/math.py +++ b/sphinx/util/math.py @@ -4,10 +4,10 @@ from typing import Optional from docutils import nodes -from sphinx.builders.html import HTMLTranslator +from sphinx.builders.html import HTML5Translator -def get_node_equation_number(writer: HTMLTranslator, node: nodes.math_block) -> str: +def get_node_equation_number(writer: HTML5Translator, node: nodes.math_block) -> str: if writer.builder.config.math_numfig and writer.builder.config.numfig: figtype = 'displaymath' if writer.builder.name == 'singlehtml': diff --git a/sphinx/writers/_html4.py b/sphinx/writers/_html4.py new file mode 100644 index 000000000..4ff2ebd3a --- /dev/null +++ b/sphinx/writers/_html4.py @@ -0,0 +1,856 @@ +"""Frozen HTML 4 translator.""" + +import os +import posixpath +import re +import urllib.parse +from typing import TYPE_CHECKING, Iterable, Optional, Tuple, cast + +from docutils import nodes +from docutils.nodes import Element, Node, Text +from docutils.writers.html4css1 import HTMLTranslator as BaseTranslator + +from sphinx import addnodes +from sphinx.builders import Builder +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 sphinx.builders.html import StandaloneHTMLBuilder + + +logger = logging.getLogger(__name__) + + +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 + elif scale == 100: + return length + else: + amount, unit = matched.groups() + result = float(amount) * scale / 100 + return "%s%s" % (int(result), unit) + + +# RemovedInSphinx70Warning +class HTML4Translator(SphinxTranslator, BaseTranslator): + """ + Our custom HTML translator. + """ + + builder: "StandaloneHTMLBuilder" + + 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, _('Permalink 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, _('Permalink 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, 'code', '')) + + 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, 'code', '')) + + 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_desc_parameterlist(self, node: Element) -> None: + self.body.append('(') + self.first_param = 1 + self.optional_param_level = 0 + # How many required parameters are left. + self.required_params_left = sum([isinstance(c, addnodes.desc_parameter) + for c in node.children]) + self.param_separator = node.child_text_separator + + def depart_desc_parameterlist(self, node: Element) -> None: + self.body.append(')') + + # 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: + if self.first_param: + self.first_param = 0 + elif not self.required_params_left: + self.body.append(self.param_separator) + if self.optional_param_level == 0: + self.required_params_left -= 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('') + if self.required_params_left: + self.body.append(self.param_separator) + + def visit_desc_optional(self, node: Element) -> None: + self.optional_param_level += 1 + self.body.append('[') + + def depart_desc_optional(self, node: Element) -> None: + self.optional_param_level -= 1 + self.body.append(']') + + 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) + 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 + 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])) + self.set_first_last(node) + + def depart_admonition(self, node: Optional[Element] = 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) -> Optional[Tuple[int, ...]]: + if node.get('secnumber'): + return node['secnumber'] + elif isinstance(node.parent, nodes.section): + if self.builder.name == 'singlehtml': + docname = self.docnames[-1] + anchorname = "%s/#%s" % (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 = "%s/%s" % (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: + if node['ids'] and self.config.html_permalinks and self.builder.add_permalinks: + format = '%s' + self.body.append(format % (node['ids'][0], title, + self.config.html_permalinks_icon)) + + def generate_targets_for_listing(self, node: Element) -> None: + """Generate hyperlink targets for listings. + + Original visit_bullet_list(), visit_definition_list() and visit_enumerated_list() + generates hyperlink targets inside listing tags (