Example: structured data#
Technique: new handlers and a new complete(). Not every renderer produces text. This
one produces a list of dictionaries, one per heading, paragraph, calculation or block, so
a web front-end (or a template engine, or a database) can lay the calculation out itself.
It keeps the Unicode symbol rules of ASCIIRenderer for the strings inside each
entry.
The code#
"""
A renderer whose output is data, not text: a list of dicts that a web front-end
(or a template) can lay out however it likes. It keeps the Unicode symbol rules
of ASCIIRenderer and replaces the line handlers and complete().
"""
from handcalcs.renderers import ASCIIRenderer
from handcalcs.renderers.base import RenderContext
class DataRenderer(ASCIIRenderer):
name = "data"
def complete(self, tree, base_context):
"""Flatten the render tree into a list of dicts."""
return list(self._entries(tree))
def _entries(self, items):
for item in items:
if not item:
continue # an ignored line, a command, a spacer
if isinstance(item, dict):
yield item
elif isinstance(item, str):
yield {"kind": "text", "text": item}
elif all(isinstance(part, str) for part in item): # a line's parts
yield {"kind": "text", "text": " ".join(item)}
elif isinstance(item[-1], list): # a block: [header, body]
header, body = item[0], item[-1]
body = list(self._entries(body))
if header.strip():
yield {"kind": "block", "header": header, "body": body}
else:
yield from body # a headerless group of lines
def columns(renderer, nodes, mode, base_context):
base_context.line_context.current_mode = mode
return ", ".join(renderer.render(node, base_context) for node in nodes)
@DataRenderer.register("calc_line")
def calc_line(renderer, node, base_context):
# Render the comment first: an inline ``# hc:`` command sets the line context.
comment = renderer.render(node.comment, base_context) if node.comment else ""
context = base_context.current
if getattr(context, "ignore", False):
base_context.line_context = RenderContext.sparse()
return None
entry = {
"kind": "param" if getattr(context, "param_line", False) else "calc",
"target": columns(renderer, node.assigns, "sym", base_context),
"result": columns(renderer, node.assigns, "num", base_context),
"comment": comment,
}
if entry["kind"] == "calc":
base_context.line_context.current_mode = "sym"
entry["symbolic"] = "".join(renderer.render(n, base_context) for n in node.expression_tree)
base_context.line_context.current_mode = "num"
entry["numeric"] = "".join(renderer.render(n, base_context) for n in node.expression_tree)
base_context.line_context = RenderContext.sparse() # end of the line's overrides
return entry
@DataRenderer.register("params_block")
def params_block(renderer, node, base_context):
cells = []
for line in node.lines:
base_context.line_context.param_line = True
cells.append(renderer.render(line, base_context))
return {"kind": "params", "cells": [cell for cell in cells if cell]}
@DataRenderer.register("heading")
def heading(renderer, node, base_context):
return {"kind": "heading", "level": node.heading_level, "text": node.content}
@DataRenderer.register("inline_comment")
def inline_comment(renderer, node, base_context):
return node.content
The result#
## Web check
# The web must be at least class 2.
h_w = 310; t_w = 6
x_1 = h_w / t_w # slenderness
if x_1 <= 60:
web_class = 1
[{'kind': 'heading', 'level': 1, 'text': 'Web check'},
{'kind': 'text', 'text': 'The web must be at least class 2.'},
{'cells': [{'comment': '', 'kind': 'param', 'result': '310', 'target': 'h_w'},
{'comment': '', 'kind': 'param', 'result': '6', 'target': 't_w'}],
'kind': 'params'},
{'comment': 'slenderness',
'kind': 'calc',
'numeric': '310 / 6',
'result': '51.667',
'symbolic': 'h_w / t_w',
'target': 'x_1'},
{'body': [{'comment': '', 'kind': 'param', 'result': '1', 'target': 'web_class'}],
'header': 'Since (x_1<=60) -> (51.667<=60) is True:',
'kind': 'block'}]
json.dumps(result) turns it into JSON.
How it works#
calc_line replaces the built-in handler, which returns a list of parts, with one
that returns a dict. It follows the same steps as the built-in
render_calcline:
Render the comment first. An inline
# hc:command sets the line context, and that has to happen before the rest of the line is rendered.Honour
ignore(# hc: -i) andparam_line(set by the inheritedcalc_line:prerule for parameters).Render each step with
current_modeset: targets and the symbolic step in"sym", the numeric step and the result in"num".Reset the line context to empty, so this line’s overrides don’t leak to the next.
params_block renders each line as a parameter by setting param_line on the line
context, as the built-in handler does, and returns the cells directly instead of a grid.
heading and inline_comment return the raw content. (The inherited pre rule
on comments still runs first, so {{ }} values are filled in.)
complete walks the render tree. Every built-in block handler still returns
[header, body], and the comment-line handler still returns a string, so complete
handles the three tree shapes as well as the dicts.
Variations#
Return the numbers as well as the strings: read
node.assigns[0].valuein thecalc_linehandler.Produce XML by building
xml.etree.ElementTreeelements in the handlers and serialising them incomplete.Write straight to a file or a service in
complete, as the Excel renderer does with itsfilepathoption.Keep markup strings in the handlers and parse them only in
complete, as the Word renderer does. Its handlers return fragments of Word’s math markup (OMML) the way the LaTeX handlers return LaTeX, so the base line and block handlers concatenate them unchanged. Brackets are the two halves of one element (thelpar/rparcontext fields), always used as a pair. A few handlers return small objects (a heading, a params table) that itscompletewalks into a python-docx document (WordRenderer.write_item).
Next: Testing your renderer.