Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,17 @@ them on a private corpus without copying it into the repository, are described i
| `domain/diagram/` | the drawing (BPMN DI): `di/` reads and writes planes, frames, groups and the checkpoint every engine shares, `layout/` holds the engines (`clean/` redraws with `FullLayout`, `incremental/` keeps a hand-made drawing), `DrawnDiagram` is the drawing the diagram commands change, `commands/` holds one handler per diagram command (`ShapePlacement`, `ShapeAlignment`, `SpaceInsertion`, ...) and `DiagramCommands`, which dispatches them; `BatchDrawing` is all a batch does to the drawing |
| `domain/editing/` | what changes the model, one folder per concern: `elements/` (add, remove, retype, move, order, placement, artifacts, pools, lanes), `connections/` (connect, associations, sequence flows, bridges, flow ids, split), `properties/` (set, value coercion, the set keys, nested slots, vendor elements, decision links), `events/` (triggers, event definitions, root elements, the event-gateway rule), `extensions/` (ext), `ids/` (id renames); a command is a class created with the `EditSession` (`new ElementCreation(edit).add(op)`, `new Connections(edit).connect(op)`, ...) |
| `domain/validation/` | structural validation (`StructuralValidator`, `FlowOrder`), the validator hook, the `FindingBaseline` a write compares with and the `FindingMatcher` that tells the same finding before and after an edit; `profiles/` holds the engine rules that load on demand |
| `domain/views/` | the read views (`show`, `find`, `--around`, `--context`) and `render/`, the SVG of a diagram |
| `domain/views/` | the read views (`show`, `find`, `--around`, `--context`) and `DrawableDoc`, a document ready to draw (one without a diagram laid out in memory) |
| `domain/batch/` | one batch of commands on a document in memory: `EditBatch` (guard, commands, validation, layout, kept text, result), `ModelCommands` hands each model command to its domain service and `BatchDrawing` (`domain/diagram/`) lays the drawing out between them; the top of the domain, nothing imports it back |
| `application/port/in/` | one contract per use case, the way in: an interface with its `Command` and `Result` (`EditDocumentUseCase`, `RelayoutDiagramUseCase`, `ValidateDocumentUseCase`, `ShowModelUseCase`, ...), also for a pure query that needs no outbound port |
| `application/port/out/` | what the application needs from outside, named after what it does: `DocumentPort` (load a document, resolve where a result goes, save it), `LoadProjectSettingsPort` (the `.bpmnlintrc`), `WriteImagePort` |
| `application/port/out/` | what the application needs from outside, named after what it does: `DocumentPort` (load a document, resolve where a result goes, save it), `LoadProjectSettingsPort` (the `.bpmnlintrc`), `RenderDiagramPort` (a plane as SVG), `WriteImagePort` |
| `application/service/` | one service per use case that implements its contract (`EditDocumentService implements EditDocumentUseCase`): load, let the domain work, save, all through the outbound ports; a query asks the domain and returns its answer |
| `adapters/in/cli/` | the driving adapter: the `Cli` (commander, streams, exit codes, errors) and one class per command; it knows the use cases only by their contracts and reaches the domain only through them; from the domain it takes the command vocabulary to check its flags (errors, op rules, flag values) |
| `adapters/in/report/` | what a use case's result looks like to the one who asked: text and JSON reports (`MutationText`, `MutationReportBuilder`, `ValidationText`, ...), the views as text, and the reference the guide and `bpmn kinds` print; the CLI prints them and `index.ts` / `api.ts` return them |
| `adapters/out/bpmnlint/` | the injected bpmnlint as a validator of the domain, browser-safe: `BpmnlintConfigResolution` (the effective `.bpmnlintrc`), `BpmnlintValidator`, `IdConventionsReader` (the id conventions of `element-id-naming`) |
| `adapters/out/files/` | the driven adapters on disk, Node only: `FileDocumentStore`, `BpmnlintProjectSettings` (loads the `.bpmnlintrc` and its plugins for `adapters/out/bpmnlint/`), `FileImageWriter` (PNG) |
| `adapters/out/files/` | the driven adapters on disk, Node only: `FileDocumentStore`, `BpmnlintProjectSettings` (loads the `.bpmnlintrc` and its plugins for `adapters/out/bpmnlint/`), `FileImageWriter` (PNG via resvg) |
| `adapters/out/document/` | `DocumentStore`, what the file store and the in-memory store share behind the `DocumentPort`: a source parsed into a `Doc`, the lossless check, the target of a write and whether to write; browser-safe |
| `adapters/out/render/` | the SVG renderer behind the `RenderDiagramPort`, browser-safe: `SvgRenderer` (the port), `DocRenderer` (one plane, start to end), `crop/` (which part of the plane), `paint/` (the shapes, connections and labels as SVG), `compose/` (frame, scale, badges, tiles) |
| `adapters/out/memory/` | the driven adapters of the in-memory API: `NoDocumentFiles`, `NoProjectSettings` |
| `types/` | type shims for the bpmn-io packages that ship no usable declarations |

