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:
Scroll sideways for more columns.
| Field | Holds |
|---|---|
c4Type | The header keyword, sanitized |
title | The last title line, or the last accTitle (the reference stores an accessibility title as the title) |
accTitle | Always "": the C4 parser never sets it |
accDescr | The description of accDescr, "" if none |
c4ShapeInRow, c4BoundaryInRow | The row counts, 4 and 2 unless UpdateLayoutConfig sets them; parseInt results, whole numbers or NaN |
boundaries | Every boundary in the DB's order; the first is the global boundary |
shapes | Every person, system, container and component in declaration order |
rels | Every 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.textof such a value asundefined; a flag keepsdescrandtechnapart 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 itsparseIntresult ("5","-10","NaN") and a named one is its raw text; the renderer appliesparseIntagain. - The global boundary's
tagsandlinkarenull; 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):RelandBiRelon 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
extraas 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 targetb)\nSystem(cand 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. directionanywhere in a line is an error. The first lexer rule matches the whole line, and the reference's DB has nosetDirection.titlestops 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 becausestring_kv_valueneeds a character. A key's blanks are trimmed. - Boundary kinds.
Enterprise_Boundary,System_BoundaryandContainer_Boundarysplice their kind in as the third argument (JavaScript'ssplice(2, 0, …)), so a positional third argument becomes the tags; a bareBoundarytakes its third argument as the type, and so doesNode_L.Node,Deployment_Node,Node_LandNode_Rare 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, orglobal, is accepted and gets itself as its parent, so it disappears from the tree. RelIndexdrops its first argument,Rel_Backisrel_b, andRel(a, "b")is accepted and makes no relation, sinceaddRelreturns without a label.UpdateLayoutConfigignores values below one and reads withparseInt;UpdateElementStyleof 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 andaccDescr:the description, which becomes a<desc>; anaccDescr { … }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
Boundswith the extent of what is drawn, a cursor, and a count of shapes in the row. A shape startsc4ShapeMargin(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 exceedsc4ShapeInRow. The limit is an absolute x: the screen's width at the top, and below it the parent's limit divided by the smaller ofc4BoundaryInRowand the number of sibling boundaries. A wrapped row returns to the boundary's left edge plus the margin andnextLinePaddingX. The count is shared by the sibling boundaries of one level and is not reset bysetData. - 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_WIDTHindraw.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 plusdiagramMarginXat everyc4BoundaryInRowth boundary, otherwise to the right of the last. Its own shapes are placed, then its children, then its box is drawn,rx2.5 with a7.0,7.0dash (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,boxandrounded,cylinder,databaseanddb,queueandpipe,component; an unknown keyword is ignored). A rounded box ismax(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 itsw. 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 atmax(216 - 2 * 20, 32)px unlessc4.wrapis off, with sizes from the stylesheet: the name bold at the element's font size, the type at0.75emand the description at0.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 onetextper line withtext-anchor: middle,dominant-baseline: middleand atspanofdyper line. - Relations. A relation joins the point where the line between the two centers meets each outline. The first relation is a
lineand every later one a quadraticpathwith its control point a quarter of the way across and halfway down.Rel_Backhas only a start marker,BiRelboth. 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. InC4Dynamicthe label becomesn: labelcounted 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"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.flowchartsection 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,typeC4ShapeandparentBoundaryfields store a JavaScript object in the reference, and the model holds strings. The parser reports an error (is not ported).$typeon a relationship declaration replacestypewith 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
wrapdoes not reach the shapes through the facade. The parser takes it asOptions::wrap(the canonical example resolves it from the directives and front matter), but the facade parses with the defaults, so thewrapflag on elements is false and the labels of boundaries and relations, whose wrap needswrapon both the element and the configuration, never wrap. Element labels wrap byc4.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.
parseIntof a 400-digit number givesInfinity, 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 c4compares the canonical models of the fixtures, the C4 string literals of Mermaid's specs and documentation, and the cases oftools/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 configcompares thec4section.pnpm -C tools/port port:geometry c4compares 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 comparedefs, 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_specandc4_system_spec(the ports of Mermaid's parser and DB specs) inferrigura-syntax;c4_label_specinferrigura-renderchecks 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
c4BoundaryInRowis 1. - Two text paths round differently. Element labels are laid out by
createTextand their boxes aregetBBoxwith no rounding; legacy text is measured bycalculateTextDimensions, which rounds each line's width and height to integers. The reference also measures insans-serifand 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 aContainerDbis exactly-(h/2) + padding/1.5 - ywith the label box; an apparent 0.38 px difference came from measuring in a fallback face and needs no correction. getBBoxof 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 adirectionline 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 drawsx="NaN"at the origin, while a positionalNaNis falsy and draws offset 0;$offsetX="5px"gives 5. - The fixture
update-layout-config-4-shapes-per-rowdraws three and one, not one row of four as its description says, because the 1200 px limit wraps the fourth shape.