Internals
This document presents information on the internal implementation of the Chalk library. This information should not be needed by the casual user of the library, but it can certainly be useful to developers and also provides a record of the major design decisions. While much of the functionality of the Chalk library resembles the Haskell diagrams
library, there are important distinctions in the implementation due to the differences of the two languages (Python and Haskell).
Core data types¶
Chalk is an embedded domain specific language (EDSL). The two extremes of language design are shallow and deep EDSLs. Loosely put, a shallow EDSL specifies the language through a set of functions, while a deep EDSL specifies the syntax of the language using a data type (the abstract syntax tree; AST), which is then interpreted using given evaluator functions. Chalk uses a hybrid approach which defines an intermediate core data structure (in our case, the Diagram
type) and a suite of functions that operate on this type. (For more information on the concepts of deep and shallow EDSLs, see the paper of Gibbons and Wu from ICFP'14).
The Diagram
type (implemented as BaseDiagram
in chalk/core.py
) can be thought as an algebraic data type with the following six variants: Empty
, Primitive
, Compose
, ApplyTransform
, ApplyStyle
, ApplyName
. Each of the variants may hold additional information, as follows:
class Empty(BaseDiagram):
pass
class Primitive(BaseDiagram):
shape: Shape
style: Style
transform: Affine
class Compose(BaseDiagram):
envelope: Envelope
diagram1: Diagram
diagram2: Diagram
class ApplyTransform(BaseDiagram):
transform: Affine
diagram: Diagram
class ApplyStyle(BaseDiagram):
style: Style
diagram: Diagram
class ApplyName(BaseDiagram):
dname: str
diagram: Diagram
Instances of BaseDiagram
can be constructed and modified using the functions provided by the library. For example,
circle(1)
Primitive(shape=Path(...), style=Style(...), transform=Affine(...))
circle(1) | circle(1)
Compose(
envelope=...,
diagram1=Primitive(shape=Path(...), style=Style(...), transform=Affine(...)),
diagram2=Primitive(shape=Path(...), style=Style(...), transform=Affine(...)),
)
Diagram
AST can be interpreted in multiple ways, arguably the most obvious being through the rendering functions (see the chalk/backend
submodule); other interpretations are flattening the Diagram
AST to a list of Primitive
s or extracting the Envelope
or Trace
of a Diagram
. All these functions are implemented using the visitor pattern, which is the object-oriented correspondent of pattern matching and folding encountered in functional programming. (Jeremy Gibbons provides a nice exposition on the relationship between object-oriented design patterns and their functional counterparts in his paper Design Patterns as Higher-Order Datatype-Generic Programs). Support for functional and object-oriented use¶
Internally the library is implemented in a functional style, through functions that operate on the Diagram
AST. For example, for composition we have a combinator function beside
(in chalk/combinators.py
) that takes two Diagram
s and returns a new Diagram
corresponding to their composition. The main benefit of using functions is that the library can be easily split into self-contained submodules, each pertaining to a certain type of functionality (shapes, alignment, transformations, and so on). In contrast, using an object-oriented style would bundle the entire functionality as methods inside the class and would only allow to separate the variants (e.g., Empty
, Primitive
, Compose
) across files (see the "expression problem" for the trade-offs between the functional and object-oriented style).
However, allowing to write code in an object-oriented style (using dot notation) provides an arguably more convenient and idiomatic style in Python. For this reason, we attach all the functions as methods in the chalk/core.py
file; for example:
class BaseDiagram:
beside = chalk.combinators.beside
circle(1).beside(circle(1), unit_x)
circle(1) | circle(1)
__or__
in this case). The challenge of this implementation decision is that it complicates the type checking due to circular imports. We solve this problem by introducing a Diagram
protocol (in chalk/types.py
), which specifies type signatures for all the methods that are to be implemented later on. The Diagram
type is used throughout the code for type hinting, for example:
def juxtapose(self: Diagram, other: Diagram, direction: V2) -> Diagram:
# We can use `get_envelope` here since the `Diagram` protocol promises
# that such a method will be implemented.
Diagram
protocol is provided by the BaseDiagram
in chalk/core.py
. Trail-like data types¶
Apart from the main Diagram
data type, there are several other related types (Trail
, Located
, Path
) that encode a trail-like drawing and can be "lifted" to a Diagram
using the stroke
method. These trail-like structures encode different types of information and, as a consequence, have different combination semantics:
Trail
corresponds to a list of vectors (translation-invariant offsets, which can be either straight or bendy—implemented as arcs). ATrail
isTransformable
(by transforming each of the vectors), but since vectors are translation-invariant, applyingtranslate
leaves aTrail
unchanged. The monoid composition corresponds to the list monoid: it extends the first trail with the second one. ATrail
can be closed which means that it is a loop and it will be able to hold a color when filled in.Located
is aTrail
paired with alocation
origin point. ATrail
can be turned intoLocated
using theat
method which specifies the origin location.Located
instances do not form a monoid.Path
is a list ofLocated
instances. Having more than oneLocated
instance is important, since it allows to easily draw objects with holes in them (such as rings). The monoid composition also corresponds to the list monoid, but the effects is different fromTrail
: it overlays theLocated
subpaths.
Below we present an example (inspired from the diagrams
library) that showcases the distinction of the combination semantics (the concat
function) for the Diagram
, Path
, Trail
types.
from colour import Color
from toolz import iterate, take
from chalk import *
red = Color("red")
t = Trail.regular_polygon(3, 1)
t_loc = t.centered()
# Diagram
dia1 = concat(take(3, iterate(lambda d: d.rotate_by(1 / 9), t_loc.stroke()))).fill_color(red)
# Path
dia2 = Path.concat(take(3, iterate(lambda d: d.rotate_by(1 / 9), t_loc.to_path()))).stroke().fill_color(red)
# Trail
dia3 = Trail.concat(take(3, iterate(lambda d: d.rotate_by(1 / 9), t))).stroke().center_xy()
hcat([dia1, dia2, dia3], sep=0.2)