Expand Down
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ flowchart TB
service --> portOut{{"Port<br/>application/port/out"}}
portOut --> files["Files<br/>adapters/out/files"]
portOut --> memory["No project, no files<br/>adapters/out/memory"]
portOut --> render["SVG renderer<br/>adapters/out/render"]
files --> store["Loading and saving a document<br/>adapters/out/document"]
memory --> store
files --> bpmnlint["bpmnlint<br/>adapters/out/bpmnlint"]
Expand All @@ -34,7 +35,7 @@ flowchart TB
classDef adapter stroke:#2f80ed,stroke-width:2px
classDef app stroke:#f2994a,stroke-width:2px
classDef core stroke:#27ae60,stroke-width:3px
class cli,files,memory,store,bpmnlint adapter
class cli,files,memory,store,bpmnlint,render adapter
class portIn,service,portOut app
class domain core
```
Expand All @@ -48,6 +49,8 @@ container.
adapters hand it the `.bpmnlintrc` they load; `index.ts` hands it to hosts that bring their own
bpmnlint. `adapters/out/document/` is what the file store and the in-memory store share behind
the `DocumentPort`: a source parsed into a `Doc`, the target of a write, the save.
`adapters/out/render/` draws a plane as SVG behind the `RenderDiagramPort`, browser-safe; the
file adapters only write or rasterise what it drew.

| rule (checked by the test) | why |
| --- | --- |
Expand Down
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ not in an `applyToXml`-only bundle; and the command (`-o`, the temp default,
`-o -`, `--json`, `--plane`, the usage errors). The synthetic
`test/fixtures/render/gallery.bpmn` has every element kind the renderer
draws (built with the CLI, then a group and multiple events added by hand).
`test/domain/views/render/render-agent.test.ts` checks the agent features (stage 2): every layout
`test/adapters/out/render/render-agent.test.ts` checks the agent features (stage 2): every layout
problem of every scenario and fixture located inside the bounding box of its
elements on its plane (`locateProblems`; `layoutProblems` unchanged);
`--problems` listing every metrics problem in its order with the same ids,
Expand All @@ -342,7 +342,7 @@ mutating commands (text, JSON, the region of a large drawing, a failed render
that leaves the change standing); and the fuzz walk's render oracle. The
synthetic `test/fixtures/render/long.bpmn` (28 nodes in a row, 3,940 px
wide, built with the CLI) is the tiles and focus model.
`test/domain/views/render/render-fixes.test.ts` checks the fixes after the verification of
`test/adapters/out/render/render-fixes.test.ts` checks the fixes after the verification of
issue #12 on synthetic models: broken or far-flung coordinates (NaN,
1e308, Infinity, 1e9, a negative size, far label bounds) are left out with a
note and never give `width="NaN"`, an all-broken plane is `E_RENDER`;
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/in/cli/MutationCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { EditCommand, TriggerOptions } from '../../../domain/commands/types
import { TRIGGER_FIELDS, TRIGGER_KEYS } from '../../../domain/commands/utils/op-fields.js';
import type { MutationResult } from '../../../domain/batch/types.js';
import { LAYOUT_MODES, type LayoutMode } from '../../../domain/diagram/types.js';
import { RENDER_MAX_SIZE, type RenderOptions } from '../../../domain/views/render/types.js';
import { RENDER_MAX_SIZE, type RenderOptions } from '../../../application/port/in/RenderSvgUseCase.js';
import { changeHighlight } from '../report/utils/highlight.js';
import { type MutationReport, type MutationSummary } from '../report/command-report.js';
import { RenderReport } from './RenderReport.js';
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/in/cli/RenderCommand.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { basename } from 'node:path';
import { InvalidArgumentError, type Command as CommandType } from 'commander';
import { ViewRequest } from '../../../domain/views/ViewRequest.js';
import { RENDER_MAX_SIZE, type RenderOptions } from '../../../domain/views/render/types.js';
import { RENDER_MAX_SIZE, type RenderOptions } from '../../../application/port/in/RenderSvgUseCase.js';
import { RenderReport } from './RenderReport.js';
import { usageError } from '../../../domain/document/utils/errors.js';
import { CliCommand, type RawOpts } from './CliCommand.js';
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/in/cli/RenderReport.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Shot } from '../../../application/port/out/WriteImagePort.js';
import { AROUND_DEFAULT_DEPTH } from '../../../domain/views/around-view.js';
import type { RenderHighlight, RenderOptions } from '../../../domain/views/render/types.js';
import type { RenderHighlight, RenderOptions } from '../../../application/port/in/RenderSvgUseCase.js';
import { scaleText } from '../../../domain/views/utils/scale.js';
import type { BpmnError } from '../../../domain/document/BpmnError.js';

Expand Down
2 changes: 1 addition & 1 deletion src/adapters/in/report/utils/highlight.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MutationReport } from '../command-report.js';
import type { RenderHighlight } from '../../../../domain/views/render/types.js';
import type { RenderHighlight } from '../../../../application/port/in/RenderSvgUseCase.js';

/** What a mutation touched, as halos; each id in its first category only. */
export function changeHighlight(report: Pick<MutationReport, 'created' | 'changed' | 'layout'>): Required<RenderHighlight> {
Expand Down
24 changes: 9 additions & 15 deletions src/adapters/out/files/FileImageWriter.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, extname, join } from 'node:path';
import { type RenderProblem } from '../../../domain/views/render/types.js';
import { ioError } from '../../../domain/document/utils/errors.js';
import { PngRenderer } from './PngRenderer.js';
import type { ImageRequest, WriteImagePort, Shot, ShotImage } from '../../../application/port/out/WriteImagePort.js';
import { DocRenderer } from '../../../domain/views/render/DocRenderer.js';

export class FileImageWriter implements WriteImagePort {
/**
* Writes to `out`, stdout (`-`) or the temp directory's bpmn-cli/<stem>.<format>;
* tiles get -1, -2, ... before the extension.
*/
async write({ xml, opts, format, out, stem, stdout }: ImageRequest): Promise<Shot> {
async write({ drawn, format, out, stem, stdout }: ImageRequest): Promise<Shot> {
let parts: Array<{ bytes: string | Uint8Array; width: number; height: number; tokens: number; columns?: [number, number]; band?: number }>;
let rendered: { scale: number; plane: string; planes: string[]; notes: string[]; problems: RenderProblem[]; nodes?: string[] };
if (format === 'svg') {
const svg = await DocRenderer.renderXml(xml, opts);
parts = (svg.tiles ?? [svg]).map((tile) => ({ bytes: tile.svg, width: tile.width, height: tile.height, tokens: tile.tokens, ...('columns' in tile && tile.columns ? { columns: tile.columns } : {}), ...('band' in tile && tile.band ? { band: tile.band } : {}) }));
rendered = svg;
parts = (drawn.tiles ?? [drawn]).map((tile) => ({ bytes: tile.svg, width: tile.width, height: tile.height, tokens: tile.tokens, ...('columns' in tile && tile.columns ? { columns: tile.columns } : {}), ...('band' in tile && tile.band ? { band: tile.band } : {}) }));
} else {
const png = await new PngRenderer().render(xml, opts);
const png = await new PngRenderer().render(drawn);
parts = png.images.map((image) => ({ bytes: image.png, width: image.width, height: image.height, tokens: image.tokens, ...(image.columns ? { columns: image.columns } : {}), ...(image.band ? { band: image.band } : {}) }));
rendered = png;
}
const images: ShotImage[] = [];
if (out === '-') {
Expand All @@ -49,12 +43,12 @@ export class FileImageWriter implements WriteImagePort {
width: first.width,
height: first.height,
tokens: first.tokens,
scale: rendered.scale,
plane: rendered.plane,
planes: rendered.planes,
notes: rendered.notes,
problems: rendered.problems,
...(rendered.nodes ? { nodes: rendered.nodes } : {}),
scale: drawn.scale,
plane: drawn.plane,
planes: drawn.planes,
notes: drawn.notes,
problems: drawn.problems,
...(drawn.nodes ? { nodes: drawn.nodes } : {}),
...(images.length > 1 ? { images } : {}),
};
}
Expand Down
6 changes: 2 additions & 4 deletions src/adapters/out/files/PngRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { RENDER_MAX_SIZE, type RenderOptions, type RenderProblem, type RenderTile } from '../../../domain/views/render/types.js';
import type { RenderProblem, RenderResult, RenderTile } from '../../../application/port/in/RenderSvgUseCase.js';
import { BpmnError } from '../../../domain/document/BpmnError.js';
import { DocRenderer } from '../../../domain/views/render/DocRenderer.js';

export interface PngImage {
png: Uint8Array;
Expand Down Expand Up @@ -57,8 +56,7 @@ export class PngRenderer {
private static engine: Promise<Engine> | undefined;

/** Any rasteriser failure, including a wasm trap, becomes E_RENDER, never an abort. */
async render(xml: string, opts: RenderOptions = {}): Promise<PngRenderResult> {
const drawn = await DocRenderer.renderXml(xml, { ...opts, maxSize: opts.maxSize ?? RENDER_MAX_SIZE });
async render(drawn: RenderResult): Promise<PngRenderResult> {
const images: PngImage[] = [];
const parts: Array<Pick<RenderTile, 'svg' | 'columns' | 'band'>> = drawn.tiles ?? [{ svg: drawn.svg }];
for (const part of parts) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import { Doc } from '../../document/Doc.js';
import { ioError, modelError } from '../../document/utils/errors.js';
import { FullLayout } from '../../diagram/layout/FullLayout.js';
import type { Box } from '../../diagram/utils/geom.js';
import { LayoutAnalysis } from '../../diagram/di/LayoutAnalysis.js';
import type { LayoutProblem } from '../../diagram/utils/problems.js';
import { DiagramReader } from '../../diagram/di/DiagramReader.js';
import { Semantics } from '../../diagram/di/Semantics.js';
import type { DEdge, DShape, Plane } from '../../diagram/di/types.js';
import { is } from '../../document/utils/moddle.js';
import { Composer } from './Composer.js';
import { Crop } from './Crop.js';
import { Scene } from './Scene.js';
import { COORD_LIMIT, HALO_COLORS, type RenderOptions, type RenderResult } from './types.js';
import { planeOfId } from '../utils/planes.js';
import type { Doc } from '../../../domain/document/Doc.js';
import { ioError, modelError } from '../../../domain/document/utils/errors.js';
import type { Box } from '../../../domain/diagram/utils/geom.js';
import type { LayoutProblem } from '../../../domain/diagram/utils/problems.js';
import { DiagramReader } from '../../../domain/diagram/di/DiagramReader.js';
import { Semantics } from '../../../domain/diagram/di/Semantics.js';
import type { DEdge, DShape, Plane } from '../../../domain/diagram/di/types.js';
import { is } from '../../../domain/document/utils/moddle.js';
import { Composer } from './compose/Composer.js';
import { Crop } from './crop/Crop.js';
import { Scene } from './paint/Scene.js';
import { HALO_COLORS, type RenderOptions, type RenderResult } from '../../../application/port/in/RenderSvgUseCase.js';
import { COORD_LIMIT } from './types.js';
import { planeOfId } from './crop/planes.js';

/**
* Draws one diagram plane as SVG without a browser, close to bpmn-js (not pixel-identical), so
Expand All @@ -29,19 +28,6 @@ export class DocRenderer {
this.sem = Semantics.of(doc.definitions);
}

/** A document without any diagram is laid out in memory first; its problems stay the file's. */
static async renderXml(xml: string, opts: RenderOptions = {}): Promise<RenderResult> {
let doc = await Doc.fromXml(xml);
if (new DiagramReader(doc.definitions).planes().length) return new DocRenderer(doc, opts).render();
const fileProblems = opts.problems ? LayoutAnalysis.of(doc.definitions).problems : [];
const { xml: drawn } = await new FullLayout(doc.model).run();
doc = await Doc.fromXml(drawn);
const result = new DocRenderer(doc, opts).render(fileProblems);
const notes = ['the file has no diagram: it is drawn by the layout engine in memory (nothing is written; `bpmn layout <file>` saves a drawing)'];
if (opts.problems) notes.push("the problems are the file's (`bpmn metrics <file>`), not the in-memory drawing's: none is in the image");
return { ...result, notes: [...notes, ...result.notes] };
}

/** `fileProblems`: those of a file drawn in memory, listed instead of the drawing's. */
render(fileProblems?: LayoutProblem[]): RenderResult {
const { doc, opts, sem } = this;
Expand Down
19 changes: 19 additions & 0 deletions src/adapters/out/render/SvgRenderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { LayoutProblem } from '../../../domain/diagram/utils/problems.js';
import type { Doc } from '../../../domain/document/Doc.js';
import type { RenderOptions, RenderResult } from '../../../application/port/in/RenderSvgUseCase.js';
import type { RenderDiagramPort } from '../../../application/port/out/RenderDiagramPort.js';
import { DocRenderer } from './DocRenderer.js';

/**
* Our own SVG renderer instead of bpmn-js: bpmn-js needs a browser DOM to measure text and place
* labels, so in Node it would take a headless Chromium (over 100 MB, seconds per image, output that
* varies with fonts and versions) and would not run in the browser bundle without a DOM either.
* This one needs no browser, gives the same bytes on every machine and draws what an agent needs and
* bpmn-js has no notion of: crops (around, path, scope), halos for created, changed and moved
* elements, numbered layout problems, tiles and notes on broken DI.
*/
export class SvgRenderer implements RenderDiagramPort {
render(doc: Doc, opts: RenderOptions = {}, fileProblems?: LayoutProblem[]): RenderResult {
return new DocRenderer(doc, opts).render(fileProblems);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { Box, Point } from '../../diagram/utils/geom.js';
import { containsPoint, grow, unionAll } from '../../diagram/utils/geom.js';
import { LayoutAnalysis } from '../../diagram/di/LayoutAnalysis.js';
import { ProblemLocator } from '../../diagram/di/ProblemLocator.js';
import { problemKey, type LayoutProblem } from '../../diagram/utils/problems.js';
import type { Plane } from '../../diagram/di/types.js';
import type { Box, Point } from '../../../../domain/diagram/utils/geom.js';
import { containsPoint, grow, unionAll } from '../../../../domain/diagram/utils/geom.js';
import { LayoutAnalysis } from '../../../../domain/diagram/di/LayoutAnalysis.js';
import { ProblemLocator } from '../../../../domain/diagram/di/ProblemLocator.js';
import { problemKey, type LayoutProblem } from '../../../../domain/diagram/utils/problems.js';
import type { Plane } from '../../../../domain/diagram/di/types.js';
import { TileGrid } from './TileGrid.js';
import { LAYER, MARGIN, MAX_TILES, MIN_LABEL_SCALE, type RenderFrame, type Item, type RenderOptions, type RenderProblem, type RenderResult } from './types.js';
import { MIN_LABEL_SCALE, type RenderOptions, type RenderProblem, type RenderResult } from '../../../../application/port/in/RenderSvgUseCase.js';
import { LAYER, MARGIN, MAX_TILES, type RenderFrame, type Item } from '../types.js';
import { meets, pointsBox } from '../utils/boxes.js';
import { scaleText } from '../utils/scale.js';
import { scaleText } from '../../../../domain/views/utils/scale.js';
import { FILL, formatNumber } from '../utils/svg.js';

const FOCUS_MARGIN = 40;
Expand Down
Loading
Loading