Technical Brief
Pintora builds a simplified tool chain for diagrammers from DSL-parsing to diagram-drawing through sensible layering and abstraction.
Workflow and data
Pintora's workflow and data are shown below.
Input Text
|
| () IDiagramParser
v
DiagramIR
|
| () IDiagramArtist
v
GraphicsIR
|
| () IRenderer
v
Output
IR
stands for Intermediate Representation and represents the different phases of the processing.
DiagramIR
represents the logical data for a specific diagram type, relevant to the textual DSL of the diagramGraphicsIR
is the visual repesentation format provided by Pintora
IDiagramParser and DiagramIR
The role of the IDiagramParser
is to convert the textual DSL of the diagram into logical data, preparing the ground for the subsequent construction of the visual elements.
For example, here is the logical data that corresponds to Pintora's built-in Entity Relationship Diagram.
export type ErDiagramIR = {
entities: Record<string, Entity>
relationships: Relationship[]
}
export type Attribute = {
attributeType: string
attributeName: string
attributeKey?: string
}
export type Entity = {
attributes: Attribute[]
}
export type Relationship = {
entityA: string
roleA: string
entityB: string
relSpec: RelSpec
}
export type RelSpec = {
cardA: Cardinality
cardB: Cardinality
relType: Identification
}
Any parser tool and technique can be used to implement this process, from line-by-line parsing of regular expressions to programs generated by various parser generators, as long as they can be run in a JS environment.
Pintora's built-in diagrams use nearley.js to generate context-independent syntax parsers that are easy to use, based on an improved Earley algorithm, it has decent performance (though perhaps relatively slow - in worst case - of the mainstream solutions, but perfectly adequate for small text diagram DSLs) and a pretty small runtime. Diagram authors can choose between an efficient parser generator solution such as jison / PEG.js, or a handwritten parser.
IDiagramArtist and GraphicsIR
IDiagramArtist
converts diagram logic data into visual description data GraphicsIR
, which provides input to the IRenderer
for different platforms later.
The main parts of GraphicsIR
are
rootMark
, which must be aGroup
type mark, is the root element of the diagram, and all other elements are its childrenwidth
andheight
that describe the overall width and height of the diagram- an optional
bgColor
for the diagram's background color
export type Mark = Group | Rect | Circle | Ellipse | Text | Line | PolyLine | Polygon | Marker | Path | GSymbol
export interface GraphicsIR {
mark: Mark
width: number
height: number
bgColor?: string
}
Pintora abstracts visual elements into different types of marks. A collection of attributes attrs
is used to describe the characteristics of the marks, some (e.g. x
and y
) are common attributes, while each type of mark has its specific attributes (e.g. path
for the Path
mark).
In addition to attrs
, there are also special fields on the tags that describe other behaviors. For example, matrix
for describing visual transformations, or children
specific to Group
.
export interface IMark {
attrs?: MarkAttrs
class?: string
/** for transform */
matrix?: Matrix | number[]
}
export interface Group extends IMark {
type: 'group'
children: Mark[]
}
export interface Circle extends IMark {
type: 'circle'
attrs: MarkAttrs & {
x: number
y: number
r: number
}
}
/**
* Common mark attrs, borrowed from @antv/g
*/
export type MarkAttrs = {
x?: number
y?: number
/** radius of circle */
r?: number
/** stroke color */
stroke?: ColorType
/** fill color */
fill?: ColorType
opacity?: number
lineWidth?: number
...
}
You can find the full GraphicsIR
definition in pintora's source code.
Pintora's rendering layer currently uses antv/g and can output both canvas and svg formats. So GraphicsIR
is currently defined in much the same way as antv/g
, and you will also find many terms similar to SVG definitions.
To build a complete visual representation of a diagram, the artist needs to do several things, including generating various markers, specifying colors, calculating layout-related data, etc., so the amount of code is usually the largest part of the diagram implementation.
IDiagram and diagramRegistry
IDiagram
is a fully defined interface to a diagram. After an object that implements this interface registers itself into the diagram collection diagramRegistry
, Pintora can recognize and process the input text of the diagram description and turn it into a specific image output.
export interface IDiagram<D = any, Config = any> {
/**
* A pattern used to detect if the input text should be handled by this diagram.
* @example /^\s*sequenceDiagram/
*/
pattern: RegExp
parser: IDiagramParser<D>
artist: IDiagramArtist<D, Config>
configKey?: string
clear(): void
}
export type ParseContext = {
preContent?: string
}
/**
* Parse input text to DiagramIR
*/
export interface IDiagramParser<D> {
parse(text: string, context?: ParseContext): D
}
/**
* Convert DiagramIR to GraphicsIR
*/
export interface IDiagramArtist<D, Config = any> {
draw(diagramIR: D, config?: Config): GraphicsIR
}
To register a new type of diagram:
import { IDiagram } from '@pintora/core'
import pintora from '@pintora/standalone'
const diagramDefinition: IDiagram = { ... }
pintora.diagramRegistry.registerDiagram(diagramDefinition)
Some other details
Text layout
Pintora uses canvas.measureText
to calculate the layout parameters for text, and uses jsdom and its underlying dependency node-canvas to do this on the Node.js side.
Layout libraries
For some diagram types, it is not easy to compute layouts that satisfy the logical properties of the diagram, but are also readable and aesthetically pleasing. Inspired by the Mermaid.js' implementation, Pintora maintains a fork of dagrejs/dagre - @pintora/dagre.