Ferrigura / Diagrams
Block diagram
A block diagram draws blocks on a grid: a column count, a span per block, empty cells, nested composites and edges between blocks. It starts with block or block-beta. ferrigura parses it with a hand-written scanner and recursive-descent parser in ferrigura-syntax (src/block/) and draws it with a renderer of the type's own in ferrigura-render (src/block/). No graph engine lays it out: layout.ts's arithmetic (rows, spans, padding) is ported as it is. The renderer builds on the unified renderer's shapes, markers, edge paths and labels, and adds two shapes of its own, the composite and the block arrow. The shared machinery is in flowchart.md; this page covers what is specific to block diagrams.
Model
ferrigura_model::block::BlockDiagram is the Diagram DB after parsing (blockDB.ts), with camelCase keys. A field the reference leaves undefined is absent from the JSON.
columnsis the columns of the entry registered asroot:-1when the text gives none orauto. It is absent only when a class list names a blockrootwith blanks around it, which replaces that entry.blocksare the root's children in order, composites with theirchildren. A block hasid,type(nafor a plain id,square,round,circle,doublecircle,diamond,hexagon,stadium,subroutine,cylinder,lean_right,lean_left,trapezoid,inv_trapezoid,rect_left_inv_arrow,block_arrow,compositeorspace),label(sanitized, with the entity placeholders the DB keeps),width(on space cells),widthInColumns(the span),columns(of a composite),directions(of a block arrow, as written),classes,styles,colorIndex(a composite's palette slot) andchildren.edges, in declaration order:id(the count of its key, then-, thenstart-end, so1-A-Band2-A-B),start,end,label,thickness(normalorthick),pattern(solidordotted),directions,arrowTypeEndandarrowTypeStart.classDefs:id,stylesandtextStyles.
An edge's type is always edge and is left out. The model leaves out the layout, the palette slots beyond colorIndex and the shapes (renderer concerns). The front matter title is read and dropped, because the block DB has no title setter. Ids that the reference draws at random, those of space cells and anonymous composites, are the port's deterministic id-<twelve digits>-<n>; the differential replaces them on both sides by $genK placeholders in tree order, and a space cell's label (its generated id) takes the same placeholder. A user's node id cannot contain -, so every id with a - is a generated one. The one undefined that is written is the first block of a chained link, an empty object (see Parsing). The conventions common to all models are in canonical-model.md.
Parsing
The text goes through the shared preparation (front matter, directives), comment-line removal and encodeEntities, and gets one line break at the end, as Diagram.fromText does.
Scanner (block/scanner.rs). The rules of block.jison in their written order on a state stack (0007): first match wins. INITIAL holds block-beta, block: and block, whitespace, columns, quote openers, space, the style, class and accessibility keywords, end, the shape openers in the order of the grammar, <[, node ids, the links and : with a span. The other states (string, md_string, NODE, BLOCK_ARROW, ARROW_DIR, LINK_LABEL, classDef, class, style, the accessibility states) have the rules the grammar's %x states give. A keyword that ends in a word character gets the implicit word boundary, so blockA, spaceship, defaults, columnsX, endpoint, classy and stylish are node ids; a keyword followed by \s+ needs its blank. Rules that match an empty text ([^\n]*, [^"]*, [^\}]*) return a token even when empty, and at the end of the input the scanner behaves as Jison's done flag does: the first call with an empty input reads the rules once, and every later call returns the raw end symbol, which the grammar does not accept.
Grammar (block/grammar.rs). Recursive descent over the tokens: a document of statements (node statements with an optional span and a chain of links, columns, space, block … end composites, classDef, class, style). Each action gives the objects the reference's does; a link gives three items [left, edge, right], where the left block keeps id, label, type and directions but not its span.
DB (block/db.rs). The reference keeps one object per block, and the DB's map and the tree hold the same objects, so a later mention can change an object that the tree does not show. The port keeps the objects in an arena, and the map and the tree hold indices, which keeps those identities. Populating follows populateBlockDatabase item by item: labels are sanitized when non-empty (a label that sanitizes to empty takes the id); classDef, class and style apply their styles; columns sets the parent's columns; an edge joins the edge list; any other block takes the default label, keeps its first position when its id was seen, takes the new type unless it is na, and takes the new label when it differs from the id. A new composite takes the next palette index before its children, so outer composites come first, and a space with span w becomes w cells. parse_with_warnings returns the reference's log.warn of a block wider than the first columns of its list.
Quirks of the reference that are kept on purpose:
- A diagram with no statement is rejected, and so is a header
block:xat the start, although the detector accepts it. A --> B --> Cgives a first block with no id, label or type and the edge1-undefined-Cwith no start; a second chain's first block is dropped, because the map already holds theundefinedkey.A:2 --> Bdrops the span ofA, andA --> B:2is rejected: a span follows a standalone node only.- The first rule wins, so ids may contain
",],.,~,oandx:A o--o Bhas the nodeo,A ~~~ Bhas the node~~~, andx-yis rejected. - Edges:
---has no end arrow,====and==>are thick,-.,-.-and-.->are dotted,<-->has a point at each end, and a link with no leading mark starts witharrow_open. - A
stylefor an id that no block has yet is an error;style A,B …is an error too (ids are not split), whilestyle Awith no definition is not. classbefore the declaration creates a DB entry outside the tree, so the later node is not drawn.class A, Bwith a blank beforeBlooks upBuntrimmed and replaces an existingBentry.class Awith no class name applies the empty class.classDef xfollowed by a line takes the style options from the next line, since the blank after the name takes the line break.classDef DEFAULTis rejected,classDef defaultnames a class, andclassDef my-classis a lexical error.- Class styles lose the first
;of each style; a style that mentionscoloralso gives a text style, with the firstfillmadebgFilland then the firstcolormadefill. The test is unanchored, sobackground-colorgivesbackground-fill. rootis registered from the start: a node or a class namedrootis absorbed by the root entry.columns 0is kept in the model (the renderer rejects it),columns autois-1,columns 00007is7, andcolumns 3xis3with the idx.space:0gives no cell,spaceis one cell, and a cell's id is<id>-<j>; the space itself is not in the tree.- Block arrow directions and styles keep their blanks as written (
( right , down )gives[" right ", "down "]); the renderer expandsxandy. accDescr {}is accepted and ignored. A non-empty accessibility block,accTitle:andaccDescr:with a value,linkStyle,interpolate, a baredefault, markdown strings, the empty labelA[""], unquoted label text (A[x]) and a shape without a label (A()) are rejected: no production uses them. Labels are quoted, with no escapes. A link label must be quoted and non-empty, and a block arrow's direction must beright,left,up,down,xory.
Layout and rendering
block/mod.rs is blockRenderer.ts. The steps follow the reference: every block is measured with nothing wrapping (calculateBlockSizes), layout.rs places the blocks, each block is drawn at its final size where its label wraps (insertBlocks), the edges join the drawn blocks (insertEdges), and the root takes the box the drawing covers. Every size is the float32 getBBox of the drawn group, as the browser gives it.
Layout (block/layout.rs, layout.ts). Sizes come bottom up (setBlockSizes): a parent's children share the largest per-column width and the largest height among the non-space children, so a child of span s is maxWidth·s + padding·(s−1) wide; the grid is columns wide (or one row of n children when columns is absent or larger), and the parent is widened to its assigned width or its sibling's size when that is larger. Positions come top down (layoutBlocks): each child's row and column follow its span and the parent's columns, a row is as high as its tallest child, and a child is centered at its column's start plus padding. The padding is block.padding (8), read at the call. A columns of 0 is an error, as in the reference.
Nodes and edges (block/nodes.rs, renderHelpers.ts). getNodeFromBlock maps a block type to a unified shape (na and square to rect, round to a rounded rect, diamond to question, and so on); a composite and a block arrow use the shapes below. The measure pass gives a node no width, so its label does not wrap; the placed pass gives the final width, so the label wraps at the width the shape leaves for it. An edge is drawn only when both ends have a size, with the points [start center, midpoint, end center] clipped by the shapes' own intersect functions; its id is <diagram id>-<edge id>, its class list carries the thickness and pattern classes and flowchart-link, and its label is a Markdown label at the midpoint.
Shapes of its own (unified/shapes/composite.rs, unified/shapes/block_arrow.rs). A composite is a rect.basic.cluster.composite.label-container with an empty label, drawn as a sibling of its children (every block is a child of g.block, in pre-order), so the children paint over it; in a color theme it gets data-color-id from its colorIndex. A block arrow is a polygon from a table of sixteen point sets keyed by its expanded directions (x is right and left, y is up and down), in the order right, left, up, down; an unknown key or no direction gives the single point (0, 0).
Style sheet (block/style.rs, styles.ts). Colors are custom properties on the diagram id (0011); the classDef rules come from createCssStyles, and the palette rules are one per slot, on composites only. The composite's fill and stroke are the one place with a computed rgba, since a custom property cannot carry the alpha of fade.
Root. The size is bounds.height + magicFactor + 10 by bounds.width + 10, with magicFactor = max(1, round(0.125·width/height)); the viewBox is the box padded by 5 and does not include the magic factor. With useMaxWidth (the default) the root has width="100%" and max-width; without it, height and width attributes.
Differences from Mermaid
- Labels are SVG text, also with
htmlLabels: true, which is the reference's production default (0009). - Generated ids are deterministic (see Model).
- A drawing of only spaces with
useMaxWidth: falsegetsheight="NaN", as in the reference; the attribute is written as it writes it, a named exception to the reading of the root.
Known gaps
- A site configuration's
dompurifyConfigis not read by the sanitizer, which runs DOMPurify's default configuration once where the reference'ssanitizeMorerunsremoveScriptfirst; the one spec that tests it is ignored.
Checking it
pnpm -C tools/port port:diff blockcompares the canonical models on the fixtures, the string literals of the specs and the Markdown examples, and the explicit cases ofcases.ts(one per quirk above); generated ids are replaced by placeholders (normalizeGeneratedinexport-block.ts,normalize_block_idsin thecanonicalexample).pnpm -C tools/port port:geometry blockrenders every fixture with both in Chromium and compares geometry. There is no layout-data run: the layout is the type's own arithmetic, checked by the geometry run.- Rust:
block_spec.rsandblock_db_spec.rsinferrigura-syntax/tests;block_layout_spec.rs,block_color_index_spec.rsandblock_render_spec.rsinferrigura-render/tests. The last holds what the geometry run does not state: theNaNheight of a drawing of only spaces, the zero-column error, the repeated classes of a composite, the first block of a chained link with no id and no edge, and the palette slots of a composite. The sixteen point tables ofblockArrow.spec.tsare in the unit tests ofblock_arrow.rs.
Learnings
- Node labels do not wrap at 200. In the measure pass a node has no width, so
wrappingWidthisInfinityand a 680 px label stays on one row; only edge labels takecreateText's default of 200. The reference's e2e test that says "200 px wrap threshold" checks overlap and its premise is wrong. - A hexagon label wraps on a float tie. In two equal polygons
Opening tagwraps andClosing tagdoes not, because the wrap test iswidth <= limitand the shape changes the node's width first. The shapes that read the node's own width or height (hexagon, lean and trapezoid shapes, double circle, cylinder) take it from the block's size; the flowchart never sets one. - A composite's class is
node default flowchart-label default flowchart-label. The classes are repeated, and the structural key of the geometry run counts them. - Every block edge has
style="undefined;;;undefined"and a doubled path id prefix (b10-b10-1-A-E):edge.styleis undefined, soedgeStylesis[undefined]. - The root's
heighthas a factor the viewBox lacks. WithuseMaxWidth: falsetheheightattribute is 117 where the viewBox says 116. The factor is JavaScript'sMath.round, writtenfloor(x + 0.5)here, and it is NaN for a drawing with no height, whichMath.maxkeeps (Rust'sf64::maxwould drop it). - Edge endpoints move back 4 px under a point marker, and the circle and cross markers have no offset in
markerOffsets, although the dash arithmetic ofmarkerOffsets2has one. - Block edges have no color, so no colored marker copies are made, and
arrow_open(every edge without a leading mark) has no marker. - The measure and the placed pass differ, so the same label can have two sizes; a shape that changes
node.widthfirst (the hexagon) changes the wrap limit of the second pass. styles2Mapsplits on:, so a style value with a second colon loses everything after it, and the node'sstyleis merged with its classes' styles with!importantappended.