Example: a reStructuredText renderer#

Technique: tokens, handlers and join_item. reStructuredText (reST) is the markup of Sphinx and docutils. Its math is LaTeX, so this renderer starts from LatexRenderer and keeps all of its math. What changes is the text around the math: headings, paragraphs, imports, how math groups are wrapped, and how block bodies are indented.

The code#

"""
A reStructuredText renderer: LaTeX math in ``.. math::`` directives, reST
headings, and indented block bodies. Built on LatexRenderer by changing its
tokens, a few handlers and the join step.
"""
import re

from handcalcs.renderers import LatexRenderer

INDENT = "   "
UNDERLINES = "=-~^\""
# The inherited block headers ("Since $...$ is True:") use LaTeX inline math.
INLINE_MATH = re.compile(r"\$(.+?)\$")


class RstRenderer(LatexRenderer):
    name = "rst"

    # Inside a math group the rows are plain LaTeX; ``math_group`` below wraps
    # them in the directive.
    MATH_OPEN = "\\begin{aligned}"
    MATH_CLOSE = "\\end{aligned}"
    MATH_SKIP = ""
    PARAGRAPH = ""
    LINE_BREAK = "|"  # a reST line block with one empty line
    # A .rst file needs nothing around the output (not LaTeX's document).
    preamble = ""
    postamble = ""

    def math_group(self, rows):
        body = self.ROW_SEP.join(rows)
        lines = [self.MATH_OPEN, *body.split("\n"), self.MATH_CLOSE]
        return ".. math::\n\n" + "\n".join(INDENT + line for line in lines)

    def paragraph(self, text):
        return text

    def join_item(self, item, depth, base_context):
        """A block body becomes an indented (block-quoted) paragraph group."""
        if item and isinstance(item, list) and isinstance(item[-1], list):
            header, body = item[0], item[-1]
            is_math = bool(body) and body[0] == self.MATH_OPEN
            if not is_math and isinstance(header, str) and header.strip():
                inner = "".join(self.join_items(body, depth + 1, base_context))
                indented = "\n".join(
                    INDENT + line if line else line for line in inner.strip("\n").split("\n")
                )
                header = INLINE_MATH.sub(r":math:`\1`", header)
                return [f"{header}\n\n", f"{indented}\n\n"]
        return super().join_item(item, depth, base_context)

    def complete(self, tree, base_context):
        return self.join(tree, base_context).strip() + "\n"


@RstRenderer.register("heading")
def rst_heading(renderer, node, base_context):
    underline = UNDERLINES[min(node.heading_level, len(UNDERLINES)) - 1]
    return f"{node.content}\n{underline * len(node.content)}"


@RstRenderer.register("comment_line")
def rst_comment_line(renderer, node, base_context):
    return node.content


@RstRenderer.register("import")
def rst_import(renderer, node, base_context):
    names = ", ".join(name.identifier for name in node.names)
    if node.import_from:
        return f"``from {node.import_from_module} import {names}``"
    return f"``import {names}``"

The result#

## Web check
# The web must be at least class 2.
h_w = 310; t_w = 6; F_y = 350
x_1 = h_w / t_w  # slenderness
if h_w / t_w <= 1100 / sqrt(F_y):
    web_class = 1
Output (RstRenderer)

Web check

The web must be at least class 2.

.. math::

\begin{aligned} h_{w} &= 310 ; &t_{w} &= 6 ; &F_{y} &= 350 ; \end{aligned}

.. math::

\begin{aligned} x_{1} &= \frac{h_{w}}{t_{w}} = \frac{310}{6} = 51.667 \ &\quad\text{(slenderness)} \end{aligned}

Since :math:\bigl(\frac{h_{w}}{t_{w}} \le \frac{1100}{\sqrt{F_{y}}}\bigr) \rightarrow \bigl(\frac{310}{6} \le \frac{1100}{\sqrt{350}}\bigr) is True:

.. math::

\begin{aligned} \mathrm{web}_{\mathrm{class}} &= 1 \end{aligned}

Web check
=========

The web must be at least class 2.

.. math::

   \begin{aligned}
   h_{w} &= 310 \; &t_{w} &= 6 \; &F_{y} &= 350 \;
   \end{aligned}

.. math::

   \begin{aligned}
   x_{1} &= \frac{h_{w}}{t_{w}} = \frac{310}{6} = 51.667 \\
   &\quad\text{(slenderness)}
   \end{aligned}

Since :math:`\bigl(\frac{h_{w}}{t_{w}} \le \frac{1100}{\sqrt{F_{y}}}\bigr) \rightarrow \bigl(\frac{310}{6} \le \frac{1100}{\sqrt{350}}\bigr)` is True:

   .. math::

      \begin{aligned}
      \mathrm{web}_{\mathrm{class}} &= 1
      \end{aligned}

How it works#

The changes fall into the three levers, in order of size.

Tokens. MATH_SKIP, PARAGRAPH and LINE_BREAK replace LaTeX commands (\medskip, \noindent) with reST equivalents. MATH_OPEN and MATH_CLOSE become the bare aligned environment; they also act as markers that LatexRenderer.join_item uses to recognise a math group, so they must stay distinct strings.

Handlers. heading, comment_line and import are replaced, because LaTeX’s versions emit \section* and escape text for LaTeX. The math handlers (names, operators, constants) are all inherited.

Methods.

  • math_group wraps a group’s rows in a .. math:: directive and indents them, which reST requires.

  • paragraph returns text unchanged (LaTeX adds \noindent).

  • join_item handles the one shape that differs: a block with a header (an if or for). reST indents a block body as a block quote, so the method joins the body one level deeper, indents every line, and converts the header’s inline $...$ math (from the inherited header:if_block handler) to the :math: role. Every other item goes to LatexRenderer.join_item.

  • complete strips the surrounding blank lines.

  • preamble and postamble are emptied, so a file written with destination= holds only the reST, not the LaTeX document the parent class writes.

Checking the output#

reST is strict about indentation and blank lines, so check the output with docutils:

import docutils.core

docutils.core.publish_string(rst_text, writer_name="html",
                             settings_overrides={"halt_level": 2})

halt_level=2 turns warnings into exceptions, which makes this a one-line test. See Testing your renderer.

Next: Example: structured data.