diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9806cbe8..0be60055 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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 |
diff --git a/docs/architecture.md b/docs/architecture.md
index 53df2da4..6bd465f8 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -26,6 +26,7 @@ flowchart TB
service --> portOut{{"Port application/port/out"}}
portOut --> files["Files adapters/out/files"]
portOut --> memory["No project, no files adapters/out/memory"]
+ portOut --> render["SVG renderer adapters/out/render"]
files --> store["Loading and saving a document adapters/out/document"]
memory --> store
files --> bpmnlint["bpmnlint adapters/out/bpmnlint"]
@@ -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
```
@@ -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 |
| --- | --- |
diff --git a/docs/testing.md b/docs/testing.md
index 6472b830..6ccbfe71 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -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,
@@ -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`;
diff --git a/src/adapters/in/cli/MutationCommand.ts b/src/adapters/in/cli/MutationCommand.ts
index 36e75d89..74383e78 100644
--- a/src/adapters/in/cli/MutationCommand.ts
+++ b/src/adapters/in/cli/MutationCommand.ts
@@ -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';
diff --git a/src/adapters/in/cli/RenderCommand.ts b/src/adapters/in/cli/RenderCommand.ts
index 5a0bd41a..b80a51cd 100644
--- a/src/adapters/in/cli/RenderCommand.ts
+++ b/src/adapters/in/cli/RenderCommand.ts
@@ -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';
diff --git a/src/adapters/in/cli/RenderReport.ts b/src/adapters/in/cli/RenderReport.ts
index ea7a290d..83cdc0d1 100644
--- a/src/adapters/in/cli/RenderReport.ts
+++ b/src/adapters/in/cli/RenderReport.ts
@@ -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';
diff --git a/src/adapters/in/report/utils/highlight.ts b/src/adapters/in/report/utils/highlight.ts
index 2d4fddec..47ea4e2d 100644
--- a/src/adapters/in/report/utils/highlight.ts
+++ b/src/adapters/in/report/utils/highlight.ts
@@ -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): Required {
diff --git a/src/adapters/out/files/FileImageWriter.ts b/src/adapters/out/files/FileImageWriter.ts
index b90957d0..1b6aa0e1 100644
--- a/src/adapters/out/files/FileImageWriter.ts
+++ b/src/adapters/out/files/FileImageWriter.ts
@@ -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/.;
* tiles get -1, -2, ... before the extension.
*/
- async write({ xml, opts, format, out, stem, stdout }: ImageRequest): Promise {
+ async write({ drawn, format, out, stem, stdout }: ImageRequest): Promise {
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 === '-') {
@@ -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 } : {}),
};
}
diff --git a/src/adapters/out/files/PngRenderer.ts b/src/adapters/out/files/PngRenderer.ts
index f9a3c521..97127e49 100644
--- a/src/adapters/out/files/PngRenderer.ts
+++ b/src/adapters/out/files/PngRenderer.ts
@@ -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;
@@ -57,8 +56,7 @@ export class PngRenderer {
private static engine: Promise | undefined;
/** Any rasteriser failure, including a wasm trap, becomes E_RENDER, never an abort. */
- async render(xml: string, opts: RenderOptions = {}): Promise {
- const drawn = await DocRenderer.renderXml(xml, { ...opts, maxSize: opts.maxSize ?? RENDER_MAX_SIZE });
+ async render(drawn: RenderResult): Promise {
const images: PngImage[] = [];
const parts: Array> = drawn.tiles ?? [{ svg: drawn.svg }];
for (const part of parts) {
diff --git a/src/domain/views/render/DocRenderer.ts b/src/adapters/out/render/DocRenderer.ts
similarity index 82%
rename from src/domain/views/render/DocRenderer.ts
rename to src/adapters/out/render/DocRenderer.ts
index a8dc1724..b3420571 100644
--- a/src/domain/views/render/DocRenderer.ts
+++ b/src/adapters/out/render/DocRenderer.ts
@@ -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
@@ -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 {
- 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 ` saves a drawing)'];
- if (opts.problems) notes.push("the problems are the file's (`bpmn metrics `), 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;
diff --git a/src/adapters/out/render/SvgRenderer.ts b/src/adapters/out/render/SvgRenderer.ts
new file mode 100644
index 00000000..804ca519
--- /dev/null
+++ b/src/adapters/out/render/SvgRenderer.ts
@@ -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);
+ }
+}
diff --git a/src/domain/views/render/Composer.ts b/src/adapters/out/render/compose/Composer.ts
similarity index 92%
rename from src/domain/views/render/Composer.ts
rename to src/adapters/out/render/compose/Composer.ts
index dd142b57..96aeb839 100644
--- a/src/domain/views/render/Composer.ts
+++ b/src/adapters/out/render/compose/Composer.ts
@@ -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;
diff --git a/src/domain/views/render/TileGrid.ts b/src/adapters/out/render/compose/TileGrid.ts
similarity index 93%
rename from src/domain/views/render/TileGrid.ts
rename to src/adapters/out/render/compose/TileGrid.ts
index 33ec8601..a9b656bb 100644
--- a/src/domain/views/render/TileGrid.ts
+++ b/src/adapters/out/render/compose/TileGrid.ts
@@ -1,10 +1,10 @@
-import type { Box } from '../../diagram/utils/geom.js';
-import { containsPoint } from '../../diagram/utils/geom.js';
-import type { LayoutProblem } from '../../diagram/utils/problems.js';
-import type { DShape } from '../../diagram/di/types.js';
-import { is } from '../../document/utils/moddle.js';
-import { DiagramColumns } from '../DiagramColumns.js';
-import { MARGIN, MAX_TILES, type RenderFrame, type Item } from './types.js';
+import type { Box } from '../../../../domain/diagram/utils/geom.js';
+import { containsPoint } from '../../../../domain/diagram/utils/geom.js';
+import type { LayoutProblem } from '../../../../domain/diagram/utils/problems.js';
+import type { DShape } from '../../../../domain/diagram/di/types.js';
+import { is } from '../../../../domain/document/utils/moddle.js';
+import { DiagramColumns } from '../../../../domain/views/DiagramColumns.js';
+import { MARGIN, MAX_TILES, type RenderFrame, type Item } from '../types.js';
import { meets } from '../utils/boxes.js';
export interface TileView {
diff --git a/src/domain/views/render/Crop.ts b/src/adapters/out/render/crop/Crop.ts
similarity index 90%
rename from src/domain/views/render/Crop.ts
rename to src/adapters/out/render/crop/Crop.ts
index 9ebf87f2..86e8d7b4 100644
--- a/src/domain/views/render/Crop.ts
+++ b/src/adapters/out/render/crop/Crop.ts
@@ -1,13 +1,13 @@
-import { cx, cy } from '../../diagram/utils/geom.js';
-import type { Plane } from '../../diagram/di/types.js';
-import type { Semantics } from '../../diagram/di/Semantics.js';
-import type { Doc } from '../../document/Doc.js';
-import { is } from '../../document/utils/moddle.js';
-import { AroundWindow } from '../AroundWindow.js';
-import { ElementSelector } from '../../diagram/commands/ElementSelector.js';
-import { modelError, usageError } from '../../document/utils/errors.js';
-import type { RenderOptions } from './types.js';
-import { planeOfId } from '../utils/planes.js';
+import { cx, cy } from '../../../../domain/diagram/utils/geom.js';
+import type { Plane } from '../../../../domain/diagram/di/types.js';
+import type { Semantics } from '../../../../domain/diagram/di/Semantics.js';
+import type { Doc } from '../../../../domain/document/Doc.js';
+import { is } from '../../../../domain/document/utils/moddle.js';
+import { AroundWindow } from '../../../../domain/views/AroundWindow.js';
+import { ElementSelector } from '../../../../domain/diagram/commands/ElementSelector.js';
+import { modelError, usageError } from '../../../../domain/document/utils/errors.js';
+import type { RenderOptions } from '../../../../application/port/in/RenderSvgUseCase.js';
+import { planeOfId } from './planes.js';
/** The crop the options ask for and the plane it implies. */
export interface CropChoice {
diff --git a/src/domain/views/utils/planes.ts b/src/adapters/out/render/crop/planes.ts
similarity index 68%
rename from src/domain/views/utils/planes.ts
rename to src/adapters/out/render/crop/planes.ts
index 2ac0b605..8fcc2b5f 100644
--- a/src/domain/views/utils/planes.ts
+++ b/src/adapters/out/render/crop/planes.ts
@@ -1,3 +1,3 @@
-import type { Plane } from '../../diagram/di/types.js';
+import type { Plane } from '../../../../domain/diagram/di/types.js';
export const planeOfId = (planes: Plane[], id: string): Plane | undefined => planes.find((plane) => plane.shapes.has(id) || plane.edges.has(id));
diff --git a/src/domain/views/render/EdgePainter.ts b/src/adapters/out/render/paint/EdgePainter.ts
similarity index 95%
rename from src/domain/views/render/EdgePainter.ts
rename to src/adapters/out/render/paint/EdgePainter.ts
index 3091a905..ce882362 100644
--- a/src/domain/views/render/EdgePainter.ts
+++ b/src/adapters/out/render/paint/EdgePainter.ts
@@ -1,11 +1,12 @@
-import type { Box, Point } from '../../diagram/utils/geom.js';
-import { cx, unionAll } from '../../diagram/utils/geom.js';
-import { raw } from '../../diagram/utils/raw.js';
-import type { DEdge } from '../../diagram/di/types.js';
-import { is, type El } from '../../document/utils/moddle.js';
+import type { Box, Point } from '../../../../domain/diagram/utils/geom.js';
+import { cx, unionAll } from '../../../../domain/diagram/utils/geom.js';
+import { raw } from '../../../../domain/diagram/utils/raw.js';
+import type { DEdge } from '../../../../domain/diagram/di/types.js';
+import { is, type El } from '../../../../domain/document/utils/moddle.js';
import { LabelPainter } from './LabelPainter.js';
import { Style } from './Style.js';
-import { HALO_COLORS, type Item } from './types.js';
+import { HALO_COLORS } from '../../../../application/port/in/RenderSvgUseCase.js';
+import type { Item } from '../types.js';
import { pointsBox } from '../utils/boxes.js';
import { attr, FILL, formatNumber } from '../utils/svg.js';
diff --git a/src/domain/views/render/LabelPainter.ts b/src/adapters/out/render/paint/LabelPainter.ts
similarity index 89%
rename from src/domain/views/render/LabelPainter.ts
rename to src/adapters/out/render/paint/LabelPainter.ts
index 6301bc07..ee40c091 100644
--- a/src/domain/views/render/LabelPainter.ts
+++ b/src/adapters/out/render/paint/LabelPainter.ts
@@ -1,7 +1,7 @@
-import { cx } from '../../diagram/utils/geom.js';
-import type { Box, Point } from '../../diagram/utils/geom.js';
-import { textWidth, wrapLines } from '../../diagram/utils/text.js';
-import { LAYER, type Item } from './types.js';
+import { cx } from '../../../../domain/diagram/utils/geom.js';
+import type { Box, Point } from '../../../../domain/diagram/utils/geom.js';
+import { textWidth, wrapLines } from '../../../../domain/diagram/utils/text.js';
+import { LAYER, type Item } from '../types.js';
import { attr, lines, SIZE } from '../utils/svg.js';
/** external labels (bpmn-js: 11 px, 90 px wide, line height 1.2) */
diff --git a/src/domain/views/render/Scene.ts b/src/adapters/out/render/paint/Scene.ts
similarity index 94%
rename from src/domain/views/render/Scene.ts
rename to src/adapters/out/render/paint/Scene.ts
index 11f2c9e1..6b3d8e5b 100644
--- a/src/domain/views/render/Scene.ts
+++ b/src/adapters/out/render/paint/Scene.ts
@@ -1,9 +1,10 @@
-import type { Point } from '../../diagram/utils/geom.js';
-import { grow } from '../../diagram/utils/geom.js';
-import type { DShape, Plane } from '../../diagram/di/types.js';
+import type { Point } from '../../../../domain/diagram/utils/geom.js';
+import { grow } from '../../../../domain/diagram/utils/geom.js';
+import type { DShape, Plane } from '../../../../domain/diagram/di/types.js';
import { EdgePainter } from './EdgePainter.js';
import { ShapePainter } from './ShapePainter.js';
-import { HALO_COLORS, LAYER, type Item } from './types.js';
+import { HALO_COLORS } from '../../../../application/port/in/RenderSvgUseCase.js';
+import { LAYER, type Item } from '../types.js';
import { pointsBox } from '../utils/boxes.js';
import { attr, formatNumber } from '../utils/svg.js';
diff --git a/src/domain/views/render/ShapePainter.ts b/src/adapters/out/render/paint/ShapePainter.ts
similarity index 97%
rename from src/domain/views/render/ShapePainter.ts
rename to src/adapters/out/render/paint/ShapePainter.ts
index 594ac490..56ce379b 100644
--- a/src/domain/views/render/ShapePainter.ts
+++ b/src/adapters/out/render/paint/ShapePainter.ts
@@ -1,12 +1,13 @@
-import type { Box } from '../../diagram/utils/geom.js';
-import { grow } from '../../diagram/utils/geom.js';
-import { raw, rawList } from '../../diagram/utils/raw.js';
-import type { DShape } from '../../diagram/di/types.js';
-import { titleBox, textWidth } from '../../diagram/utils/text.js';
-import { groupName, is, type El } from '../../document/utils/moddle.js';
+import type { Box } from '../../../../domain/diagram/utils/geom.js';
+import { grow } from '../../../../domain/diagram/utils/geom.js';
+import { raw, rawList } from '../../../../domain/diagram/utils/raw.js';
+import type { DShape } from '../../../../domain/diagram/di/types.js';
+import { titleBox, textWidth } from '../../../../domain/diagram/utils/text.js';
+import { groupName, is, type El } from '../../../../domain/document/utils/moddle.js';
import { LabelPainter } from './LabelPainter.js';
import { Style } from './Style.js';
-import { HALO_COLORS, type Item } from './types.js';
+import { HALO_COLORS } from '../../../../application/port/in/RenderSvgUseCase.js';
+import type { Item } from '../types.js';
import { at, formatNumber } from '../utils/svg.js';
const TIMER_TICKS = 'M0 -7.6L0 -9.8M3.8 -6.6L4.9 -8.5M6.6 -3.8L8.5 -4.9M7.6 0L9.8 0M6.6 3.8L8.5 4.9M3.8 6.6L4.9 8.5M0 7.6L0 9.8M-3.8 6.6L-4.9 8.5M-6.6 3.8L-8.5 4.9M-7.6 0L-9.8 0M-6.6 -3.8L-8.5 -4.9M-3.8 -6.6L-4.9 -8.5';
diff --git a/src/domain/views/render/Style.ts b/src/adapters/out/render/paint/Style.ts
similarity index 90%
rename from src/domain/views/render/Style.ts
rename to src/adapters/out/render/paint/Style.ts
index 53e17c45..802b3f2a 100644
--- a/src/domain/views/render/Style.ts
+++ b/src/adapters/out/render/paint/Style.ts
@@ -1,5 +1,5 @@
-import { raw } from '../../diagram/utils/raw.js';
-import type { El } from '../../document/utils/moddle.js';
+import { raw } from '../../../../domain/diagram/utils/raw.js';
+import type { El } from '../../../../domain/document/utils/moddle.js';
import { FILL, STROKE } from '../utils/svg.js';
/** The colour attributes of a DI element as bpmn-js reads them: color: first, then bioc:. */
diff --git a/src/adapters/out/render/types.ts b/src/adapters/out/render/types.ts
new file mode 100644
index 00000000..a4a43d6e
--- /dev/null
+++ b/src/adapters/out/render/types.ts
@@ -0,0 +1,39 @@
+import type { Box } from '../../../domain/diagram/utils/geom.js';
+import type { LayoutProblem } from '../../../domain/diagram/utils/problems.js';
+import type { Plane } from '../../../domain/diagram/di/types.js';
+import type { El } from '../../../domain/document/utils/moddle.js';
+
+/** A drawing that needs more tiles stays one image, with a note. */
+export const MAX_TILES = 40;
+/** Coordinates beyond this (or not a number) are broken DI and not drawn. */
+export const COORD_LIMIT = 1_000_000;
+
+export const MARGIN = 20;
+
+export interface Item {
+ box: Box;
+ layer: number;
+ svg: string;
+ id?: string;
+ /** drawn when in view, but the view is not made to fit them */
+ extra?: boolean;
+ /** fitted instead of `box`, e.g. a faded connection's first 40 px */
+ fit?: Box;
+ tile?: number;
+ /** what makes a tile worth keeping; absent for connections, context and overlays */
+ content?: Box;
+}
+
+export const LAYER = { participant: 0, lane: 1, subProcess: 2, leaf: 3, group: 4, boundary: 5, edge: 6, label: 7, badge: 8 } as const;
+
+export interface RenderFrame {
+ plane: string;
+ planes: string[];
+ notes: string[];
+ located?: Plane;
+ defs?: El;
+ /** a file drawn in memory: its own layout problems, without badges */
+ fileProblems?: LayoutProblem[];
+ drawn?: Set;
+ nodes?: string[];
+}
diff --git a/src/domain/views/utils/boxes.ts b/src/adapters/out/render/utils/boxes.ts
similarity index 80%
rename from src/domain/views/utils/boxes.ts
rename to src/adapters/out/render/utils/boxes.ts
index af4c80f1..e3ab523f 100644
--- a/src/domain/views/utils/boxes.ts
+++ b/src/adapters/out/render/utils/boxes.ts
@@ -1,4 +1,4 @@
-import { unionAll, type Box, type Point } from '../../diagram/utils/geom.js';
+import { unionAll, type Box, type Point } from '../../../../domain/diagram/utils/geom.js';
export function meets(first: Box, second: Box): boolean {
return first.x <= second.x + second.width && second.x <= first.x + first.width && first.y <= second.y + second.height && second.y <= first.y + first.height;
diff --git a/src/domain/views/utils/svg.ts b/src/adapters/out/render/utils/svg.ts
similarity index 100%
rename from src/domain/views/utils/svg.ts
rename to src/adapters/out/render/utils/svg.ts
diff --git a/src/application/port/in/RenderDiagramUseCase.ts b/src/application/port/in/RenderDiagramUseCase.ts
index a89b9583..f209823b 100644
--- a/src/application/port/in/RenderDiagramUseCase.ts
+++ b/src/application/port/in/RenderDiagramUseCase.ts
@@ -1,4 +1,5 @@
import type { ImageRequest, Shot } from '../out/WriteImagePort.js';
+import type { RenderOptions } from './RenderSvgUseCase.js';
/**
* `bpmn render` and `--render`:
@@ -12,7 +13,8 @@ export interface RenderDiagramUseCase {
export declare namespace RenderDiagramUseCase {
/** The document to draw and the image request for it. */
- interface Command extends Omit {
+ interface Command extends Omit {
+ opts: RenderOptions;
source: { file: string } | { bytes: Uint8Array; name: string } | { xml: string };
}
diff --git a/src/application/port/in/RenderSvgUseCase.ts b/src/application/port/in/RenderSvgUseCase.ts
index 0f6932e1..f0f2878f 100644
--- a/src/application/port/in/RenderSvgUseCase.ts
+++ b/src/application/port/in/RenderSvgUseCase.ts
@@ -1,5 +1,84 @@
import type { Doc } from '../../../domain/document/Doc.js';
-import type { RenderOptions, RenderResult } from '../../../domain/views/render/types.js';
+import type { LayoutProblem, MetricKey } from '../../../domain/diagram/utils/problems.js';
+
+/** The CLI's `--render` colours created, changed and moved elements apart. */
+export interface RenderHighlight {
+ /** green */
+ created?: string[];
+ /** orange: the model or a colour changed */
+ changed?: string[];
+ /** blue: placed, moved or rerouted by the layout */
+ moved?: string[];
+}
+
+/** Options of renderSvg / renderDocSvg / renderPng; at most one crop option. */
+export interface RenderOptions {
+ /** a collapsed sub-process, process or collaboration; default: the first diagram or the crop's */
+ plane?: string;
+ scale?: number;
+ /** longest edge in px; a larger image is scaled down to it */
+ maxSize?: number;
+ /** crop: exactly the flow nodes of `show --around` */
+ around?: { id: string; depth?: number; inner?: boolean };
+ /** crop: the shortest sequence-flow path */
+ path?: { from: string; to: string; via?: string[] };
+ /** crop: a pool, lane or sub-process with what is drawn inside it */
+ scope?: string;
+ ids?: string[];
+ /** their plane, cut to the region around them when the whole would be unreadable */
+ focus?: string[];
+ highlight?: string[] | RenderHighlight;
+ /** numbered badges: true = every layout problem, or exactly these */
+ problems?: boolean | readonly LayoutProblem[];
+ /** split an unreadable image into tiles at the diagram's columns */
+ tiles?: boolean;
+}
+
+export interface RenderProblem {
+ n: number;
+ kind: MetricKey;
+ ids: string[];
+ detail?: string;
+ /** false: on another plane, outside the crop, or nowhere to draw */
+ drawn: boolean;
+ tile?: number;
+}
+
+export interface RenderTile {
+ svg: string;
+ width: number;
+ height: number;
+ tokens: number;
+ /** the plane's columns it spans; absent when it holds no flow node */
+ columns?: [number, number];
+ band?: number;
+}
+
+export interface RenderResult {
+ /** with tiles: svg, width, height and tokens are the first tile's */
+ svg: string;
+ width: number;
+ height: number;
+ /** below `scale` when maxSize capped it */
+ scale: number;
+ /** width * height / 784, rounded */
+ tokens: number;
+ plane: string;
+ /** the other planes: what `plane` can name */
+ planes: string[];
+ notes: string[];
+ problems: RenderProblem[];
+ /** with a crop: its flow nodes drawn */
+ nodes?: string[];
+ tiles?: RenderTile[];
+}
+
+/** What Claude Code displays. */
+export const RENDER_MAX_SIZE = 2000;
+/** Below this scale the 11-12 px labels drop under ~8 px and are hard to read. */
+export const MIN_LABEL_SCALE = 0.75;
+/** A plain highlight list uses `changed`. */
+export const HALO_COLORS = { created: '#2f9e44', changed: '#f76707', moved: '#1c7ed6' } as const;
/** `bpmn render --format svg` in memory: one plane of a document as SVG, nothing written. */
export interface RenderSvgUseCase {
diff --git a/src/application/port/out/RenderDiagramPort.ts b/src/application/port/out/RenderDiagramPort.ts
new file mode 100644
index 00000000..9f26dd88
--- /dev/null
+++ b/src/application/port/out/RenderDiagramPort.ts
@@ -0,0 +1,9 @@
+import type { LayoutProblem } from '../../../domain/diagram/utils/problems.js';
+import type { Doc } from '../../../domain/document/Doc.js';
+import type { RenderOptions, RenderResult } from '../in/RenderSvgUseCase.js';
+
+/** Draws one plane of a document as SVG; `fileProblems` are listed instead of the drawing's. */
+export interface RenderDiagramPort {
+
+ render(doc: Doc, opts: RenderOptions, fileProblems?: LayoutProblem[]): RenderResult;
+}
diff --git a/src/application/port/out/WriteImagePort.ts b/src/application/port/out/WriteImagePort.ts
index 53cf329a..7d4439f2 100644
--- a/src/application/port/out/WriteImagePort.ts
+++ b/src/application/port/out/WriteImagePort.ts
@@ -1,4 +1,4 @@
-import type { RenderOptions, RenderProblem } from '../../../domain/views/render/types.js';
+import type { RenderProblem, RenderResult } from '../in/RenderSvgUseCase.js';
export type ImageFormat = 'png' | 'svg';
@@ -30,8 +30,7 @@ export interface Shot {
export interface ImageRequest {
- xml: string;
- opts: RenderOptions;
+ drawn: RenderResult;
format: ImageFormat;
/**
@@ -44,7 +43,7 @@ export interface ImageRequest {
stdout: { write(bytes: string | Uint8Array): unknown };
}
-/** Renders a document as PNG or SVG and writes it to a file or stdout (`-`). */
+/** Writes a drawn document as PNG or SVG to a file or stdout (`-`). */
export interface WriteImagePort {
write(request: ImageRequest): Promise;
diff --git a/src/application/service/RenderDiagramService.ts b/src/application/service/RenderDiagramService.ts
index 0bbf0500..5bdb7b5c 100644
--- a/src/application/service/RenderDiagramService.ts
+++ b/src/application/service/RenderDiagramService.ts
@@ -1,16 +1,22 @@
import type { RenderDiagramUseCase } from '../port/in/RenderDiagramUseCase.js';
import type { DocumentPort } from '../port/out/DocumentPort.js';
+import type { RenderDiagramPort } from '../port/out/RenderDiagramPort.js';
import type { WriteImagePort } from '../port/out/WriteImagePort.js';
+import { DrawableDoc } from '../../domain/views/DrawableDoc.js';
export class RenderDiagramService implements RenderDiagramUseCase {
constructor(
private readonly documents: DocumentPort,
+ private readonly renderer: RenderDiagramPort,
private readonly images: WriteImagePort,
) {}
async render(command: RenderDiagramUseCase.Command): Promise {
- const { source, ...request } = command;
+ const { source, opts, ...request } = command;
const xml = await this.documents.text(source);
- return this.images.write({ xml, ...request });
+ const drawable = await DrawableDoc.fromXml(xml, !!opts.problems);
+ const result = this.renderer.render(drawable.doc, opts, drawable.fileProblems);
+ const drawn = { ...result, notes: [...drawable.notes, ...result.notes] };
+ return this.images.write({ drawn, ...request });
}
}
diff --git a/src/application/service/RenderSvgService.ts b/src/application/service/RenderSvgService.ts
index 7e4dafa3..318e1358 100644
--- a/src/application/service/RenderSvgService.ts
+++ b/src/application/service/RenderSvgService.ts
@@ -1,15 +1,19 @@
-import type { RenderSvgUseCase } from '../port/in/RenderSvgUseCase.js';
-import type { RenderResult } from '../../domain/views/render/types.js';
-import { DocRenderer } from '../../domain/views/render/DocRenderer.js';
+import type { RenderResult, RenderSvgUseCase } from '../port/in/RenderSvgUseCase.js';
+import type { RenderDiagramPort } from '../port/out/RenderDiagramPort.js';
+import { DrawableDoc } from '../../domain/views/DrawableDoc.js';
export class RenderSvgService implements RenderSvgUseCase {
- renderXml(command: RenderSvgUseCase.XmlCommand): Promise {
+ constructor(private readonly renderer: RenderDiagramPort) {}
+
+ async renderXml(command: RenderSvgUseCase.XmlCommand): Promise {
const { xml, ...opts } = command;
- return DocRenderer.renderXml(xml, opts);
+ const drawable = await DrawableDoc.fromXml(xml, !!opts.problems);
+ const result = this.renderer.render(drawable.doc, opts, drawable.fileProblems);
+ return { ...result, notes: [...drawable.notes, ...result.notes] };
}
renderDoc(command: RenderSvgUseCase.DocCommand): RenderResult {
const { doc, ...opts } = command;
- return new DocRenderer(doc, opts).render();
+ return this.renderer.render(doc, opts);
}
}
diff --git a/src/cli.ts b/src/cli.ts
index 8c09dfc7..246bd019 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -4,6 +4,7 @@ import { Cli } from './adapters/in/cli/Cli.js';
import { BpmnlintProjectSettings } from './adapters/out/files/BpmnlintProjectSettings.js';
import { FileDocumentStore } from './adapters/out/files/FileDocumentStore.js';
import { FileImageWriter } from './adapters/out/files/FileImageWriter.js';
+import { SvgRenderer } from './adapters/out/render/SvgRenderer.js';
import { EditDocumentService } from './application/service/EditDocumentService.js';
import { CreateDocumentService } from './application/service/CreateDocumentService.js';
import { RelayoutDiagramService } from './application/service/RelayoutDiagramService.js';
@@ -49,7 +50,7 @@ const cli = new Cli(VERSION, {
findElements: new FindElementsService(),
analyzeLayout: new AnalyzeLayoutService(),
runCommands: new RunCommandsService(),
- renderDiagram: new RenderDiagramService(files, new FileImageWriter()),
+ renderDiagram: new RenderDiagramService(files, new SvgRenderer(), new FileImageWriter()),
});
void cli.run(process.argv).then(() => {
process.exitCode = cli.exitCode;
diff --git a/src/domain/views/DrawableDoc.ts b/src/domain/views/DrawableDoc.ts
new file mode 100644
index 00000000..ad33886a
--- /dev/null
+++ b/src/domain/views/DrawableDoc.ts
@@ -0,0 +1,24 @@
+import { Doc } from '../document/Doc.js';
+import { FullLayout } from '../diagram/layout/FullLayout.js';
+import { LayoutAnalysis } from '../diagram/di/LayoutAnalysis.js';
+import type { LayoutProblem } from '../diagram/utils/problems.js';
+import { DiagramReader } from '../diagram/di/DiagramReader.js';
+
+/** A document ready to draw: one without any diagram is laid out in memory first; its problems stay the file's. */
+export class DrawableDoc {
+ private constructor(
+ readonly doc: Doc,
+ readonly notes: string[],
+ readonly fileProblems?: LayoutProblem[],
+ ) {}
+
+ static async fromXml(xml: string, withProblems: boolean): Promise {
+ const doc = await Doc.fromXml(xml);
+ if (new DiagramReader(doc.definitions).planes().length) return new DrawableDoc(doc, []);
+ const fileProblems = withProblems ? LayoutAnalysis.of(doc.definitions).problems : [];
+ const { xml: drawn } = await new FullLayout(doc.model).run();
+ const notes = ['the file has no diagram: it is drawn by the layout engine in memory (nothing is written; `bpmn layout ` saves a drawing)'];
+ if (withProblems) notes.push("the problems are the file's (`bpmn metrics `), not the in-memory drawing's: none is in the image");
+ return new DrawableDoc(await Doc.fromXml(drawn), notes, fileProblems);
+ }
+}
diff --git a/src/domain/views/render/types.ts b/src/domain/views/render/types.ts
deleted file mode 100644
index 6a809cd9..00000000
--- a/src/domain/views/render/types.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import type { Box } from '../../diagram/utils/geom.js';
-import type { LayoutProblem, MetricKey } from '../../diagram/utils/problems.js';
-import type { Plane } from '../../diagram/di/types.js';
-import type { El } from '../../document/utils/moddle.js';
-
-/** The CLI's `--render` colours created, changed and moved elements apart. */
-export interface RenderHighlight {
- /** green */
- created?: string[];
- /** orange: the model or a colour changed */
- changed?: string[];
- /** blue: placed, moved or rerouted by the layout */
- moved?: string[];
-}
-
-/** Options of renderSvg / renderDocSvg / renderPng; at most one crop option. */
-export interface RenderOptions {
- /** a collapsed sub-process, process or collaboration; default: the first diagram or the crop's */
- plane?: string;
- scale?: number;
- /** longest edge in px; a larger image is scaled down to it */
- maxSize?: number;
- /** crop: exactly the flow nodes of `show --around` */
- around?: { id: string; depth?: number; inner?: boolean };
- /** crop: the shortest sequence-flow path */
- path?: { from: string; to: string; via?: string[] };
- /** crop: a pool, lane or sub-process with what is drawn inside it */
- scope?: string;
- ids?: string[];
- /** their plane, cut to the region around them when the whole would be unreadable */
- focus?: string[];
- highlight?: string[] | RenderHighlight;
- /** numbered badges: true = every layout problem, or exactly these */
- problems?: boolean | readonly LayoutProblem[];
- /** split an unreadable image into tiles at the diagram's columns */
- tiles?: boolean;
-}
-
-export interface RenderProblem {
- n: number;
- kind: MetricKey;
- ids: string[];
- detail?: string;
- /** false: on another plane, outside the crop, or nowhere to draw */
- drawn: boolean;
- tile?: number;
-}
-
-export interface RenderTile {
- svg: string;
- width: number;
- height: number;
- tokens: number;
- /** the plane's columns it spans; absent when it holds no flow node */
- columns?: [number, number];
- band?: number;
-}
-
-export interface RenderResult {
- /** with tiles: svg, width, height and tokens are the first tile's */
- svg: string;
- width: number;
- height: number;
- /** below `scale` when maxSize capped it */
- scale: number;
- /** width * height / 784, rounded */
- tokens: number;
- plane: string;
- /** the other planes: what `plane` can name */
- planes: string[];
- notes: string[];
- problems: RenderProblem[];
- /** with a crop: its flow nodes drawn */
- nodes?: string[];
- tiles?: RenderTile[];
-}
-
-/** What Claude Code displays. */
-export const RENDER_MAX_SIZE = 2000;
-/** Below this scale the 11-12 px labels drop under ~8 px and are hard to read. */
-export const MIN_LABEL_SCALE = 0.75;
-/** A plain highlight list uses `changed`. */
-export const HALO_COLORS = { created: '#2f9e44', changed: '#f76707', moved: '#1c7ed6' } as const;
-/** A drawing that needs more tiles stays one image, with a note. */
-export const MAX_TILES = 40;
-/** Coordinates beyond this (or not a number) are broken DI and not drawn. */
-export const COORD_LIMIT = 1_000_000;
-
-export const MARGIN = 20;
-
-export interface Item {
- box: Box;
- layer: number;
- svg: string;
- id?: string;
- /** drawn when in view, but the view is not made to fit them */
- extra?: boolean;
- /** fitted instead of `box`, e.g. a faded connection's first 40 px */
- fit?: Box;
- tile?: number;
- /** what makes a tile worth keeping; absent for connections, context and overlays */
- content?: Box;
-}
-
-export const LAYER = { participant: 0, lane: 1, subProcess: 2, leaf: 3, group: 4, boundary: 5, edge: 6, label: 7, badge: 8 } as const;
-
-export interface RenderFrame {
- plane: string;
- planes: string[];
- notes: string[];
- located?: Plane;
- defs?: El;
- /** a file drawn in memory: its own layout problems, without badges */
- fileProblems?: LayoutProblem[];
- drawn?: Set;
- nodes?: string[];
-}
diff --git a/src/index.ts b/src/index.ts
index 0e010f17..2e708850 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,7 +25,7 @@ import type { PreservedText } from './domain/document/xml/TextPreservation.js';
import { BpmnlintValidator, type BpmnlintValidatorOptions } from './adapters/out/bpmnlint/BpmnlintValidator.js';
import { BpmnlintConfigResolution } from './adapters/out/bpmnlint/BpmnlintConfigResolution.js';
import { IdConventionsReader, type IdNamingHelpers } from './adapters/out/bpmnlint/IdConventionsReader.js';
-import type { RenderOptions, RenderResult } from './domain/views/render/types.js';
+import type { RenderOptions, RenderResult } from './application/port/in/RenderSvgUseCase.js';
import type { AroundOptions, AroundView, ElementContext, Implementation } from './domain/views/around-view.js';
import type { ElementDetail, FindHit, ModelView } from './domain/views/model-view.js';
import type { LayoutView } from './domain/views/layout-view.js';
@@ -36,6 +36,7 @@ import { ShowConventionsService } from './application/service/ShowConventionsSer
import { RunCommandsService } from './application/service/RunCommandsService.js';
import { AnalyzeLayoutService } from './application/service/AnalyzeLayoutService.js';
import { RenderSvgService } from './application/service/RenderSvgService.js';
+import { SvgRenderer } from './adapters/out/render/SvgRenderer.js';
import { EditDocumentService } from './application/service/EditDocumentService.js';
import { RelayoutDiagramService } from './application/service/RelayoutDiagramService.js';
import { NoProjectSettings } from './adapters/out/memory/NoProjectSettings.js';
@@ -175,12 +176,12 @@ export function layoutView(defs: El): LayoutView { return new ShowModelService()
export type { DiagramCommandResult as FormatResult, DiagramCommandEntry as FormatEntry } from './domain/diagram/commands/DiagramCommands.js';
export { SWATCHES, type SwatchName } from './domain/diagram/utils/colors.js';
/* the drawing as SVG; PNG: renderPng in @miragon/bpmn-cli/node */
-export { RENDER_MAX_SIZE, MIN_LABEL_SCALE, HALO_COLORS, type RenderOptions, type RenderResult, type RenderHighlight, type RenderProblem, type RenderTile } from './domain/views/render/types.js';
+export { RENDER_MAX_SIZE, MIN_LABEL_SCALE, HALO_COLORS, type RenderOptions, type RenderResult, type RenderHighlight, type RenderProblem, type RenderTile } from './application/port/in/RenderSvgUseCase.js';
export { changeHighlight } from './adapters/in/report/utils/highlight.js';
/** `bpmn render --format svg`: one plane of the document as SVG. */
-export function renderSvg(xml: string, opts: RenderOptions = {}): Promise { return new RenderSvgService().renderXml({ ...opts, xml }); }
+export function renderSvg(xml: string, opts: RenderOptions = {}): Promise { return new RenderSvgService(new SvgRenderer()).renderXml({ ...opts, xml }); }
/** One plane of a parsed document as SVG (no layout). */
-export function renderDocSvg(doc: Doc, opts: RenderOptions = {}): RenderResult { return new RenderSvgService().renderDoc({ ...opts, doc }); }
+export function renderDocSvg(doc: Doc, opts: RenderOptions = {}): RenderResult { return new RenderSvgService(new SvgRenderer()).renderDoc({ ...opts, doc }); }
/** The extension elements of an element, in order. */
diff --git a/src/node.ts b/src/node.ts
index b4c9c1bb..02d242f1 100644
--- a/src/node.ts
+++ b/src/node.ts
@@ -5,7 +5,9 @@ import type { Doc } from './domain/document/Doc.js';
import type { EditCommand } from './domain/commands/types.js';
import type { CheckResult } from './application/port/in/ValidateDocumentUseCase.js';
import type { MutationResult } from './domain/batch/types.js';
-import type { RenderOptions } from './domain/views/render/types.js';
+import { RENDER_MAX_SIZE, type RenderOptions } from './application/port/in/RenderSvgUseCase.js';
+import { RenderSvgService } from './application/service/RenderSvgService.js';
+import { SvgRenderer } from './adapters/out/render/SvgRenderer.js';
import { PngRenderer, type PngRenderResult } from './adapters/out/files/PngRenderer.js';
import type { BpmnlintEngine } from './adapters/out/bpmnlint/bpmnlint-contract.js';
import type { NamedValidator, Validator } from './domain/validation/validators.js';
@@ -70,6 +72,7 @@ export function projectBpmnlintSettings(file: string | undefined, opts: FileBpmn
export function projectIdConventions(file: string | undefined, opts: FileBpmnlintOptions = {}): Promise { return new ProjectBpmnlint(file, opts).idConventions(); }
/** `bpmn render` (PNG): one plane of the document, a part of it or its tiles as PNG images. */
-export function renderPng(xml: string, opts: RenderOptions = {}): Promise {
- return new PngRenderer().render(xml, opts);
+export async function renderPng(xml: string, opts: RenderOptions = {}): Promise {
+ const drawn = await new RenderSvgService(new SvgRenderer()).renderXml({ ...opts, maxSize: opts.maxSize ?? RENDER_MAX_SIZE, xml });
+ return new PngRenderer().render(drawn);
}
diff --git a/test/domain/views/render/render-agent.test.ts b/test/adapters/out/render/render-agent.test.ts
similarity index 100%
rename from test/domain/views/render/render-agent.test.ts
rename to test/adapters/out/render/render-agent.test.ts
diff --git a/test/domain/views/render/render-fixes.test.ts b/test/adapters/out/render/render-fixes.test.ts
similarity index 99%
rename from test/domain/views/render/render-fixes.test.ts
rename to test/adapters/out/render/render-fixes.test.ts
index 75cb55c9..f3cf75ab 100644
--- a/test/domain/views/render/render-fixes.test.ts
+++ b/test/adapters/out/render/render-fixes.test.ts
@@ -15,7 +15,7 @@ import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Doc } from '../../../../src/domain/document/Doc.js';
import { MIN_LABEL_SCALE, renderSvg } from '../../../../src/index.js';
-import { MAX_TILES } from '../../../../src/domain/views/render/types.js';
+import { MAX_TILES } from '../../../../src/adapters/out/render/types.js';
import { scaleText } from '../../../../src/domain/views/utils/scale.js';
import { textWidth } from '../../../../src/domain/diagram/utils/text.js';
import { PngRenderer } from '../../../../src/adapters/out/files/PngRenderer.js';
diff --git a/test/architecture.test.ts b/test/architecture.test.ts
index 672a3237..26cd2684 100644
--- a/test/architecture.test.ts
+++ b/test/architecture.test.ts
@@ -35,14 +35,14 @@ const sourceFiles = (readdirSync('src', { recursive: true }) as string[]).filter
const ENTRIES = ['src/api.ts', 'src/cli.ts', 'src/index.ts', 'src/node.ts'];
const DOMAIN_MODULES = ['document', 'commands', 'diagram', 'editing', 'validation', 'views', 'batch'];
-const ADAPTERS = ['in/cli', 'in/report', 'out/bpmnlint', 'out/document', 'out/files', 'out/memory'];
+const ADAPTERS = ['in/cli', 'in/report', 'out/bpmnlint', 'out/document', 'out/files', 'out/memory', 'out/render'];
/** adapter -> the adapters it may use: the CLI prints through the reports the library also returns, the files load the .bpmnlintrc bpmnlint runs, the files and memory stores share how a document is loaded and saved */
const ADAPTER_USES: Record = { 'in/cli': ['in/report'], 'out/files': ['out/bpmnlint', 'out/document'], 'out/memory': ['out/document'] };
/** the public API's classes (Doc, BpmnError, ChangeSet), constants and pure helpers on kinds, op names and metrics */
const DOMAIN_VALUES_ENTRIES_REEXPORT = [
'Doc', 'TARGETS', 'BpmnError', 'EXIT_CODES', 'ChangeSet', 'KINDS', 'parseKind', 'kindOf', 'kindLabel', 'DIAGRAM_COMMAND_NAMES', 'isDiagramCommand', 'LAYOUT_MODES',
'AROUND_DEFAULT_DEPTH', 'PLATFORM_CHOICES',
- 'diffProblems', 'metricsDelta', 'KEYS', 'WEIGHTS', 'SWATCHES', 'RENDER_MAX_SIZE', 'MIN_LABEL_SCALE', 'HALO_COLORS',
+ 'diffProblems', 'metricsDelta', 'KEYS', 'WEIGHTS', 'SWATCHES',
];
const inFolder =(file: string, folder: string) => file.startsWith(join('src', folder) + sep);
@@ -112,6 +112,17 @@ describe('architecture', () => {
}
});
+ it('the renderer stays out of the domain: no domain file draws SVG', () => {
+ expect(existsSync(join(SRC_DIR, 'domain', 'views', 'render'))).toBe(false);
+ const drawing = sourceFiles.filter((file) => inFolder(file, 'domain') && /