Metadata-Version: 2.4
Name: mocodraw
Version: 0.2.0
Summary: Draw entity-relationship diagrams (MCD) from a text description, as self-contained SVG
Author: Chenghao Wang
License-Expression: GPL-3.0-or-later
Keywords: entity-relationship,database,diagram,svg,merise,mcd,crow-foot,mocodo
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Education
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: Education
Classifier: Topic :: Scientific/Engineering :: Visualization
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: png
Requires-Dist: cairosvg>=2.7; extra == "png"
Provides-Extra: view
Requires-Dist: cairosvg>=2.7; extra == "view"
Requires-Dist: matplotlib>=3.7; extra == "view"
Provides-Extra: reference
Requires-Dist: mocodo<5,>=4.3; extra == "reference"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: fontTools>=4; extra == "dev"
Requires-Dist: build>=1; extra == "dev"
Requires-Dist: twine>=5; extra == "dev"
Requires-Dist: trove-classifiers>=2024; extra == "dev"
Dynamic: license-file

# mocodraw

**English** · [Français](README.fr.md)

Draw entity-relationship diagrams from a text description.

```text
CLIENT: Réf. client, Nom, Prénom, Adresse
PASSER, 0N CLIENT, 11 COMMANDE
COMMANDE: Num commande, Date, Montant
```

```python
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](https://www.mocodo.net) 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

```bash
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

```bash
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:

```python
import mocodraw
```

or from the command line:

```bash
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.

```bash
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:

```bash
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:

```python
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:

```python
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:

```python
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=True` raises.
  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:

```python
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:

```python
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:

```bash
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:

```bash
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

```python
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:

```python
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.

```python
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:

```python
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 written `0_`.
- **`Entity(..., weak=True)`** — optional. It finds the `1,1` leg 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 and `Schema.warnings` says why.
- **`Association(name, legs, attributes)`** — a leg is `(entity, direction, cardinality)`, a
  `Leg` object, or a shorter tuple. `identifying=True` underlines the cardinality, and must
  be `1,1`, because that is the only cardinality Mocodo will underline.
- **`direction`** is `right`, `left`, `top` or `bottom`: 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**:

```bash
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):

```bash
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`, `xinnian` and
  `trebuchet` presets, 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.json`
  **exactly**, except for a handful of characters that the system fonts do not contain
  (recorded in `mocodraw/_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.svg` output;
* `--shapes` defaults to `arial` rather than Mocodo's `copperplate`, 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.

```text
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

```bash
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.
