Skip to content
Ferrigura

Ferrigura / Diagrams

C4 diagram

A C4 diagram draws the C4 model of a software system: persons, systems, containers and components as boxes grouped in boundaries, and relationships between them. The five keywords are C4Context, C4Container, C4Component, C4Dynamic and C4Deployment; they share one grammar, one DB and one renderer, and the keyword only sets the type, which the renderer reads once, to number the relations of C4Dynamic. ferrigura reads it with a hand-written scanner and parser of the Jison grammar (ferrigura-syntax, src/c4) and draws it with a renderer of its own (ferrigura-render, src/c4); the layout is C4's own grid, not a layout engine, and the shapes come from the unified renderer's person, cylinder and queue.

Model

ferrigura_model::c4::C4Diagram is the DB state after the parse (c4Db.ts), version 1:

FieldHolds
c4TypeThe header keyword, sanitized
titleThe last title line, or the last accTitle (the reference stores an accessibility title as the title)
accTitleAlways "": the C4 parser never sets it
accDescrThe description of accDescr, "" if none
c4ShapeInRow, c4BoundaryInRowThe row counts, 4 and 2 unless UpdateLayoutConfig sets them; parseInt results, whole numbers or NaN
boundariesEvery boundary in the DB's order; the first is the global boundary
shapesEvery person, system, container and component in declaration order
relsEvery relation in the order it was first given

A shape is { alias, typeC4Shape, label, descr, techn, sprite, tags, link, parentBoundary, wrap, … } with the fields of UpdateElementStyle (bgColor, fontColor, borderColor, shadowing, shape, legendText, legendSprite); a boundary has the same fields with type and, for a deployment node, nodeType; a relation is { type, from, to, label, techn, descr, sprite, tags, link, wrap, textColor, lineColor, offsetX, offsetY }. The model keeps the DB's quirks:

  • A text field is a string or absent. A { text } field is its string. A field that was never set, or that a style statement overwrote with a bare string, is left out, since the reference reads .text of such a value as undefined; a flag keeps descr and techn apart in the parser, because ??= keeps a bare string.
  • A label may be an object. A named argument in the label slot (Person(a, $sprite="x")) is stored by the reference as the text, and the model writes that one-entry object.
  • Offsets are strings: the String() of the DB value, so a positional offset is its parseInt result ("5", "-10", "NaN") and a named one is its raw text; the renderer applies parseInt again.
  • The global boundary's tags and link are null; an absent value is left out.
  • Texts keep the entity placeholders (fl°name¶ß). Only the title and the type go through the sanitizer.
  • A re-declared alias is one element that keeps every field the new declaration does not set, takes the new kind, and takes the current boundary as its parent. Relations are keyed on (from, to): Rel and BiRel on one pair merge into one relation of the last type.
  • The front matter title does not reach the diagram: the C4 DB has no title setter.
  • Named keys with no field of their own are kept in extra as strings, or as { text } for a text key on a shape, so the DB state is complete.

Left out are the layout fields, the parser's stack and current boundary, and the common DB's accessibility title. The conventions of the whole family are in canonical-model.md.

Parsing

c4::parse runs the steps of Diagram.fromText: it prepares the text (front matter), removes directives and comment lines, trims the start, encodes entities and adds a line break. The scanner and the grammar call the DB as each statement is reduced, in document order; a boundary calls its DB function before its body and pops the boundary stack after its } (0007). c4::parse_with takes the top-level wrap setting as an option, which the DB stores on each element.

The scanner reproduces Jison's lexer: exclusive start states, the first rule that matches wins, and a keyword literal has a word boundary. A keyword begins a structure state that runs until the closing ); inside it the attribute state reads quoted strings, $key="value" pairs, empty arguments, and unquoted text. Jison's end-of-input behavior is kept: once the input is empty the next call reads the rules of the current state at the end, and afterwards returns the raw end symbol, which the grammar rejects.

