mocodraw
English · Français
Draw entity-relationship diagrams from a text description.
CLIENT: Réf. client, Nom, Prénom, Adresse
PASSER, 0N CLIENT, 11 COMMANDE
COMMANDE: Num commande, Date, Montant
import mocodraw
print(mocodraw.preview(source)) # see it in the terminal
svg = mocodraw.render(source) # get the SVG
mocodraw.render_to_file(source, "shop.svg", png=True)
The input language is the one Mocodo uses. This package is an independent implementation of that language and of the drawing it produces: the layout and the rendered output are validated against Mocodo itself, element by element.
Nothing beyond the standard library is required to parse, lay out and draw. PNG output is the single optional extra.
See it work first
python examples/demo_en.py # a guided tour, English
python examples/demo_fr.py # the same tour, French
python examples/gallery.py # every example model at once
The tour walks through parsing, the ASCII preview, how the layout follows the source, how
a cycle forces a second row, styles, the whole input language, and the errors the library
produces. It prints to the terminal and writes examples/out/demo_en.html (or
demo_fr.html) with the same walkthrough and the real SVG inlined, so the diagrams can be
looked at. gallery.py writes examples/out/gallery.html the same way.
Install
pip install mocodraw # from PyPI
pip install mocodraw[png] # ... with PNG output (also needs the cairo library)
Until it is published, install from a checkout with pip install ..
Then, either as a library:
import mocodraw
or from the command line:
mocodraw model.mcd # writes model.svg
mocodraw model.mcd --preview # prints the diagram to the terminal
Raster output
--png — or write(..., png=True) — also writes a PNG beside the SVG, and --png-scale 3
makes it large enough for a printed handout or a projector. This is the package's one
optional extra: it needs cairosvg and the cairo library, and nothing else here needs
either.
pip install 'mocodraw[png]' # the cairosvg part
brew install cairo # macOS; apt install libcairo2 on Debian/Ubuntu
A Homebrew or MacPorts cairo is found without any environment variable, which is otherwise the usual snag on macOS. If cairo cannot be installed at all, the SVG is plain and self-contained, so any converter will do:
rsvg-convert -w 1600 model.svg -o model.png # librsvg
resvg --width 1600 model.svg model.png # resvg
inkscape --export-width=1600 model.svg # Inkscape
Showing it
matplotlib cannot read an SVG -- imread renders rasters -- so plt.show() needs the
PNG. In a notebook the SVG goes straight in, with no extra dependency at all:
from IPython.display import SVG, display
display(SVG(schema.render())) # or mocodraw.render(source)
In a script -- or in a notebook, if you would rather have a figure than the notebook's own rendering -- one call does it:
schema.show() # a matplotlib figure, sized to the diagram
schema.show(width=14, scale=3) # a wider figure, rasterised three times the size
That needs pip install 'mocodraw[view]': matplotlib to draw with, and cairosvg because
matplotlib reads rasters rather than SVG. show() returns the axes, so a title or a
caption can be added to it. If you would rather build the figure yourself, the two steps
behind it are these:
import io, webbrowser
import matplotlib.pyplot as plt
import mocodraw
schema.write("shop.svg")
webbrowser.open("shop.svg") # the simplest thing of all
png = mocodraw.svg_to_png_bytes(schema.render(), scale=2) # bytes, no file in between
plt.imshow(plt.imread(io.BytesIO(png)))
plt.axis("off")
plt.show()
In the browser (Pyodide)
Everything except the rasteriser runs unchanged under Pyodide: it is pure Python, and
cairosvg is imported lazily, so render(), preview(), to_mcd() and parse() all
work. Two things are different, and they have the same cause -- a browser tab is not a
computer.
- PNG needs cairo, which is a C library. Pyodide cannot load it, so
png=Trueraises. The SVG is unaffected, and the browser can rasterise it itself. - Files live in memory.
write("shop.svg")succeeds, but the file is in the interpreter's virtual filesystem, not on your disk, so nothing appears in Downloads until you hand the bytes to the browser:
import js
from pyodide.ffi import to_js # older Pyodide: from pyodide import to_js
svg = schema.render()
blob = js.Blob.new(to_js([svg]), to_js({"type": "image/svg+xml"}))
url = js.URL.createObjectURL(blob)
anchor = js.document.createElement("a")
anchor.href = url
anchor.download = "shop.svg"
anchor.click()
js.URL.revokeObjectURL(url)
For a PNG, let the browser convert it -- it already knows how, and it needs nothing installed:
import base64, js
from pyodide.ffi import create_proxy
data = "data:image/svg+xml;base64," + base64.b64encode(schema.render().encode()).decode()
image = js.Image.new()
canvas = js.document.createElement("canvas")
def on_load(event):
canvas.width, canvas.height = image.width * 2, image.height * 2
context = canvas.getContext("2d")
context.scale(2, 2)
context.drawImage(image, 0, 0)
anchor = js.document.createElement("a")
anchor.href = canvas.toDataURL("image/png")
anchor.download = "shop.png"
anchor.click()
image.onload = create_proxy(on_load)
image.src = data
If that is more machinery than the moment calls for, run the same script locally, where
pip install 'mocodraw[png]' makes it one line.
Single file
dist/mocodraw_standalone.py is the whole package concatenated into one importable,
runnable file, for places where nothing can be installed — an online Python service, a
locked-down machine, a code snippet:
python dist/mocodraw_standalone.py # prints a diagram
dist/mocodraw_selfcheck.py is the same bundle followed by a self-test of the parser,
the layout, the renderer, the object layer, every style preset and every error path. It is what to paste
into an online playground to check the library works in a bare runtime.
Both are generated; regenerate with:
python tools/bundle.py --mode demo --out dist/mocodraw_standalone.py
python tools/bundle.py --mode selfcheck --out dist/mocodraw_selfcheck.py
Verified on online-python.com (stock CPython, no packages installed); the screenshot is
in the repository at docs/online-python-selfcheck.png.
The input language
Each line is one of:
| Line | Meaning |
|---|---|
NAME: attr, attr |
an entity |
NAME, 0N A, 11 B: attr |
an association, with an optional attribute |
: :: |
an empty cell, to align rows |
% text |
a comment |
/XT\ P <= C1, C2 |
an inheritance |
(CIF) A, B: 50, 20 |
a constraint |
| (blank line) | starts a new row of the grid |
The layout is decided entirely by which line comes where: the order of lines is the order of boxes, a blank line starts a new row, and indentation puts boxes on successive pages. There is no coordinate syntax, which is what makes a model reviewable as a diff.
Attributes
| Written | Means |
|---|---|
name |
a plain attribute |
Réf. client [VARCHAR(50)] |
with a datatype (kept, not drawn in the MCD) |
#customerId > CUSTOMER > customerId |
a foreign key: drawn as #customerId, plus a link to the referenced attribute |
_name first |
not the identifier (the first attribute is one by default) |
0_name |
adds a component to the main identifier |
1_name, 2_other |
alternative identifier groups, numbered in a gutter column |
Legs
| Written | Means |
|---|---|
0N ENTITY |
cardinality 0,N |
0N< ENTITY |
with an arrow head |
-11 ENTITY |
cardinality hidden |
_11 ENTITY |
a strengthening leg: makes the entity weak (dashed underline) |
/11 ENTITY |
a cluster peg |
0N [a note] ENTITY |
a note shown on hover |
Library
import mocodraw
diagram = mocodraw.Diagram.from_source(source, colors="ocean", shapes="arial", scale=1.5)
diagram.model # entities, associations, inheritances, constraints
diagram.geometry # canvas size, box centres, per-leg tweaks
diagram.geometry_dict() # the same, as plain JSON-serialisable data
diagram.svg() # the SVG document
diagram.preview() # ASCII art
diagram.overlaps() # legs drawn on top of a box, if any
diagram.write("out.svg", png=True)
Convenience wrappers for the single-call cases:
mocodraw.render(source, **options) # -> str
mocodraw.render_to_file(source, "out.svg") # -> [Path]
mocodraw.preview(source) # -> str
mocodraw.parse(source) # -> Model
Options
| Option | Default | Meaning |
|---|---|---|
colors |
"ocean" |
a palette name, a dict, or a path to a palette JSON file |
shapes |
"arial" |
a shape preset: arial, copperplate, georgia, mondrian, sans, serif, times, trebuchet, verdana, xinnian |
scale |
1.0 |
multiplies every geometry constant and font size |
adjust_width |
1.0 |
fudges text measurement only |
id_gutter |
"auto" |
"on", "off"; "auto" shows the identifier gutter only when an entity has alternative identifiers |
df_label |
"DF" |
the name that marks a dependency box |
card_format |
"{min_card},{max_card}" |
how cardinalities are written |
strengthen_card |
"_1,1_" |
the cardinality drawn on a strengthening leg |
strong_id_label / weak_id_label |
"ID" / "id" |
the gutter labels for strong and weak identifiers |
flex |
0.75 |
how eagerly a leg is curved when its label would be hidden; 0 disables it |
notes |
True |
emit the hover-note machinery |
Any further keyword is written straight into the style, e.g. margin=30,
entity_color="#ffffff", box_stroke_depth=2.
mocodraw.available_palettes() and mocodraw.available_shapes() list what is built in.
Build a model in Python
Writing .mcd by hand suits a small model; a program that generates diagrams wants typed
objects. Schema is that layer, and it writes the Mocodo source for you.
from mocodraw import Association, Entity, Schema
schema = Schema(
Entity("CLIENT", "customerId", ["lastName", "firstName", "city"]),
Association("PLACE", [
("CLIENT", "right", "0,N"), # (entity, direction, cardinality)
("ORDER_RECORD", "left", "1,1"),
]),
Entity("ORDER_RECORD", "orderId", ["orderDate", "status"]),
)
print(schema.to_mcd()) # the Mocodo source
schema.render() # straight to SVG
schema.directions() # the side each leg really leaves on
All three return what they compute, so nothing reaches the disk until you ask. The four things worth asking for:
print(schema.preview()) # see it in the terminal first
schema.write("shop.svg", png=True, png_scale=3) # both files; PNG needs the [png] extra
open("shop.mcd", "w").write(schema.to_mcd()) # keep the source, to hand-edit later
schema.diagram().overlaps() # any leg crossing a box?
What comes out is ordinary Mocodo source, so the command line works on it too:
mocodraw shop.mcd --check --preview.
Entity(name, primary_key, attributes)— the key comes first and is underlined. A composite key is a list, and every component after the first is written0_.Entity(..., weak=True)— optional. It finds the1,1leg pointing at the entity, which underlines that cardinality and switches the key from a solid rule to a dashed one. If no leg qualifies, or several do, nothing is marked andSchema.warningssays why.Association(name, legs, attributes)— a leg is(entity, direction, cardinality), aLegobject, or a shorter tuple.identifying=Trueunderlines the cardinality, and must be1,1, because that is the only cardinality Mocodo will underline.directionisright,left,toporbottom: the side of the entity the association sits on.
The direction is the interesting part. Mocodo has no coordinate syntax — a box's position,
and therefore the side a leg leaves on, follows from the order of the lines and the blank
lines between them. So to_mcd solves for a grid whose layout really does put each box
where the legs ask, emits that grid explicitly, and then checks the drawing against the
request.
Nothing blocks you, and nothing is silently dropped. The builder always produces a
diagram, and anything it could not honour is written down: layout compromises in
schema.conflicts, model remarks in schema.warnings, both echoed as % comments in the
emitted file. Call to_mcd(strict=True) when you would rather have an exception than a
best effort.
to_mcd(layout=...) decides the arrangement yourself: "single" for one row in declaration
order, or a list of rows such as [["A", None, "L", "B"]] where None is an empty cell.
Empty cells are emitted as :, so the layout is exactly the one asked for.
Writing your own model
Three entry points, and the working loop is the same for all of them.
| You have | Write | Then |
|---|---|---|
| a model you want to edit by hand | Mocodo text in a .mcd file |
mocodraw model.mcd --preview, --check, then render it |
| a model a program should generate | Entity / Association / Schema objects |
schema.to_mcd() to get the text back, or schema.render() for the SVG |
| a model already written for Mocodo | just point this library at it | mocodraw.render(source) |
The loop that keeps you out of trouble is preview, check, render:
mocodraw modele.mcd --preview # see it in the terminal, no file written
mocodraw modele.mcd --check # a leg through a box? rearrange, then retry
mocodraw modele.mcd # write modele.svg
--check is the one worth making a habit. Legs are straight segments between box centres,
so a box that happens to sit between two connected boxes will have a line running through
it; the fix is almost always a blank line, which starts a new row. When you build with
objects, the same two signals arrive as diagram.overlaps() and schema.conflicts.
Command line
mocodraw model.mcd write model.svg
mocodraw model.mcd -o out.svg --colors bw --scale 1.5
mocodraw model.mcd --preview ASCII, no file written
mocodraw model.mcd --embed-preview both
mocodraw model.mcd --check report overlapping legs, exit 1 if any
mocodraw model.mcd --geometry --preview dump the computed geometry as JSON
mocodraw model.mcd --png --png-scale 2
mocodraw - read the source from stdin
--check earns its keep: legs are routed straight between box centres, so a box that
happens to sit between two connected boxes will have a line running through it. When it
reports something, insert a blank line to break the model into more rows.
Fidelity
The layout algorithm and the rendered output are validated against Mocodo 4.3.3 itself,
not against hand-written expectations. Two tools do the comparison, and
tests/test_against_mocodo.py runs both as part of the test suite (skipped when Mocodo
is not installed):
python tools/compare_with_mocodo.py # canvas size and every box centre
python tools/compare_svg.py -v # every line, rect, circle, polygon, path, label
Current results on this machine:
- 10 reference models — chains, multi-row models with cycles, reflexive associations, inheritance, alternative identifiers, foreign keys, dependency boxes, hidden boxes, empty cells — identical, canvas and every box centre;
- every drawn primitive identical: not only the boxes and the lines, but the path data
of every rounded outline, polygon and arrow head, and the colours and font names as well;
checked for the
arial,georgia,mondrian,times,verdana,xinnianandtrebuchetpresets, for all seven palettes tried, and for scales 0.5 through 2.4; - the character widths the layout depends on agree with Mocodo's
font_metrics.jsonexactly, except for a handful of characters that the system fonts do not contain (recorded inmocodraw/_font_data.ABSENT), and for which each side falls back to its own default.
What the comparison covers, and what it once missed
This claim was once weaker than it sounded. The comparison read only attributes whose value
is a single number, which silently skipped d and points — the geometry of every rounded
outline, polygon and arrow head — and every colour. Two real bugs survived behind that:
an association's body fill hanging below its box by the height of the title band, and
inheritance arrow heads pointing at the association instead of at the entity. The first was
spotted by eye, the second while widening the comparison.
The tool now parses and compares path data, coordinate lists, colours and font names, and
tests/test_artifacts.py checks that it really notices a one-pixel change in a path, a
one-step change in a colour, and a moved polygon vertex. So the honest form of the claim is:
the output is identical as far as the comparison reaches, the comparison is itself tested,
and its blind spots have been found and closed once.
Known deviations
Three shape presets cannot match on this machine, and the tests mark them as expected failures rather than hiding them:
| Preset | Why |
|---|---|
sans, serif |
Mocodo's bundled font table has no Liberation Sans or Liberation Serif, so it silently substitutes Courier New. We measure the real fonts. |
copperplate |
Mocodo resolves Copperplate to the Light face and Futura to the condensed one; we measure the regular faces. |
All three come down to which physical font file a family name resolves to, not to the layout. On a machine whose font table includes those families, Mocodo would agree with us. There are three more deliberate differences, all cosmetic:
- no interactive hover-outside-the-canvas note card, only the in-canvas one;
- no separate
_static.svgoutput; --shapesdefaults toarialrather than Mocodo'scopperplate, because Arial is the preset whose metrics are exactly reproducible everywhere.
Cluster hulls are drawn only for three-legged clusters, and are suppressed when the
diagram contains a CIF constraint. Hulls of four or more legs need a convex-hull
routine that no classroom diagram has ever required.
How it works
There are two ways in: Mocodo source text, or the objects of mocodraw.schema, which emit
source text and then take the same path.
source text
│ parser.py lines -> clauses (entities, associations, legs, attributes)
▼
clauses
│ document.py linking, identifier rules, grid rows, pages
▼
Model
│ style.py palette + shape preset + overrides
│ metrics.py character advance widths, from _font_data
│ layout.py measure, widen columns, stack rows, compress
▼
Model + Style + Geometry
│ svg.py drawing operations -> SVG
│ preview.py drawing operations -> ASCII
▼
output
The layout is a fixed grid, which is what makes it deterministic and explainable: a
box's width comes from its longest line of text, all boxes in a column take the width of
the widest, rows are stacked with room for the cardinality labels between them, and then
over-wide columns and over-tall rows are pulled back. The same source therefore produces
the same coordinates on every machine, and tests/test_layout.py asserts that.
Boxes describe themselves as a list of backend-agnostic drawing operations
(("rect", {...})), and the SVG and ASCII renderers both consume that list. Adding a
third backend means writing one more serialiser, not touching the layout.
Text measurement is the one place where the output is not machine-independent, because
it depends on the font tables in mocodraw/_font_data.py. Those are generated from the
fonts installed on the machine that ran tools/gen_font_metrics.py; the generated file
is committed, so ordinary users never need the generator.
Development
pip install -e '.[dev]' # pytest, build, twine, fontTools
pip install -e '.[reference]' # mocodo, for the fidelity comparisons below
pytest -q # the reference comparisons skip without mocodo
python tools/gen_font_metrics.py --verify # regenerate and diff the font tables
python tools/gen_palettes.py # regenerate the colour schemes
python tools/bundle.py --mode demo # regenerate the single-file bundles
python examples/demo_en.py # regenerate the demo pages
ruff configuration and the test layout are in pyproject.toml. Releasing is documented
in docs/PUBLISHING.md. The demo's commentary
lives in one table with an English and a French column, so the two cannot drift apart; the
table is in examples/demo.py.
Licence
GPL-3.0-or-later.
The code here is an independent implementation, but the input language, the layout algorithm and the shape presets are Mocodo's, and Mocodo is GPL-3.0. Matching its behaviour as closely as this package does makes the GPL the honest choice. Mocodo itself is by Aristide Grange and is worth using directly if you want its converter targets (relational schema, SQL DDL, Chen and crow's-foot notations, layout optimiser).
The font tables in _font_data.py are measurements of the fonts installed on the
machine that generated them; advance widths are functional facts about a font, not
creative content.
Metadata
Release files for mocodraw 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mocodraw-0.2.0.tar.gz | 481.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mocodraw-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 571.5 kB
Release files / mocodraw-0.2.0.tar.gz
| Download URL | mocodraw-0.2.0.tar.gz |
|---|---|
| Size | 481.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
743ffd8d7a340713db7af0bdc3ae5d049d7b452c61b0c1f37de5c495a127494e
|
|
BLAKE2b-256 checksum How to use checksums |
585c628171cbef33abc7dce628b525e3e2a1fbd54e80561ec53a7c2f9836b3bd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.12
|
Release files / mocodraw-0.2.0-py3-none-any.whl
| Download URL | mocodraw-0.2.0-py3-none-any.whl |
|---|---|
| Size | 90.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7459770ec3e209a59190261712a160277225ff5798763104c3371b88c13ff9c9
|
|
BLAKE2b-256 checksum How to use checksums |
0f4c5e04740486424ae3915194f4e7ea1b42796004b4895b76cf7b22d92145a9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.12
|