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') && / { + const offenders = sourceFiles.filter((file) => inFolder(file, join('adapters', 'out', 'render')) && importsOf(file).some((specifier) => specifier.startsWith('node:'))); + expect(offenders).toEqual([]); + }); + it('the domain modules import each other without a cycle', () => { expect(cycleIn(domainModuleGraph())).toBeUndefined(); }); diff --git a/test/e2e/render.test.ts b/test/e2e/render.test.ts index 90664118..f05e12d9 100644 --- a/test/e2e/render.test.ts +++ b/test/e2e/render.test.ts @@ -1,12 +1,12 @@ /** * `bpmn render` (issue #12): the SVG renderer of the core - * (src/domain/views/render/DocRenderer.ts), the PNG rasteriser of the node layer + * (src/adapters/out/render/DocRenderer.ts), the PNG rasteriser of the node layer * (src/adapters/out/files/PngRenderer.ts: resvg-wasm with the bundled Arimo font) and the * command. Every scenario and fixture renders to SVG and PNG with the same * bytes twice; labels are laid out like bpmn-js; a rasteriser failure is * E_RENDER, never a process abort. The agent features (crops, halos, * numbered problems, tiles, `--render`) are tested in - * test/domain/views/render/render-agent.test.ts. Synthetic models only. + * test/adapters/out/render/render-agent.test.ts. Synthetic models only. */ import { spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; @@ -152,7 +152,7 @@ describe('renderSvg', () => { it('lays out external labels like bpmn-js 18: label bounds, max(90, their width) in 11 px then the widest line, 13.2 px lines', async () => { const { svg } = await renderSvg(LABELS); // the start event: centred on its label bounds (x 73, width 90), top at y 143, first baseline 3/4 line lower - // (more cases of the two passes: test/domain/views/render/render-fixes.test.ts) + // (more cases of the two passes: test/adapters/out/render/render-fixes.test.ts) const expected = ['A rather long', 'start event name', 'that wraps']; const start = labelOf(svg, 'Event_Start'); expect(start.map((line) => line.text)).toEqual(expected); @@ -245,7 +245,7 @@ describe('renderSvg', () => { expect(lean.chunks.flatMap((chunk: { inputs: string[] }) => chunk.inputs).filter((input: string) => /render/.test(input))).toEqual([]); // (not vacuous: an entry that imports renderSvg has it) const render = await bundleCore({ stdin: { contents: "export { renderSvg } from './src/index.ts';", sourcefile: 'render-only.ts' }, minify: false }); - expect(render.chunks.flatMap((chunk: { inputs: string[] }) => chunk.inputs)).toContain('src/domain/views/render/DocRenderer.ts'); + expect(render.chunks.flatMap((chunk: { inputs: string[] }) => chunk.inputs)).toContain('src/adapters/out/render/DocRenderer.ts'); }, 60000); }); diff --git a/test/helpers/helpers.ts b/test/helpers/helpers.ts index 304ca241..e2de206b 100644 --- a/test/helpers/helpers.ts +++ b/test/helpers/helpers.ts @@ -3,6 +3,7 @@ import type { CliUseCases } from '../../src/adapters/in/cli/Cli.js'; import { BpmnlintProjectSettings } from '../../src/adapters/out/files/BpmnlintProjectSettings.js'; import { FileDocumentStore } from '../../src/adapters/out/files/FileDocumentStore.js'; import { FileImageWriter } from '../../src/adapters/out/files/FileImageWriter.js'; +import { SvgRenderer } from '../../src/adapters/out/render/SvgRenderer.js'; import { EditDocumentService } from '../../src/application/service/EditDocumentService.js'; import { CreateDocumentService } from '../../src/application/service/CreateDocumentService.js'; import { RelayoutDiagramService } from '../../src/application/service/RelayoutDiagramService.js'; @@ -30,7 +31,7 @@ export const FILE_USE_CASES: CliUseCases = { findElements: new FindElementsService(), analyzeLayout: new AnalyzeLayoutService(), runCommands: new RunCommandsService(), - renderDiagram: new RenderDiagramService(files, new FileImageWriter()), + renderDiagram: new RenderDiagramService(files, new SvgRenderer(), new FileImageWriter()), }; export const BPMN_NS = 'http://www.omg.org/spec/BPMN/20100524/MODEL';