The quirks that ferrigura keeps because the reference has them, each covered by a case in tools/port/cases.ts or a spec test:

  • An unquoted argument runs to the next comma, over ) and line breaks. Rel(a, b) followed by a line makes the target b)\nSystem(c and swallows the next statement; at the end of the text it is an error. System(a) is an error for the same reason, and an unquoted last argument is an error.
  • A space before ( is a lexical error. System(a, "A" ) followed by a line reads the space as an argument and swallows the next line into the description; System(a, "A" , "desc") makes the space a positional argument.
  • direction anywhere in a line is an error. The first lexer rule matches the whole line, and the reference's DB has no setDirection.
  • title stops at # and ;, which is then a lexical error, and keeps its blanks except one. The last title wins.
  • Strings have no escapes, and $descr="" is an error because string_kv_value needs a character. A key's blanks are trimmed.
  • Boundary kinds. Enterprise_Boundary, System_Boundary and Container_Boundary splice their kind in as the third argument (JavaScript's splice(2, 0, …)), so a positional third argument becomes the tags; a bare Boundary takes its third argument as the type, and so does Node_L. Node, Deployment_Node, Node_L and Node_R are boundaries with a body. A boundary's { may sit on its line or the next, an empty boundary is an error, and a boundary re-declared inside itself, or global, is accepted and gets itself as its parent, so it disappears from the tree.
  • RelIndex drops its first argument, Rel_Back is rel_b, and Rel(a, "b") is accepted and makes no relation, since addRel returns without a label.
  • UpdateLayoutConfig ignores values below one and reads with parseInt; UpdateElementStyle of an unknown alias is ignored; an element may be named with a keyword.
  • A deployment node's sprite argument is read and never stored, as in the reference.
  • The title from accTitle. accTitle: sets the title and accDescr: the description, which becomes a <desc>; an accDescr { … } block keeps its inner indentation and loses its ends.

Layout and rendering

The renderer ports c4Renderer.ts, svgDraw.ts, c4ShapeAdapter.ts, c4LabelHelper.ts and styles.js, with the shapes of rendering-util that C4 uses. grid.rs is the Bounds class, draw.rs the recursion over boundaries and elements, shape.rs the element's shape and size, label.rs the stacked element label, legacy.rs the text of boundaries and relations, relation.rs the relations, config.rs the configuration and the element palette, style.rs the style sheet and icons.rs the three icon symbols.

  • The grid. A level (the screen, or the contents of a boundary) has a Bounds with the extent of what is drawn, a cursor, and a count of shapes in the row. A shape starts c4ShapeMargin (50) right of the previous one and twice that below the top of the row, and a row wraps when the shape's left or right edge reaches the width limit, or the count exceeds c4ShapeInRow. The limit is an absolute x: the screen's width at the top, and below it the parent's limit divided by the smaller of c4BoundaryInRow and the number of sibling boundaries. A wrapped row returns to the boundary's left edge plus the margin and nextLinePaddingX. The count is shared by the sibling boundaries of one level and is not reset by setData.
  • The screen is 1200 px. The reference reads screen.availWidth, so its output depends on the viewer's screen: four 216 px systems fit three to a row at 1200 px and four at 1920 px. ferrigura takes a fixed 1200 px (SCREEN_WIDTH in draw.rs), which is also the harness page's screen.
  • Boundaries. For each child boundary, the header height is the label (measured bold at the boundary font size plus 2), the type as [type] and the description, with fixed gaps; the global boundary is measured the same way and moves every first row down, though it is never drawn. A boundary starts a new row at the parent's left edge plus diagramMarginX at every c4BoundaryInRowth boundary, otherwise to the right of the last. Its own shapes are placed, then its children, then its box is drawn, rx 2.5 with a 7.0,7.0 dash (none for a deployment node), after its children.
  • Shapes. Each element is measured, placed, and drawn at its center. The shape comes from $shape, then $sprite, then the first keyword among $tags, then the element's type (person, box and rounded, cylinder, database and db, queue and pipe, component; an unknown keyword is ignored). A rounded box is max(label width + 2 * 20, 216) wide; a person, a database and a queue take the unified renderer's shapes, with the box of their path computed analytically to match Chromium's tight bounds, so a queue is wider than its w. The stereotype is [Kind] or [Kind: techn].
  • The element label is a stack of sections (c4-name, c4-type, c4-descr) centered with a 3 px gap, wrapped at max(216 - 2 * 20, 32) px unless c4.wrap is off, with sizes from the stylesheet: the name bold at the element's font size, the type at 0.75em and the description at 0.82em. Its box is the box of the laid-out text, not rounded.
  • Legacy text. The labels, types and descriptions of boundaries and the labels and technology of relations are measured by calculateTextDimensions: each line as an SVG text, its width and height rounded, the width of the whole the widest line and the height the sum. They are drawn one text per line with text-anchor: middle, dominant-baseline: middle and a tspan of dy per line.
  • Relations. A relation joins the point where the line between the two centers meets each outline. The first relation is a line and every later one a quadratic path with its control point a quarter of the way across and halfway down. Rel_Back has only a start marker, BiRel both. The label sits at the middle of the two ends offset by the relation's offsets, so its visible text starts at the midpoint and extends right; the technology is italic below it. In C4Dynamic the label becomes n: label counted over the relations.
  • Root. The size is the extent plus 2 * diagramMargin; with a title the view box gains 60 px above. The three icon symbols (computer, database, clock) and four markers (arrowhead, arrowend, crosshead, filled-head) are written with the reference's ids; only two markers are referenced.
  • Entity placeholders are decoded at two places, as in the reference: element sections decode before layout and sanitize, and the final string decodes everything else. So #quot; stays as the text &quot; in an element label and shows as " in a boundary label.

Differences from Mermaid

  • Labels are SVG text, the fonts are the bundled faces, and every C4 label is drawn in the embedded family of the diagram's text instead of "Open Sans" (0009, 0010). The reference measures and styles in "Open Sans", sans-serif; the harness maps that name onto the bundled face, so both sides measure the same text.
  • The layout uses a fixed screen of 1200 px (see above).
  • Error messages are ferrigura's own (0007).
  • The pinned Mermaid has a stray c4.flowchart section in its default configuration; patch 04 of the reference drops it, since nothing reads it (0014).

Known gaps

  • Named arguments in the alias, the relation endpoints, or the wrap, typeC4Shape and parentBoundary fields store a JavaScript object in the reference, and the model holds strings. The parser reports an error (is not ported). $type on a relationship declaration replaces type with an object and is not ported either. No collected input uses them.
  • A named label on a boundary or a relation fails to render in the reference and is a render error here too.
  • The top-level wrap does not reach the shapes through the facade. The parser takes it as Options::wrap (the canonical example resolves it from the directives and front matter), but the facade parses with the defaults, so the wrap flag on elements is false and the labels of boundaries and relations, whose wrap needs wrap on both the element and the configuration, never wrap. Element labels wrap by c4.wrap, which is read from the section.
  • The accessibility description across diagrams. The reference's clear() does not reset the common DB's description, so one diagram's description is still there for the next on the same page. The model is per parse, and the exporter clears the common DB before each input.
  • Infinite layout values. parseInt of a 400-digit number gives Infinity, which a JSON number cannot hold; no input has one.
  • Sprites and links are parsed and not drawn, as in the reference. The sprite area of a boundary (header 48 px) is reserved in the reference's layout and is not exercised by any fixture.

Checking it

  • pnpm -C tools/port port:diff c4 compares the canonical models of the fixtures, the C4 string literals of Mermaid's specs and documentation, and the cases of tools/port/cases.ts (every quirk above, and the named-attribute texts that the reference's spec builds from its tables). pnpm -C tools/port port:diff config compares the c4 section.
  • pnpm -C tools/port port:geometry c4 compares the geometry of the fixtures. The harness page's screen is the 1200 px viewport, the reference's "Open Sans" and the other families it names in code are mapped onto the bundled face, and the harness does not compare defs, so the icon symbols are not checked.
  • Rust specs: c4_diagram_spec, c4_boundary_spec, c4_component_spec, c4_container_spec, c4_named_attributes_spec, c4_person_spec, c4_person_ext_spec and c4_system_spec (the ports of Mermaid's parser and DB specs) in ferrigura-syntax; c4_label_spec in ferrigura-render checks the wrap of the element label through the renderer. The unit tests of the scanner, the DB helpers and the model run with their crates.

Learnings

  • The unquoted-argument rule corrupts silently. Several malformed inputs parse and draw something else, so a parser that is correct only on valid input does not match. The first probes to test are Rel(a, b) followed by a line, System(a, "A" ) followed by a line, and a space before a comma.
  • The grid compares absolute coordinates with the screen width, so a shape near the left of a boundary can wrap early, two sibling boundaries on a 1200 px screen each get a limit of 600 and every shape wraps into one column, and the shared count makes the third shape of the second boundary wrap when c4BoundaryInRow is 1.
  • Two text paths round differently. Element labels are laid out by createText and their boxes are getBBox with no rounding; legacy text is measured by calculateTextDimensions, which rounds each line's width and height to integers. The reference also measures in sans-serif and keeps that result only if it is larger in width, height and line height at once, which the configured font never loses here.
  • The database label's offset is analytic. The reference's translate(-53.703125, -11.666666666666666) for a ContainerDb is exactly -(h/2) + padding/1.5 - y with the label box; an apparent 0.38 px difference came from measuring in a fallback face and needs no correction.
  • getBBox of an arc path is the tight bound, so a queue is 239.79 px wide where the formula gives less, and its path starts at x = -7.93. The cylinder's and the queue's boxes are computed analytically to the same numbers.
  • The reference fails on some inputs it parses: a named label on a boundary or relation throws in split, and a direction line reaches a missing DB function.
  • Named offsets are strings. A named $offsetX="abc" in the text-color slot is stored raw and is truthy, so the reference draws x="NaN" at the origin, while a positional NaN is falsy and draws offset 0; $offsetX="5px" gives 5.
  • The fixture update-layout-config-4-shapes-per-row draws three and one, not one row of four as its description says, because the 1200 px limit wraps the fourth shape.