From a906a4ef1a12a526089a3c1ae95ba85cff3440d9 Mon Sep 17 00:00:00 2001 From: Thomas Bouffard <27200110+tbouffard@users.noreply.github.com> Date: Sat, 16 May 2026 07:22:24 +0200 Subject: [PATCH 1/3] refactor: introduce internal extension mechanism for BPMN extensions Add a generic mechanism for managing BPMN extensions in bpmn-visualization, and migrate the existing hardcoded BPMN in Color implementation as its first user. The scope is an internal refactoring to validate the extension mechanism: existing public interfaces of DiagramConverter, StyleComputer and BpmnVisualization are unchanged, the built-in extension is hardcoded in the components that consume it, and external injection of extensions is deferred to follow-up work. Code: - New extension point interfaces in src/component/extension/extension-points.ts: - ParsingExtensionPoint: onShapeDeserialized, onEdgeDeserialized, hasLabelExtensionData - StyleExtensionPoint: enrichShapeStyle, enrichEdgeStyle, enrichMessageFlowIconStyle - New BPMN in Color extension at src/component/extension/bpmn-in-color/: - types.ts: module augmentation on JSON model (BPMNShape, BPMNEdge, BPMNLabel) and on internal model (ShapeExtensions, EdgeExtensions, LabelExtensions). Uses `export {}` so the file remains a module under any tsconfig setting (verbatimModuleSyntax-safe). - parsing-extension.ts: logic moved from setColorExtensionsOnShape/Edge in DiagramConverter. - style-extension.ts: color-to-mxGraph-style mapping moved from StyleComputer, including the previously hardcoded message flow icon stroke color. - ShapeExtensions / EdgeExtensions / LabelExtensions converted from type aliases to empty interfaces. - DiagramConverter and StyleComputer call registered extension points; no BPMN in Color logic remains in the core. Documentation: - New ADR at docs/contributors/adr/001-bpmn-extensions-management.md documenting the data layers, the pipeline phases, the two extension point interfaces, the scope of this refactoring vs. follow-up work, the migration plan and open questions. - docs/contributors/README.md references the new ADR. Tests: - 30 dedicated unit tests at test/unit/component/extension/bpmn-in-color/ covering parsing (BPMN in Color spec attributes, bpmn.io fallbacks, spec-over-fallback priority, label color, hasLabelExtensionData table-driven cases, non-pollution of extensions when source values are absent) and style (shape, edge, message flow icon, pool/lane swimlane fill color, font color via label). - All existing tests pass without modification. --- .../01-bpmn-in-color-extension-poc/explore.md | 108 ++++++ .../01-bpmn-in-color-extension-poc/plan.md | 119 +++++++ .../tasks/index.md | 19 ++ .../tasks/task-01.md | 28 ++ .../tasks/task-02.md | 30 ++ .../tasks/task-03.md | 35 ++ docs/contributors/README.md | 12 +- .../adr/001-bpmn-extensions-management.md | 314 ++++++++++++++++++ .../bpmn-in-color/parsing-extension.ts | 54 +++ .../bpmn-in-color/style-extension.ts | 53 +++ .../extension/bpmn-in-color/types.ts | 69 ++++ src/component/extension/extension-points.ts | 91 +++++ .../mxgraph/renderer/StyleComputer.ts | 43 +-- .../parser/json/converter/DiagramConverter.ts | 43 +-- src/model/bpmn/internal/types.ts | 16 +- .../bpmn-in-color/parsing-extension.test.ts | 137 ++++++++ .../bpmn-in-color/style-extension.test.ts | 153 +++++++++ 17 files changed, 1253 insertions(+), 71 deletions(-) create mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/explore.md create mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/plan.md create mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/tasks/index.md create mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-01.md create mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-02.md create mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-03.md create mode 100644 docs/contributors/adr/001-bpmn-extensions-management.md create mode 100644 src/component/extension/bpmn-in-color/parsing-extension.ts create mode 100644 src/component/extension/bpmn-in-color/style-extension.ts create mode 100644 src/component/extension/bpmn-in-color/types.ts create mode 100644 src/component/extension/extension-points.ts create mode 100644 test/unit/component/extension/bpmn-in-color/parsing-extension.test.ts create mode 100644 test/unit/component/extension/bpmn-in-color/style-extension.test.ts diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/explore.md b/.claude/tasks/01-bpmn-in-color-extension-poc/explore.md new file mode 100644 index 0000000000..83b54ad6a3 --- /dev/null +++ b/.claude/tasks/01-bpmn-in-color-extension-poc/explore.md @@ -0,0 +1,108 @@ +# Task: PoC — Manage BPMN in Color as extension + +Based on the ADR `docs/contributors/adr/001-bpmn-extensions-management.md`, implement the minimal extension mechanism to refactor BPMN in Color as a built-in extension. + +## Codebase Context + +### Current BPMN in Color implementation + +**3 touchpoints** to refactor: + +#### 1. Parsing — DiagramConverter +`src/component/parser/json/converter/DiagramConverter.ts` +- `setColorExtensionsOnShape(shape, bpmnShape)` (lines 191-202): reads `background-color`/`fill` → `shape.extensions.fillColor`, `border-color`/`stroke` → `shape.extensions.strokeColor` +- `setColorExtensionsOnEdge(edge, bpmnEdge)` (lines 212-218): reads `border-color`/`stroke` → `edge.extensions.strokeColor` +- Label color handling inline in `deserializeLabel()` (lines 166-169): reads `color` → `label.extensions.color` +- Called from: `deserializeShape()` line 127, edge loop line 151, `deserializeLabel()` line 166 + +#### 2. Style computing — StyleComputer +`src/component/mxgraph/renderer/StyleComputer.ts` +- `computeShapeStyleValues()` lines 85-95: reads `shape.extensions.fillColor`/`strokeColor`, sets mxConstants. Special case for pools/lanes (`STYLE_SWIMLANE_FILLCOLOR`) +- `computeEdgeStyleValues()` lines 103-106: reads `edge.extensions.strokeColor` +- `computeFontStyleValues()` lines 121-124: reads `bpmnCell.label?.extensions.color` +- `computeMessageFlowIconStyle()` lines 132-134: reads `edge.extensions.strokeColor` +- All gated by `if (!this.ignoreBpmnColors)` flag +- `ignoreBpmnColors` defaults to `true` (colors disabled by default), set in constructor line 45 + +#### 3. Extension types — Internal model +`src/model/bpmn/internal/types.ts` +- `ShapeExtensions` (type alias, lines 20-23): `{ fillColor?: string; strokeColor?: string }` +- `EdgeExtensions` (type alias, lines 28-30): `{ strokeColor?: string }` +- `LabelExtensions` (type alias, lines 35-37): `{ color?: string }` +- Used in Shape (line 26), Edge (line 27), Label (line 24) as `readonly extensions: XxxExtensions = {}` +- **NOT publicly exported** — internal only + +### Initialization & wiring chain + +``` +BpmnVisualization(options: GlobalOptions) + ├─ options.renderer → this.rendererOptions + ├─ createNewBpmnGraph(container, rendererOptions) → BpmnGraph + └─ load(xml): + ├─ newBpmnParser(this.parserOptions).parse(xml) + │ ├─ BpmnXmlParser.parse(xml) → JSON + │ └─ BpmnJsonParser.parse(json) → BpmnModel + │ └─ DiagramConverter.deserialize() ← COLOR PARSING HERE + ├─ bpmnModelRegistry.load(bpmnModel) + └─ newBpmnRenderer(graph, rendererOptions).render(renderedModel) + └─ StyleComputer ← COLOR STYLE HERE +``` + +**Parser creation**: `newBpmnParser()` in `src/component/parser/BpmnParser.ts` lines 43-45. Creates `BpmnJsonParser` which creates `DiagramConverter`. + +**BpmnJsonParser factory**: `src/component/parser/json/BpmnJsonParser.ts` lines 57-67. Creates all 6 converters. `DiagramConverter` is one of them. + +**Renderer creation**: `newBpmnRenderer()` in `src/component/mxgraph/BpmnRenderer.ts` lines 162-164. Creates `StyleComputer(options)`. + +### GlobalOptions / RendererOptions +`src/component/options.ts` +- `GlobalOptions` (lines 23-32): `{ container, navigation?, parser?, renderer? }` +- `RendererOptions` (lines 201-267): `{ iconPainter?, ignoreActivityLabelBounds?, ignoreBpmnColors?, ignoreLabelStyles?, ignoreTaskLabelBounds? }` + +## Key Files + +- `src/model/bpmn/internal/types.ts` — Extension type definitions to migrate (type → interface) +- `src/model/bpmn/internal/shape/Shape.ts:26` — Shape.extensions property +- `src/model/bpmn/internal/edge/edge.ts:27` — Edge.extensions property +- `src/model/bpmn/internal/Label.ts:24` — Label.extensions property +- `src/component/parser/json/converter/DiagramConverter.ts:127,151,166-169,191-218` — Color parsing functions +- `src/component/mxgraph/renderer/StyleComputer.ts:40-47,85-95,103-106,121-124,132-134` — Color style computing +- `src/component/parser/json/BpmnJsonParser.ts:57-67` — Parser factory (where to inject parsing extension) +- `src/component/parser/BpmnParser.ts:43-45` — Top-level parser factory +- `src/component/mxgraph/BpmnRenderer.ts:162-164` — Renderer factory (where to inject style extension) +- `src/component/BpmnVisualization.ts:78-87,95-103` — Constructor and load() method +- `src/component/options.ts:23-32,201-267` — GlobalOptions and RendererOptions + +## Tests that must keep passing + +| Category | File | Focus | +|----------|------|-------| +| Unit: Parsing | `test/unit/component/parser/BpmnParser.test.ts` lines 58-297 | Color attribute parsing, bpmn.io fallback | +| Unit: Style | `test/unit/component/mxgraph/renderer/StyleComputer.test.ts` lines 537-610 | Color style computation, ignoreBpmnColors flag | +| Integration | `test/integration/mxGraph.model.bpmn.colors.test.ts` | Default behavior (colors ignored) | +| E2E | `test/e2e/bpmn.colors.test.ts` lines 150-170 | Visual regression with/without colors | + +**Test fixtures**: `test/fixtures/bpmn/bpmn-in-color/` and `test/fixtures/bpmn/xml-parsing/bpmn-in-color/` + +**Test helpers**: `test/unit/helpers/bpmn-model-expect.ts` (ExpectedShape/Edge/Label with extensions), `test/unit/helpers/JsonTestUtils.ts` (verifyLabel with extensions) + +## Patterns to Follow + +1. **Extension types are `readonly` on model objects** but contents are mutated after construction (e.g., `shape.extensions.fillColor = ...`) +2. **`ignoreBpmnColors` flag** gates all color application in StyleComputer — this must be preserved +3. **BPMN in Color supports fallback** to bpmn.io attributes (`fill`/`stroke` vs `background-color`/`border-color`) +4. **Pools/lanes have special handling**: fillColor also sets `STYLE_SWIMLANE_FILLCOLOR` +5. **Message flow icon** has separate style computation using `push()` instead of `set()` on a different data structure + +## Scope for PoC (minimal) + +Based on the ADR migration plan, the minimal PoC should: + +1. **Create extension point interfaces**: `ParsingExtensionPoint`, `StyleExtensionPoint`, `BpmnExtension` +2. **Migrate types**: `ShapeExtensions`, `EdgeExtensions`, `LabelExtensions` from type alias to empty interface +3. **Create built-in BPMN in Color extension** implementing the interfaces +4. **Wire into DiagramConverter**: call parsing extension points instead of hardcoded functions +5. **Wire into StyleComputer**: call style extension points instead of hardcoded color logic +6. **All existing tests must pass unchanged** + +NOT in scope: `RenderingExtensionPoint`, `BpmnVisualization` constructor API for custom extensions, `iconPainter` injection. diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/plan.md b/.claude/tasks/01-bpmn-in-color-extension-poc/plan.md new file mode 100644 index 0000000000..cd04fc3189 --- /dev/null +++ b/.claude/tasks/01-bpmn-in-color-extension-poc/plan.md @@ -0,0 +1,119 @@ +# Implementation Plan: PoC — BPMN in Color as Extension + +## Overview + +Refactor the hardcoded BPMN in Color support into the new extension mechanism defined in the ADR. This is a pure internal refactoring — no user-facing behavior change, no new public API for custom extensions. + +The approach: +1. Define extension point interfaces (internal) +2. Migrate extension types from type aliases to empty interfaces + module augmentation +3. Extract BPMN in Color logic into a `BpmnExtension` implementation +4. Wire extension points into DiagramConverter and StyleComputer +5. Remove hardcoded color code from core + +Key design decisions: +- **Parsing**: DiagramConverter's constructor does **not** change. It internally imports and always uses the BPMN in Color parsing extension. Colors are always parsed from XML into the model — this matches the current behavior. The extension usage is an implementation detail. +- **Style**: StyleComputer's constructor does **not** change. It keeps its `options?: RendererOptions` parameter. Internally, when `ignoreBpmnColors` is false, it uses the BPMN in Color style extension. The extension registration is an internal implementation detail. + +## Dependencies + +Files must be changed in this order due to type dependencies: +1. Extension point interfaces (new file) — no dependencies +2. Extension types migration — needed by everything else +3. BPMN in Color extension implementation — depends on interfaces + types +4. DiagramConverter wiring — depends on extension interfaces +5. StyleComputer wiring — depends on extension interfaces + +## File Changes + +### NEW `src/component/extension/extension-points.ts` + +- Define `ParsingExtensionPoint` interface with two optional methods: `onShapeDeserialized(shape, bpmnShape)` and `onEdgeDeserialized(edge, bpmnEdge)`. Both receive the internal model object and the raw JSON object. +- Define `StyleExtensionPoint` interface with two optional methods: `enrichShapeStyle(shape, styleValues)` and `enrichEdgeStyle(edge, styleValues)`. The `styleValues` parameter is a `Map` that the extension mutates directly. +- Define `BpmnExtension` interface grouping `parsing?: ParsingExtensionPoint` and `style?: StyleExtensionPoint` +- All interfaces marked `@internal` + +### `src/model/bpmn/internal/types.ts` + +- Convert `ShapeExtensions` from type alias to empty interface: `export interface ShapeExtensions {}` +- Convert `EdgeExtensions` from type alias to empty interface: `export interface EdgeExtensions {}` +- Convert `LabelExtensions` from type alias to empty interface: `export interface LabelExtensions {}` +- Keep `@internal` JSDoc tags + +### NEW `src/component/extension/bpmn-in-color-extension.ts` + +- Add module augmentation block: `declare module` targeting the internal types file to add color properties (`fillColor`, `strokeColor` on ShapeExtensions, `strokeColor` on EdgeExtensions, `color` on LabelExtensions) +- Important: since extension types are internal (not publicly exported), the module augmentation path must target the relative internal types file, not `'bpmn-visualization'` +- Implement a `ParsingExtensionPoint` object with: + - `onShapeDeserialized`: move logic from `setColorExtensionsOnShape` (DiagramConverter lines 191-202) + label color handling (lines 166-169, read from bpmnShape's BPMNLabel) + - `onEdgeDeserialized`: move logic from `setColorExtensionsOnEdge` (DiagramConverter lines 212-218) + label color handling for edge labels +- Implement a `StyleExtensionPoint` object with: + - `enrichShapeStyle`: move logic from StyleComputer `computeShapeStyleValues` lines 85-95 (fillColor, strokeColor, swimlaneFillColor for pools/lanes). Receives the Shape and the existing styleValues Map. + - `enrichEdgeStyle`: move logic from StyleComputer `computeEdgeStyleValues` lines 103-106 (strokeColor) +- Consider: `computeFontStyleValues` reads `bpmnCell.label?.extensions.color` — this is label styling on both shapes and edges. The style extension's `enrichShapeStyle` and `enrichEdgeStyle` methods handle this by accessing `shape.label` / `edge.label` to set font color. +- Consider: `computeMessageFlowIconStyle` uses a different data structure (`[string, string][]` with `push()` instead of `Map` with `set()`). For the PoC, keep message flow icon color logic inline in StyleComputer — it's just one line and uses a different structure. It still reads from the extension data populated by the parsing extension. +- Export `bpmnInColorParsingExtension` (the `ParsingExtensionPoint`) and `bpmnInColorStyleExtension` (the `StyleExtensionPoint`) separately, so they can be consumed independently by DiagramConverter and StyleComputer + +### `src/component/parser/json/converter/DiagramConverter.ts` + +- Constructor signature **does not change**. Keep `constructor(convertedElements, parsingMessageCollector)`. +- Add a private field `private readonly parsingExtensions: ParsingExtensionPoint[]` initialized to `[bpmnInColorParsingExtension]`. Import `bpmnInColorParsingExtension` from the extension module. +- In `deserializeShape()` (after line 126, after shape construction): replace `setColorExtensionsOnShape(shape, bpmnShape)` with a loop calling `ext.onShapeDeserialized?.(shape, bpmnShape)` for each parsing extension +- In `deserializeEdges()` (after line 150, after edge construction): replace `setColorExtensionsOnEdge(edge, bpmnEdge)` with a loop calling `ext.onEdgeDeserialized?.(edge, bpmnEdge)` for each parsing extension +- In `deserializeLabel()` (lines 166-169): remove the inline `if ('color' in bpmnLabel)` block — this is now handled by the parsing extension's `onShapeDeserialized`/`onEdgeDeserialized` which access the label via `shape.label` / `edge.label` and read color from the raw `bpmnShape.BPMNLabel` / `bpmnEdge.BPMNLabel` +- Delete `setColorExtensionsOnShape` function (lines 190-202) +- Delete `setColorExtensionsOnEdge` function (lines 211-218) + +### `src/component/mxgraph/renderer/StyleComputer.ts` + +- Constructor signature **does not change**. Keep `constructor(options?: RendererOptions)`. +- Add a private field `private readonly styleExtensions: StyleExtensionPoint[]` initialized in the constructor: if `!this.ignoreBpmnColors`, include `bpmnInColorStyleExtension` in the array; otherwise, empty array. +- Import `bpmnInColorStyleExtension` from the extension module +- In `computeShapeStyleValues()`: replace lines 85-95 (the `if (!this.ignoreBpmnColors)` block) with a loop calling `ext.enrichShapeStyle?.(shape, styleValues)` for each style extension +- In `computeEdgeStyleValues()`: replace lines 103-106 (the `if (!this.ignoreBpmnColors)` block) with a loop calling `ext.enrichEdgeStyle?.(edge, styleValues)` for each style extension +- In `computeFontStyleValues()`: remove lines 121-124 (the `if (!this.ignoreBpmnColors)` block for label color) — now handled by the style extension within `enrichShapeStyle`/`enrichEdgeStyle` +- In `computeMessageFlowIconStyle()`: keep the color logic inline (lines 132-134) — it uses a `[string, string][]` structure different from the `Map`. It still reads `edge.extensions.strokeColor` which is populated by the parsing extension. Keep the `if (!this.ignoreBpmnColors)` guard here for now. +- The `ignoreBpmnColors` private field is kept for: (a) deciding which style extensions to register, (b) the message flow icon inline color logic + +### `src/component/parser/json/BpmnJsonParser.ts` + +- No change needed. DiagramConverter handles its own extension registration internally. + +### `src/component/parser/BpmnParser.ts` + +- No change needed. + +### `src/component/mxgraph/BpmnRenderer.ts` + +- No change needed. StyleComputer handles its own extension registration internally. + +### `src/component/BpmnVisualization.ts` + +- No change needed. The `ignoreBpmnColors` flag flows through `RendererOptions` to `StyleComputer` as before. Parsing extensions are wired in `newBpmnJsonParser`. + +### `src/component/options.ts` + +- No change needed. + +## Testing Strategy + +### No new tests needed for behavior +This is a pure refactoring — all existing tests must pass. The test suite is the validation. + +### Tests to verify pass (run after each step): +- `npm run test:unit` — particularly `BpmnParser.test.ts` (color parsing) and `StyleComputer.test.ts` (color styling) +- `npm run test:integration` — particularly `mxGraph.model.bpmn.colors.test.ts` +- `npm run test:e2e` — particularly `bpmn.colors.test.ts` + +### Note on test compatibility +- `StyleComputer.test.ts` tests create `StyleComputer` directly with `{ ignoreBpmnColors: false }`. Since StyleComputer's constructor signature is unchanged and it internally registers the BPMN in Color style extension based on that flag, **these tests require no changes**. +- `BpmnParser.test.ts` tests use the parser factory which now always includes the parsing extension. Since colors were always parsed before, **these tests require no changes**. + +## Rollout Considerations + +- No breaking changes — this is internal refactoring only +- `ignoreBpmnColors` public API behavior is preserved +- Extension types are internal, so the type→interface migration has no public API impact +- Module augmentation for BPMN in Color is internal, not user-facing +- No changes to BpmnVisualization, BpmnRenderer, BpmnParser, BpmnJsonParser, or options — minimal blast radius +- DiagramConverter and StyleComputer constructors are unchanged — no ripple effect on callers or tests diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/index.md b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/index.md new file mode 100644 index 0000000000..02dbea5190 --- /dev/null +++ b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/index.md @@ -0,0 +1,19 @@ +# Tasks: BPMN in Color Extension PoC + +## Overview + +Refactor the hardcoded BPMN in Color support into the new extension mechanism defined in the ADR. Pure internal refactoring — no user-facing behavior change. + +## Task List + +- [ ] **Task 1**: Define extension point interfaces and migrate extension types - `task-01.md` +- [ ] **Task 2**: Implement BPMN in Color as extension - `task-02.md` (depends on Task 1) +- [ ] **Task 3**: Wire extension into DiagramConverter and StyleComputer - `task-03.md` (depends on Task 2) + +## Execution Order + +Sequential: Task 1 → Task 2 → Task 3 (each depends on the previous) + +## Validation + +After Task 3, run `npm run all` to verify the full check passes (lint, build, all tests). diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-01.md b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-01.md new file mode 100644 index 0000000000..3ae7e4f98d --- /dev/null +++ b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-01.md @@ -0,0 +1,28 @@ +# Task: Define extension point interfaces and migrate extension types + +## Problem + +There are no extension point interfaces in the codebase. The extension types (`ShapeExtensions`, `EdgeExtensions`, `LabelExtensions`) are type aliases, which prevents TypeScript module augmentation. + +## Proposed Solution + +Create the extension point interfaces (`ParsingExtensionPoint`, `StyleExtensionPoint`, `BpmnExtension`) in a new file. Migrate the three extension types from type aliases to empty interfaces. + +## Dependencies + +- None (can start immediately) + +## Context + +- New file: `src/component/extension/extension-points.ts` +- Types to migrate: `src/model/bpmn/internal/types.ts` (lines 20-37) +- ADR describes the interfaces: `docs/contributors/adr/001-bpmn-extensions-management.md` +- `ParsingExtensionPoint`: `onShapeDeserialized(shape, bpmnShape)`, `onEdgeDeserialized(edge, bpmnEdge)` +- `StyleExtensionPoint`: `enrichShapeStyle(shape, styleValues)`, `enrichEdgeStyle(edge, styleValues)` +- `styleValues` is `Map` mutated in place + +## Success Criteria + +- Extension point interfaces exist and compile +- Extension types are empty interfaces (not type aliases) +- All existing tests pass unchanged (the empty interfaces are structurally compatible with the old types) diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-02.md b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-02.md new file mode 100644 index 0000000000..341c281319 --- /dev/null +++ b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-02.md @@ -0,0 +1,30 @@ +# Task: Implement BPMN in Color as extension + +## Problem + +The BPMN in Color parsing and style logic is hardcoded in `DiagramConverter` and `StyleComputer`. It needs to be extracted into a `BpmnExtension` implementation using the new extension point interfaces. + +## Proposed Solution + +Create a new module that implements `ParsingExtensionPoint` and `StyleExtensionPoint` for BPMN in Color. Move the existing color logic from DiagramConverter and StyleComputer into this module, including module augmentation to declare the color properties on the extension interfaces. + +## Dependencies + +- Task #1: Extension point interfaces and migrated types must exist + +## Context + +- New file: `src/component/extension/bpmn-in-color-extension.ts` +- Parsing logic to move: `src/component/parser/json/converter/DiagramConverter.ts` lines 166-169 (label color), 191-202 (`setColorExtensionsOnShape`), 212-218 (`setColorExtensionsOnEdge`) +- Style logic to move: `src/component/mxgraph/renderer/StyleComputer.ts` lines 85-95 (shape colors), 103-106 (edge colors), 121-124 (label font color) +- Module augmentation targets internal types file to add `fillColor`, `strokeColor`, `color` properties +- Label color handling moves from `deserializeLabel` into `onShapeDeserialized`/`onEdgeDeserialized` (label accessible via `shape.label` / `edge.label`, raw data via `bpmnShape.BPMNLabel`) +- `enrichShapeStyle`/`enrichEdgeStyle` also handle font color (via `shape.label.extensions.color`) +- Pools/lanes special case: fillColor also sets `STYLE_SWIMLANE_FILLCOLOR` +- Export `bpmnInColorParsingExtension` and `bpmnInColorStyleExtension` separately + +## Success Criteria + +- Module compiles and exports both extension point implementations +- Module augmentation adds color properties to the empty extension interfaces +- All color-related logic from DiagramConverter and StyleComputer is replicated in the extension diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-03.md b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-03.md new file mode 100644 index 0000000000..594369bbc8 --- /dev/null +++ b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-03.md @@ -0,0 +1,35 @@ +# Task: Wire extension into DiagramConverter and StyleComputer + +## Problem + +DiagramConverter and StyleComputer still contain hardcoded BPMN in Color logic. They need to use the new extension points instead. + +## Proposed Solution + +Update DiagramConverter to internally register the BPMN in Color parsing extension and call it via the extension point loop. Update StyleComputer to internally register the BPMN in Color style extension (when `ignoreBpmnColors` is false) and call it via the extension point loop. Remove the hardcoded color functions and blocks. + +## Dependencies + +- Task #2: BPMN in Color extension implementation must exist + +## Context + +- DiagramConverter (`src/component/parser/json/converter/DiagramConverter.ts`): + - Constructor unchanged. Add private field `parsingExtensions` initialized to `[bpmnInColorParsingExtension]` + - Replace `setColorExtensionsOnShape(shape, bpmnShape)` (line 127) with extension loop + - Replace `setColorExtensionsOnEdge(edge, bpmnEdge)` (line 151) with extension loop + - Remove label color block in `deserializeLabel` (lines 166-169) + - Delete `setColorExtensionsOnShape` and `setColorExtensionsOnEdge` functions +- StyleComputer (`src/component/mxgraph/renderer/StyleComputer.ts`): + - Constructor unchanged. Add private field `styleExtensions` populated based on `ignoreBpmnColors` + - Replace `if (!this.ignoreBpmnColors)` blocks in `computeShapeStyleValues` (lines 85-95) and `computeEdgeStyleValues` (lines 103-106) with extension loops + - Remove font color block in `computeFontStyleValues` (lines 121-124) — handled by style extension + - Keep `computeMessageFlowIconStyle` color logic inline (line 132-134) — different data structure + - Keep `ignoreBpmnColors` field for message flow icon logic and extension registration decision + +## Success Criteria + +- All existing tests pass unchanged (unit, integration, e2e) +- No BPMN in Color-specific logic remains in DiagramConverter except the extension array +- StyleComputer's color logic is delegated to the extension (except message flow icon) +- `npm run lint-check` passes diff --git a/docs/contributors/README.md b/docs/contributors/README.md index 1330eb3e4c..705b49492d 100644 --- a/docs/contributors/README.md +++ b/docs/contributors/README.md @@ -1,6 +1,6 @@ # Guidelines for Contributors and Development -General information are available in the [Contributing Guide](../../CONTRIBUTING.md). +General information is available in the [Contributing Guide](../../CONTRIBUTING.md). Here are some tips to help during development. @@ -21,6 +21,16 @@ Here are some tips to help during development. - [mxGraph integration](mxgraph-integration.md) - [mxGraph version bump](mxgraph-version-bump.md) +## [Architecture Decision Records](./adr/) +**Purpose:** Document significant architectural decisions made in this project. + +ADRs capture the context, decision, and consequences of important technical choices. They serve as a historical record to help current and future team members understand why certain approaches were chosen. + +**When to read:** When you need to understand the rationale behind a system's design or when proposing changes to existing architecture. + +**Existing ADRs:** +- [001 — BPMN Extensions Management](./adr/001-bpmn-extensions-management.md) + ## Misc - [Documentation guidelines](documentation-guidelines.md) - [For the maintainers](maintainers.md): in particular, how to release a new version. diff --git a/docs/contributors/adr/001-bpmn-extensions-management.md b/docs/contributors/adr/001-bpmn-extensions-management.md new file mode 100644 index 0000000000..5adc0ac592 --- /dev/null +++ b/docs/contributors/adr/001-bpmn-extensions-management.md @@ -0,0 +1,314 @@ +# BPMN Extensions Management + +## Status + +| Field | Value | +|-------------------|----------------| +| **Status** | Early Proposal | +| **Date** | 2026-05-13 | +| **Updated** | - | +| **Supersedes** | - | +| **Superseded by** | - | + + +**IMPORTANT:** This is an early proposal for managing BPMN extensions in `bpmn-visualization`. +The design is subject to change based on feedback and further analysis. The goal is to introduce a consistent and extensible mechanism for handling BPMN extensions, allowing both built-in and user-defined extensions to coexist without modifying core library code. + +## Context + +The [BPMN specification](https://www.omg.org/spec/BPMN/2.0.2/) allows for extensions: custom elements and attributes that can be added to BPMN models to provide additional functionality or information beyond the standard. +Extensions can be applied to both the semantic model (process elements like tasks, events, gateways) and the diagram interchange model (visual elements like shapes, edges, labels). + +Examples of BPMN extensions include: +- [BPMN in Color](https://github.com/bpmn-miwg/bpmn-in-color): Adds color attributes (`background-color`, `border-color`, ...) to shapes, edges, and labels. +- [Bonita Connector](https://documentation.bonitasoft.com/bonita/2025.2/process/connectivity-overview): Adds connector metadata to tasks and pools, displayed as a specific icon on the top-right of the Tasks (not displayed on Pools). + +Managing BPMN extensions in a consistent and extensible way is important to allow `bpmn-visualization` to support known extensions and to let users implement their own. + +**Open question**: should we mention other examples of extensions, to have a broader scope than just colors and connectors? For example, extensions that add custom properties to elements or extensions that add new visual decorations. + +## Situation before this ADR + +`bpmn-visualization` does not provide a generic mechanism for managing BPMN extensions. + +The only extension currently supported is **BPMN in Color**, which is hardcoded in the codebase: +- **Parsing**: `DiagramConverter` contains dedicated functions (`setColorExtensionsOnShape`, `setColorExtensionsOnEdge`) that read color attributes from the raw JSON and populate the internal model's extension properties. See `src/component/parser/json/converter/DiagramConverter.ts` lines 191-218. +- **Style computing**: `StyleComputer` reads the extension properties and maps them to mxGraph style constants. See `src/component/mxgraph/renderer/StyleComputer.ts`. +- **Model**: `ShapeExtensions`, `EdgeExtensions`, and `LabelExtensions` are defined as type aliases in `src/model/bpmn/internal/types.ts`, with hardcoded color properties. + +This approach is not replicable: there is no extension point that would allow adding a new BPMN extension without modifying the core library code. + + +## Decision + +Introduce extension points in the library to manage BPMN extensions consistently and allow users to create and manage their own extensions. + +### What is involved + +Supporting BPMN extensions touches three layers of data and three phases of the pipeline. + +**Data layers:** +- **BPMN model (XML)** — the extension definition as expressed in the BPMN XML, either via custom attributes or via dedicated extension elements. +- **JSON model (raw data)** — the typed JSON representation of the XML, produced by the XML parser. Extensions augment this model to expose their custom attributes in a type-safe way. +- **Internal model (computed properties)** — the domain model used by the rest of the library. Extensions augment this model with **computed** properties derived from parsing — these properties may or may not come from XML extensions. + +**Pipeline phases:** +- **Parsing** — reads the XML/JSON extension data and populates the internal model's extension properties. +- **Style computing** — derives mxGraph styles from the internal model's extension properties. +- **Rendering** — paints additional visual elements (icons, decorations) on existing shapes, or creates new ones, based on the internal model. + +**Summary of the data flow:** BPMN model (XML) ↔ JSON model (raw data) ↔ internal model (computed properties) ↔ extension points in the pipeline (hooks for parsing, style computing, rendering). + + +### Extension Model: use TypeScript module augmentation + +#### JSON model: use TypeScript module augmentation + +The JSON model interfaces (`BPMNShape`, `BPMNEdge`, `BPMNLabel` in `src/model/bpmn/json/bpmndi.ts`) must also be extended when parsing extensions read custom attributes from the raw BPMN XML data. + +For example, the BPMN in Color parsing extension reads color attributes (`background-color`, `fill`, `border-color`, `stroke`, `color`) that are not part of the base JSON model types. Module augmentation is used to declare these properties on the JSON model interfaces: + +```ts +// In the BPMN in Color extension (src/component/extension/bpmn-in-color/types.ts) +declare module '../../../model/bpmn/json/bpmndi' { + interface BPMNShape { + 'background-color'?: string; + fill?: string; + 'border-color'?: string; + stroke?: string; + } + interface BPMNEdge { + 'border-color'?: string; + stroke?: string; + } + interface BPMNLabel { + color?: string; + } +} +``` + +This way, the parsing extension can read these properties in a type-safe manner without modifying the base JSON model interfaces. + +#### Internal model: use TypeScript module augmentation + +The current extension types (`ShapeExtensions`, `EdgeExtensions`, `LabelExtensions`) are type aliases. They must be migrated to **interfaces** to enable [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). This approach was first suggested during the BPMN in Color implementation in [this PR comment](https://github.com/process-analytics/bpmn-visualization-js/pull/2614#discussion_r1200547629). + +After migration, the base interfaces in the library become empty: + +```ts +// In bpmn-visualization (library code) +export interface ShapeExtensions {} +export interface EdgeExtensions {} +export interface LabelExtensions {} +``` + +Users (or built-in extensions like BPMN in Color) augment them to declare the properties they need: + +```ts +// Example: a custom extension that adds connector metadata to shapes +declare module 'bpmn-visualization' { + export interface ShapeExtensions { + connectorType?: string; + connectorVersion?: string; + } +} +``` + +This pattern is used by libraries like [MUI for theme customization](https://mui.com/material-ui/customization/theming/#typescript). + +The BPMN in Color extension augments both JSON and internal model interfaces in `src/component/extension/bpmn-in-color/types.ts`: + +```ts +// Internal model augmentation +declare module '../../../model/bpmn/internal/types' { + interface ShapeExtensions { + fillColor?: string; + strokeColor?: string; + } + interface EdgeExtensions { + strokeColor?: string; + } + interface LabelExtensions { + color?: string; + } +} +``` + +#### JSON model versus internal model independence + +The JSON model already provides `TExtension` (defined in `src/model/bpmn/json/Semantic.ts`) to represent XML extension elements. +The internal model extensions serve a different purpose: they store **computed results** derived from parsing, which may or may not come from XML extensions. + +These two must remain independent: +- A user may want internal model extensions without any XML extensions (e.g., extensions computed from external data). +- A user may want XML extensions without enriching the internal model. +- When both are used, the parsing extension point acts as the bridge: it reads from the JSON model and populates the internal model extensions. + +### Extension Points + +Three categories of extension points, corresponding to the three phases of the library pipeline: + +#### 1. Parsing extension point + +Called at the end of the existing parsing pipeline in converters (`DiagramConverter`, `ProcessConverter`, potentially others). The extension point receives both the internal model object and the raw JSON data, and enriches the model's extension properties. + +Labels are children of shapes and edges. Their extensions are populated during shape/edge deserialization (the label is accessible via the parent shape/edge object). There is no need for a separate label hook. + +```ts +interface ParsingExtensionPoint { + /** Called after a Shape has been deserialized. Use shape.label to enrich label extensions. */ + onShapeDeserialized?(shape: Shape, bpmnShape: BPMNShape): void; + /** Called after an Edge has been deserialized. Use edge.label to enrich label extensions. */ + onEdgeDeserialized?(edge: Edge, bpmnEdge: BPMNEdge): void; + /** Returns true if the given BPMN label data requires a label to be created during deserialization. */ + hasLabelExtensionData?(bpmnLabel: unknown): boolean; +} +``` + +The `hasLabelExtensionData` method is a workaround for a constraint discovered during the BPMN in Color refactoring: labels are immutable once set on a shape or edge, so extensions that need a label (e.g., to apply font color) must signal that requirement before label creation happens. +`DiagramConverter.deserializeLabel` calls `hasLabelExtensionData` on all registered extensions. If any extension returns `true`, a `Label` object is created even if the label has no font or bounds — ensuring that the extension can later populate the label's extension properties via `onShapeDeserialized` or `onEdgeDeserialized`. + +**Open question**: find a more generic approach for label handling — `hasLabelExtensionData` is a workaround tied to the current immutable label design. + +For example, the current `setColorExtensionsOnShape` function in `DiagramConverter` becomes an implementation of `onShapeDeserialized` in the BPMN in Color extension. + +#### 2. Style extension point + +Called by `StyleComputer` to compute additional mxGraph style properties from the internal model extensions. + +The extension receives the existing style entries map and mutates it directly, avoiding the need for merging in the caller. As with parsing, label styles are handled through the parent shape/edge (accessible via the `label` property on the model object). + +```ts +interface StyleExtensionPoint { + /** Enrich style entries for a shape. Use shape.label to enrich label styles. */ + enrichShapeStyle?(shape: Shape, styleValues: Map): void; + /** Enrich style entries for an edge. Use edge.label to enrich label styles. */ + enrichEdgeStyle?(edge: Edge, styleValues: Map): void; + /** Enrich style entries for the message flow icon of an edge. */ + enrichMessageFlowIconStyle?(edge: Edge, styleValues: Map): void; +} +``` + +The `styleValues` map uses `string | number` values because mxGraph style properties can be either strings (e.g., color hex codes) or numbers (e.g., font sizes, stroke widths). + +The `enrichMessageFlowIconStyle` hook targets a sub-element of an edge — the message flow icon — which is a distinct visual element painted on a message flow edge and requires its own style values. Without a dedicated hook, the style computation for this icon would still need to hardcode color logic, defeating the goal of moving all extension-specific code out of the core. + +**Open question**: is "enrich" the right term here? It implies that the extension adds to existing styles, but in practice it may also override them. Alternative terms could be "compute", "mutate", or simply "apply". + +#### 3. Rendering extension point + +> **Not implemented in this refactoring.** This extension point describes target-state behavior; it is deferred to follow-up work (cf. "Refactoring scope vs. follow-up work" below). + +Called during shape rendering to paint additional elements (icons, decorations) on shapes and edges. This hooks into the mxGraph shape painting methods (e.g., `paintForeground` in `BaseTaskShape`, and potentially other painting methods depending on the extension's needs). + +```ts +interface RenderingExtensionPoint { + /** Paint additional elements on a shape */ + paintShape?(paintParameter: PaintParameter): void; +} +``` + +For the Bonita Connector use case, this extension point would paint a connector icon on the top-right of tasks. The extension provides its own icon painter implementation. + + +### Configuration + +#### Refactoring scope vs. follow-up work + +This ADR proposes an **internal refactoring to validate the extension mechanism**. Its scope is intentionally limited: + +- **In scope** — **extracting** the hardcoded BPMN in Color logic from the core components into separate extension modules. The goal is to demonstrate that the implementation _can_ be extracted into objects that conform to the extension point interfaces, without changing any public interface of `DiagramConverter`, `StyleComputer`, or `BpmnVisualization`. Existing tests must keep passing without modification. The internal registration of the built-in extension is still hardcoded in the components that consume it (an array literal in `DiagramConverter` and `StyleComputer`). +- **Out of scope (follow-up work)** — **injecting** the extensions from the outside. This includes: + - exposing a public API such as `new BpmnVisualization({ bpmnExtensions: [...] })`, + - introducing a `BpmnExtension` grouping interface so that one extension is registered as a single object instead of multiple per-phase objects, + - the icon painter injection mechanism described below, + - generalizing the gating behavior of `ignoreBpmnColors` (currently only the style extension is gated; the parsing extension always runs because the internal registration is not yet configurable). + +The rest of this section describes the **target state** for follow-up work, not what this refactoring delivers today. + +#### Single registration point for the end user + +As described above, a single extension involves multiple elements: model augmentation (JSON and internal), a parsing extension point, a style extension point, and potentially a rendering extension point and custom icon painter methods. Requiring end users to configure each of these separately would be impractical — they should not need to understand the internal decomposition of an extension. Only the extension developer needs this knowledge. + +To address this, a `BpmnExtension` interface groups all extension points for a single extension. The end user registers one object (e.g., `bonitaConnectorExtension`) and the library takes care of distributing the individual extension points to the relevant components (parser, style computer, renderer). From the user's perspective, adding an extension is a single, opaque operation. + +#### Icon Painter + +The `IconPainter` is already configurable via `RendererOptions.iconPainter` in the renderer property. There must be a single place to configure icon painting, and it must remain in the renderer property — not be duplicated in the extension configuration. + +An extension that needs custom shape painting should provide additional icon painter **methods** rather than a full `IconPainter` instance. These methods are injected into the existing icon painter implementation at registration time (in the factory). This avoids the conflict of multiple extensions each providing a competing full painter. + +**Open question**: If two extensions each provide a different `IconPainter`, only one can be active. This needs further design work — possible approaches include composing painters, a chain-of-responsibility pattern, or scoping painters to specific element kinds. This will be addressed in a follow-up discussion. + +**Open question**: should we pass individual icon painter methods (injected in the factory) instead of the whole painter? +- The `createNewBpmnGraph` function is currently in charge of selecting the icon painter implementation based on the options. It will have to apply the new methods. +- Interface for the options or Record: key = name of the methods, value = implementation of the method (can be a reference). What signature? This will prevent methods from depending on a property of the painter. It should not be a problem, we don't see this need for now. +- Also required to use interface augmentation on the `IconPainter` class to make TypeScript aware of these new methods. +- This would allow avoiding the issue of multiple painters, as each extension would only pass the methods. + +**Open question**: the name of the interfaces may remove the "Point" suffix if it's clear enough without it. Also, the name "BpmnExtension" may be too generic if we want to support non-BPMN extensions in the future — consider "BpmnVisualizationExtension" or similar. + +```ts +interface BpmnExtension { + parsing?: ParsingExtensionPoint; + style?: StyleExtensionPoint; + rendering?: RenderingExtensionPoint; +} +``` + +#### Built-in vs. custom extensions + +BPMN in Color is a **built-in extension**: it is always active internally and not exposed or configurable by users. The current behavior does not change — users do not need to register it. Internally, it will be refactored to use the same extension mechanism, but it remains an implementation detail of the library. + +Only custom extensions (e.g., Bonita Connector) are passed to `BpmnVisualization` at construction time: + +```ts +const bpmnVisualization = new BpmnVisualization({ + container: 'bpmn-container', + bpmnExtensions: [bonitaConnectorExtension], +}); +``` + +The naming of interfaces and the detailed API are subject to refinement during implementation. + + +## Validation + +The solution will be validated by implementing two extensions: + +### 1. Migrate BPMN in Color + +Migrate the current hardcoded BPMN in Color implementation to the new extension mechanism. BPMN in Color remains a built-in, always-active extension — this migration is an internal refactoring with no user-facing behavior change. + +#### Migration plan + +1. **Introduce the extension point interfaces needed by this refactoring** — Create `ParsingExtensionPoint` and `StyleExtensionPoint`. `RenderingExtensionPoint` and the `BpmnExtension` grouping interface are deferred to follow-up work (cf. "Refactoring scope vs. follow-up work" above), as they are only useful once extensions can be injected by users. +2. **Migrate extension types** — Convert `ShapeExtensions`, `EdgeExtensions`, `LabelExtensions` from type aliases to empty interfaces. Move the BPMN in Color properties into a module augmentation declared alongside the built-in extension implementation. +3. **Create the built-in BPMN in Color extension** — Implement two separate objects, one per phase, since this refactoring does not introduce a `BpmnExtension` grouping interface (cf. "Refactoring scope vs. follow-up work" above): + - `bpmnInColorParsingExtension`, an object implementing `ParsingExtensionPoint`, containing the logic currently in `setColorExtensionsOnShape` and `setColorExtensionsOnEdge` (from `DiagramConverter`). + - `bpmnInColorStyleExtension`, an object implementing `StyleExtensionPoint`, containing the color-to-mxGraph-style mapping currently in `StyleComputer`. +4. **Wire the extension mechanism into the pipeline** — Update `DiagramConverter` and `StyleComputer` to call registered extension points instead of hardcoded color logic. Register the built-in BPMN in Color extension internally (not exposed to users). +5. **Remove hardcoded BPMN in Color code** — Delete the dedicated color functions from `DiagramConverter` and the color-specific branches from `StyleComputer`. The core code should no longer contain any BPMN in Color-specific logic. +6. **Validate** — Ensure all existing BPMN in Color tests pass without modification (behavior is unchanged). + +### 2. Implement Bonita Connector + +Implement the Bonita Connector extension as a new extension: +- **Parsing**: Read connector metadata from BPMN XML extensions on tasks/pools and store it in the internal model. +- **Rendering**: Paint a connector icon on the top-right of shapes that have connectors, using a custom icon painter implementation. + + +## Consequences + +### Positive + +- **Uniform mechanism**: All BPMN extensions follow the same pattern, making the codebase more consistent. +- **User-extensible**: Users can implement their own BPMN extensions without forking or modifying the library. +- **Decoupled**: Extensions are isolated from core code, making both easier to maintain and test. +- **Type-safe**: Module augmentation provides TypeScript type checking for extension properties. + +### Negative + +- **Indirection**: Extension points add a level of indirection compared to the current direct implementation. +- **API surface**: New public interfaces (`BpmnExtension`, `ParsingExtensionPoint`, `StyleExtensionPoint`, `RenderingExtensionPoint`) increase the API surface. +- **Migration effort**: Existing BPMN in Color code must be refactored to use the new mechanism. diff --git a/src/component/extension/bpmn-in-color/parsing-extension.ts b/src/component/extension/bpmn-in-color/parsing-extension.ts new file mode 100644 index 0000000000..b8cb19278d --- /dev/null +++ b/src/component/extension/bpmn-in-color/parsing-extension.ts @@ -0,0 +1,54 @@ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Edge } from '../../../model/bpmn/internal/edge/edge'; +import type Shape from '../../../model/bpmn/internal/shape/Shape'; +import type { BPMNEdge, BPMNLabel, BPMNShape } from '../../../model/bpmn/json/bpmndi'; +import type { ParsingExtensionPoint } from '../extension-points'; + +import './types'; + +function setColorExtensionsOnShape(shape: Shape, bpmnShape: BPMNShape): void { + const fillColor = bpmnShape['background-color'] ?? bpmnShape.fill; + fillColor && (shape.extensions.fillColor = fillColor); + const strokeColor = bpmnShape['border-color'] ?? bpmnShape.stroke; + strokeColor && (shape.extensions.strokeColor = strokeColor); +} + +function setColorExtensionsOnLabel(label: { extensions: { color?: string } } | undefined, bpmnLabel: string | BPMNLabel | undefined): void { + if (label && bpmnLabel && typeof bpmnLabel === 'object' && bpmnLabel.color) { + label.extensions.color = bpmnLabel.color; + } +} + +function setColorExtensionsOnEdge(edge: Edge, bpmnEdge: BPMNEdge): void { + const strokeColor = bpmnEdge['border-color'] ?? bpmnEdge.stroke; + strokeColor && (edge.extensions.strokeColor = strokeColor); +} + +export const bpmnInColorParsingExtension: ParsingExtensionPoint = { + onShapeDeserialized(shape: Shape, bpmnShape: BPMNShape): void { + setColorExtensionsOnShape(shape, bpmnShape); + setColorExtensionsOnLabel(shape.label, bpmnShape.BPMNLabel); + }, + onEdgeDeserialized(edge: Edge, bpmnEdge: BPMNEdge): void { + setColorExtensionsOnEdge(edge, bpmnEdge); + setColorExtensionsOnLabel(edge.label, bpmnEdge.BPMNLabel); + }, + hasLabelExtensionData(bpmnLabel: unknown): boolean { + return typeof bpmnLabel === 'object' && bpmnLabel !== null && 'color' in bpmnLabel; + }, +}; diff --git a/src/component/extension/bpmn-in-color/style-extension.ts b/src/component/extension/bpmn-in-color/style-extension.ts new file mode 100644 index 0000000000..76edd91cae --- /dev/null +++ b/src/component/extension/bpmn-in-color/style-extension.ts @@ -0,0 +1,53 @@ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Edge } from '../../../model/bpmn/internal/edge/edge'; +import type Label from '../../../model/bpmn/internal/Label'; +import type Shape from '../../../model/bpmn/internal/shape/Shape'; +import type { StyleExtensionPoint } from '../extension-points'; + +import './types'; + +import { ShapeUtil } from '../../../model/bpmn/internal'; +import { mxConstants } from '../../mxgraph/initializer'; + +function enrichLabelStyle(label: Label | undefined, styleValues: Map): void { + const color = label?.extensions.color; + color && styleValues.set(mxConstants.STYLE_FONTCOLOR, color); +} + +export const bpmnInColorStyleExtension: StyleExtensionPoint = { + enrichShapeStyle(shape: Shape, styleValues: Map): void { + const extensions = shape.extensions; + const fillColor = extensions.fillColor; + if (fillColor) { + styleValues.set(mxConstants.STYLE_FILLCOLOR, fillColor); + if (ShapeUtil.isPoolOrLane(shape.bpmnElement.kind)) { + styleValues.set(mxConstants.STYLE_SWIMLANE_FILLCOLOR, fillColor); + } + } + extensions.strokeColor && styleValues.set(mxConstants.STYLE_STROKECOLOR, extensions.strokeColor); + enrichLabelStyle(shape.label, styleValues); + }, + enrichEdgeStyle(edge: Edge, styleValues: Map): void { + const extensions = edge.extensions; + extensions.strokeColor && styleValues.set(mxConstants.STYLE_STROKECOLOR, extensions.strokeColor); + enrichLabelStyle(edge.label, styleValues); + }, + enrichMessageFlowIconStyle(edge: Edge, styleValues: Map): void { + edge.extensions.strokeColor && styleValues.set(mxConstants.STYLE_STROKECOLOR, edge.extensions.strokeColor); + }, +}; diff --git a/src/component/extension/bpmn-in-color/types.ts b/src/component/extension/bpmn-in-color/types.ts new file mode 100644 index 0000000000..bbf4d0494c --- /dev/null +++ b/src/component/extension/bpmn-in-color/types.ts @@ -0,0 +1,69 @@ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Marks this file as an ES module so the `declare module` blocks below are treated as module augmentation +// (with relative paths) rather than ambient module declarations. Without this marker, TypeScript reports +// "TS2436: Ambient module declaration cannot specify relative module names." +// +// `export {}` is preferred over an empty `import type {} from '...'` because it survives +// `verbatimModuleSyntax: true`, which would otherwise erase empty type-only imports and break compilation. +export {}; + +// Extend BPMN JSON diagram types with color properties from the BPMN in Color specification. +declare module '../../../model/bpmn/json/bpmndi' { + interface BPMNShape { + /** Fill color of the shape, as defined by the BPMN in Color specification. */ + 'background-color'?: string; + /** Fill color of the shape, as defined by the bpmn.io color specification. Used as fallback when {@link background-color} is not set. */ + fill?: string; + /** Stroke (border) color of the shape, as defined by the BPMN in Color specification. */ + 'border-color'?: string; + /** Stroke (border) color of the shape, as defined by the bpmn.io color specification. Used as fallback when {@link border-color} is not set. */ + stroke?: string; + } + + interface BPMNEdge { + /** Stroke color of the edge, as defined by the BPMN in Color specification. */ + 'border-color'?: string; + /** Stroke color of the edge, as defined by the bpmn.io color specification. Used as fallback when {@link border-color} is not set. */ + stroke?: string; + } + + interface BPMNLabel { + /** Font color of the label, as defined by the BPMN in Color and in the bpmn.io color specifications. */ + color?: string; + } +} + +// Extend internal model types with color properties to model data coming from the BPMN in Color specification. +declare module '../../../model/bpmn/internal/types' { + interface ShapeExtensions { + /** Fill color of the shape, as a CSS color string. */ + fillColor?: string; + /** Stroke (border) color of the shape, as a CSS color string. */ + strokeColor?: string; + } + + interface EdgeExtensions { + /** Stroke color of the edge, as a CSS color string. */ + strokeColor?: string; + } + + interface LabelExtensions { + /** Font color of the label, as a CSS color string. */ + color?: string; + } +} diff --git a/src/component/extension/extension-points.ts b/src/component/extension/extension-points.ts new file mode 100644 index 0000000000..df524b062f --- /dev/null +++ b/src/component/extension/extension-points.ts @@ -0,0 +1,91 @@ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { Edge } from '../../model/bpmn/internal/edge/edge'; +import type Shape from '../../model/bpmn/internal/shape/Shape'; +import type { BPMNEdge, BPMNShape } from '../../model/bpmn/json/bpmndi'; + +/** + * Extension point called during BPMN parsing to allow custom processing of deserialized elements. + * + * @since 0.48.0 + * @internal + */ +export interface ParsingExtensionPoint { + /** + * Called after a shape has been deserialized from the BPMN XML. + * + * @param shape The internal shape model built from the BPMN semantic and diagram data. + * @param bpmnShape The raw BPMN diagram shape from the XML. + */ + onShapeDeserialized?(shape: Shape, bpmnShape: BPMNShape): void; + + /** + * Called after an edge has been deserialized from the BPMN XML. + * + * @param edge The internal edge model built from the BPMN semantic and diagram data. + * @param bpmnEdge The raw BPMN diagram edge from the XML. + */ + onEdgeDeserialized?(edge: Edge, bpmnEdge: BPMNEdge): void; + + // We have to rework label handling — currently uses hasLabelExtensionData which is a workaround to ensure + // Labels are created when extensions need them (e.g., color). Find a more generic approach. + // Current issue: labels set to a shape or edge are immutable, so extensions cannot add a label if not already present. hasLabelExtensionData is used to check if a label should be created for the shape or edge during deserialization. + // See ADR for more details + /** + * Returns `true` if the given BPMN label data requires a label to be created during deserialization. + * + * This is a workaround: labels are immutable once set on a shape or edge, so extensions that need a label + * (e.g., to apply color) must signal that requirement here so the label is created upfront. + * + * @param bpmnLabel The raw label data from the BPMN diagram. + */ + hasLabelExtensionData?(bpmnLabel: unknown): boolean; +} + +/** + * Extension point for enriching the computed style of BPMN elements before they are rendered. + * + * @since 0.48.0 + * @internal + */ +export interface StyleExtensionPoint { + /** + * Enriches the style values computed for a shape before rendering. + * + * @param shape The internal shape model. + * @param styleValues The mutable map of style key/value pairs to enrich. + */ + enrichShapeStyle?(shape: Shape, styleValues: Map): void; + + /** + * Enriches the style values computed for an edge before rendering. + * + * @param edge The internal edge model. + * @param styleValues The mutable map of style key/value pairs to enrich. + */ + enrichEdgeStyle?(edge: Edge, styleValues: Map): void; + + /** + * Enriches the style values computed for the message flow icon of an edge before rendering. + * + * The message flow icon is a distinct visual element painted on a message flow edge. + * + * @param edge The internal edge model carrying the message flow icon. + * @param styleValues The mutable map of style key/value pairs to enrich. + */ + enrichMessageFlowIconStyle?(edge: Edge, styleValues: Map): void; +} diff --git a/src/component/mxgraph/renderer/StyleComputer.ts b/src/component/mxgraph/renderer/StyleComputer.ts index d44e0704ba..ae42c74796 100644 --- a/src/component/mxgraph/renderer/StyleComputer.ts +++ b/src/component/mxgraph/renderer/StyleComputer.ts @@ -17,6 +17,7 @@ limitations under the License. import type Bounds from '../../../model/bpmn/internal/Bounds'; import type { Edge } from '../../../model/bpmn/internal/edge/edge'; import type { Font } from '../../../model/bpmn/internal/Label'; +import type { StyleExtensionPoint } from '../../extension/extension-points'; import type { RendererOptions } from '../../options'; import { MessageVisibleKind, ShapeBpmnCallActivityKind, ShapeBpmnElementKind, ShapeBpmnMarkerKind, ShapeUtil } from '../../../model/bpmn/internal'; @@ -31,6 +32,7 @@ import { ShapeBpmnStartEvent, ShapeBpmnSubProcess, } from '../../../model/bpmn/internal/shape/ShapeBpmnElement'; +import { bpmnInColorStyleExtension } from '../../extension/bpmn-in-color/style-extension'; import { mxConstants } from '../initializer'; import { BpmnStyleIdentifier } from '../style'; @@ -38,12 +40,15 @@ import { BpmnStyleIdentifier } from '../style'; * @internal */ export default class StyleComputer { - private readonly ignoreBpmnColors: boolean; private readonly ignoreLabelStyles: boolean; + private readonly styleExtensions: StyleExtensionPoint[]; constructor(options?: RendererOptions) { - this.ignoreBpmnColors = options?.ignoreBpmnColors ?? true; this.ignoreLabelStyles = options?.ignoreLabelStyles ?? false; + // The extension list is hardcoded on purpose: the extension mechanism is currently introduced internally + // only; external injection (a public `bpmnExtensions` option) is deferred. See ADR 001 in + // docs/contributors/adr/, section "Refactoring scope vs. follow-up work". + this.styleExtensions = (options?.ignoreBpmnColors ?? true) ? [] : [bpmnInColorStyleExtension]; } computeStyle(bpmnCell: Shape | Edge, labelBounds: Bounds): string { @@ -82,17 +87,7 @@ export default class StyleComputer { styleValues.set(BpmnStyleIdentifier.EVENT_BASED_GATEWAY_KIND, String(bpmnElement.gatewayKind)); } - if (!this.ignoreBpmnColors) { - const extensions = shape.extensions; - const fillColor = extensions.fillColor; - if (fillColor) { - styleValues.set(mxConstants.STYLE_FILLCOLOR, fillColor); - if (ShapeUtil.isPoolOrLane(bpmnElement.kind)) { - styleValues.set(mxConstants.STYLE_SWIMLANE_FILLCOLOR, fillColor); - } - } - extensions.strokeColor && styleValues.set(mxConstants.STYLE_STROKECOLOR, extensions.strokeColor); - } + for (const extension of this.styleExtensions) extension.enrichShapeStyle?.(shape, styleValues); return styleValues; } @@ -100,10 +95,7 @@ export default class StyleComputer { private computeEdgeStyleValues(edge: Edge): Map { const styleValues = new Map(); - if (!this.ignoreBpmnColors) { - const extensions = edge.extensions; - extensions.strokeColor && styleValues.set(mxConstants.STYLE_STROKECOLOR, extensions.strokeColor); - } + for (const extension of this.styleExtensions) extension.enrichEdgeStyle?.(edge, styleValues); return styleValues; } @@ -118,22 +110,17 @@ export default class StyleComputer { styleValues.set(mxConstants.STYLE_FONTSTYLE, getFontStyleValue(font)); } - if (!this.ignoreBpmnColors) { - const extensions = bpmnCell.label?.extensions; - extensions?.color && styleValues.set(mxConstants.STYLE_FONTCOLOR, extensions.color); - } - return styleValues; } computeMessageFlowIconStyle(edge: Edge): string { - const styleValues: [string, string][] = []; - styleValues.push(['shape', BpmnStyleIdentifier.MESSAGE_FLOW_ICON], [BpmnStyleIdentifier.IS_INITIATING, String(edge.messageVisibleKind === MessageVisibleKind.INITIATING)]); - if (!this.ignoreBpmnColors) { - edge.extensions.strokeColor && styleValues.push([mxConstants.STYLE_STROKECOLOR, edge.extensions.strokeColor]); - } + const styleValues = new Map(); + styleValues.set('shape', BpmnStyleIdentifier.MESSAGE_FLOW_ICON); + styleValues.set(BpmnStyleIdentifier.IS_INITIATING, String(edge.messageVisibleKind === MessageVisibleKind.INITIATING)); + + for (const extension of this.styleExtensions) extension.enrichMessageFlowIconStyle?.(edge, styleValues); - return toArrayOfMxGraphStyleEntries(styleValues).join(';'); + return toArrayOfMxGraphStyleEntries([...styleValues]).join(';'); } } diff --git a/src/component/parser/json/converter/DiagramConverter.ts b/src/component/parser/json/converter/DiagramConverter.ts index 87bfd905f8..972c08db98 100644 --- a/src/component/parser/json/converter/DiagramConverter.ts +++ b/src/component/parser/json/converter/DiagramConverter.ts @@ -20,6 +20,7 @@ import type BpmnModel from '../../../../model/bpmn/internal/BpmnModel'; import type ShapeBpmnElement from '../../../../model/bpmn/internal/shape/ShapeBpmnElement'; import type { BPMNDiagram, BPMNEdge, BPMNLabel, BPMNLabelStyle, BPMNShape } from '../../../../model/bpmn/json/bpmndi'; import type { Point } from '../../../../model/bpmn/json/dc'; +import type { ParsingExtensionPoint } from '../../../extension/extension-points'; import type { ParsingMessageCollector } from '../../parsing-messages'; import { MessageVisibleKind, ShapeBpmnCallActivityKind, ShapeBpmnMarkerKind, ShapeUtil } from '../../../../model/bpmn/internal'; @@ -28,6 +29,7 @@ import { Edge, Waypoint } from '../../../../model/bpmn/internal/edge/edge'; import Label, { Font } from '../../../../model/bpmn/internal/Label'; import Shape from '../../../../model/bpmn/internal/shape/Shape'; import { ShapeBpmnCallActivity, ShapeBpmnSubProcess } from '../../../../model/bpmn/internal/shape/ShapeBpmnElement'; +import { bpmnInColorParsingExtension } from '../../../extension/bpmn-in-color/parsing-extension'; import { ensureIsArray } from '../../../helpers/array-utils'; import { EdgeUnknownBpmnElementWarning, LabelStyleMissingFontWarning, ShapeUnknownBpmnElementWarning } from '../warnings'; @@ -40,6 +42,10 @@ export default class DiagramConverter { private readonly parsingMessageCollector: ParsingMessageCollector, ) {} + // The extension list is hardcoded on purpose: the extension mechanism is currently introduced internally + // only; external injection (a public `bpmnExtensions` option) is deferred. See ADR 001 in + // docs/contributors/adr/, section "Refactoring scope vs. follow-up work". + private readonly parsingExtensions: ParsingExtensionPoint[] = [bpmnInColorParsingExtension]; private readonly convertedFonts = new Map(); deserialize(bpmnDiagrams: BPMNDiagram[] | BPMNDiagram): BpmnModel { @@ -124,7 +130,7 @@ export default class DiagramConverter { const bpmnLabel = bpmnShape.BPMNLabel; const label = this.deserializeLabel(bpmnLabel, bpmnShape.id); const shape = new Shape(bpmnShape.id, bpmnElement, bounds, label, isHorizontal); - setColorExtensionsOnShape(shape, bpmnShape); + for (const extension of this.parsingExtensions) extension.onShapeDeserialized?.(shape, bpmnShape); return shape; } @@ -148,7 +154,7 @@ export default class DiagramConverter { const messageVisibleKind = bpmnEdge.messageVisibleKind ? (bpmnEdge.messageVisibleKind as unknown as MessageVisibleKind) : MessageVisibleKind.NONE; const edge = new Edge(bpmnEdge.id, flow, waypoints, label, messageVisibleKind); - setColorExtensionsOnEdge(edge, bpmnEdge); + for (const extension of this.parsingExtensions) extension.onEdgeDeserialized?.(edge, bpmnEdge); return edge; }) .filter(Boolean); @@ -162,13 +168,9 @@ export default class DiagramConverter { if (bpmnLabel && typeof bpmnLabel === 'object') { const font = this.findFont(bpmnLabel.labelStyle, id); const bounds = deserializeBounds(bpmnLabel); - const label = new Label(font, bounds); - if ('color' in bpmnLabel) { - label.extensions.color = bpmnLabel.color as string; - return label; - } - if (font || bounds) { - return label; + const hasExtensionData = this.parsingExtensions.some(extension => extension.hasLabelExtensionData?.(bpmnLabel)); + if (font || bounds || hasExtensionData) { + return new Label(font, bounds); } } } @@ -187,32 +189,9 @@ export default class DiagramConverter { } } -// 'BPMN in Color' extensions with fallback to bpmn.io colors -function setColorExtensionsOnShape(shape: Shape, bpmnShape: BPMNShape): void { - if ('background-color' in bpmnShape) { - shape.extensions.fillColor = bpmnShape['background-color'] as string; - } else if ('fill' in bpmnShape) { - shape.extensions.fillColor = bpmnShape.fill as string; - } - if ('border-color' in bpmnShape) { - shape.extensions.strokeColor = bpmnShape['border-color'] as string; - } else if ('stroke' in bpmnShape) { - shape.extensions.strokeColor = bpmnShape.stroke as string; - } -} - function deserializeBounds(boundedElement: BPMNShape | BPMNLabel): Bounds { const bounds = boundedElement.Bounds; if (bounds) { return new Bounds(bounds.x, bounds.y, bounds.width, bounds.height); } } - -// 'BPMN in Color' extensions with fallback to bpmn.io colors -function setColorExtensionsOnEdge(edge: Edge, bpmnEdge: BPMNEdge): void { - if ('border-color' in bpmnEdge) { - edge.extensions.strokeColor = bpmnEdge['border-color'] as string; - } else if ('stroke' in bpmnEdge) { - edge.extensions.strokeColor = bpmnEdge.stroke as string; - } -} diff --git a/src/model/bpmn/internal/types.ts b/src/model/bpmn/internal/types.ts index 38d68487ac..173d564587 100644 --- a/src/model/bpmn/internal/types.ts +++ b/src/model/bpmn/internal/types.ts @@ -17,21 +17,17 @@ limitations under the License. /** * @internal */ -export interface ShapeExtensions { - fillColor?: string; - strokeColor?: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Empty interface to allow module augmentation by extensions +export interface ShapeExtensions {} /** * @internal */ -export interface EdgeExtensions { - strokeColor?: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Empty interface to allow module augmentation by extensions +export interface EdgeExtensions {} /** * @internal */ -export interface LabelExtensions { - color?: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Empty interface to allow module augmentation by extensions +export interface LabelExtensions {} diff --git a/test/unit/component/extension/bpmn-in-color/parsing-extension.test.ts b/test/unit/component/extension/bpmn-in-color/parsing-extension.test.ts new file mode 100644 index 0000000000..f196ece7cb --- /dev/null +++ b/test/unit/component/extension/bpmn-in-color/parsing-extension.test.ts @@ -0,0 +1,137 @@ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { BPMNEdge, BPMNShape } from '@lib/model/bpmn/json/bpmndi'; + +import { bpmnInColorParsingExtension } from '@lib/component/extension/bpmn-in-color/parsing-extension'; +import { ShapeBpmnElementKind } from '@lib/model/bpmn/internal'; +import { Edge } from '@lib/model/bpmn/internal/edge/edge'; +import { MessageFlow } from '@lib/model/bpmn/internal/edge/flows'; +import Label from '@lib/model/bpmn/internal/Label'; +import Shape from '@lib/model/bpmn/internal/shape/Shape'; +import ShapeBpmnElement from '@lib/model/bpmn/internal/shape/ShapeBpmnElement'; + +function newShape(label?: Label): Shape { + return new Shape('id', new ShapeBpmnElement('id', 'name', ShapeBpmnElementKind.TASK), undefined, label); +} + +function newMessageFlowEdge(label?: Label): Edge { + return new Edge('id', new MessageFlow('id', 'name', undefined, undefined), undefined, label); +} + +describe('BPMN in Color — parsing extension', () => { + describe('onShapeDeserialized', () => { + it('reads BPMN in Color spec attributes (background-color, border-color)', () => { + const shape = newShape(); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { 'background-color': '#aabbcc', 'border-color': '#112233' } as BPMNShape); + expect(shape.extensions.fillColor).toBe('#aabbcc'); + expect(shape.extensions.strokeColor).toBe('#112233'); + }); + + it('falls back to bpmn.io attributes (fill, stroke) when BPMN in Color attributes are missing', () => { + const shape = newShape(); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { fill: '#fallfill', stroke: '#fallstroke' } as BPMNShape); + expect(shape.extensions.fillColor).toBe('#fallfill'); + expect(shape.extensions.strokeColor).toBe('#fallstroke'); + }); + + it('prioritizes BPMN in Color spec over bpmn.io fallback', () => { + const shape = newShape(); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { + 'background-color': '#spec-fill', + fill: '#io-fill', + 'border-color': '#spec-stroke', + stroke: '#io-stroke', + } as BPMNShape); + expect(shape.extensions.fillColor).toBe('#spec-fill'); + expect(shape.extensions.strokeColor).toBe('#spec-stroke'); + }); + + it('reads label color when shape has a label and BPMNLabel carries a color', () => { + const label = new Label(undefined, undefined); + const shape = newShape(label); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { BPMNLabel: { color: '#labelcolor' } } as BPMNShape); + expect(label.extensions.color).toBe('#labelcolor'); + }); + + it('does not crash when the shape has no label even if BPMNLabel carries a color', () => { + const shape = newShape(); + expect(() => bpmnInColorParsingExtension.onShapeDeserialized(shape, { BPMNLabel: { color: '#labelcolor' } } as BPMNShape)).not.toThrow(); + }); + + it('does not set label color when BPMNLabel is a string (not an object)', () => { + const label = new Label(undefined, undefined); + const shape = newShape(label); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { BPMNLabel: 'just an id reference' } as BPMNShape); + expect(label.extensions.color).toBeUndefined(); + }); + + it('does not pollute shape extensions when no color attributes are present', () => { + const shape = newShape(); + bpmnInColorParsingExtension.onShapeDeserialized(shape, {} as BPMNShape); + expect(Object.hasOwn(shape.extensions, 'fillColor')).toBeFalse(); + expect(Object.hasOwn(shape.extensions, 'strokeColor')).toBeFalse(); + }); + + it('does not pollute label extensions when BPMNLabel object has no color key', () => { + const label = new Label(undefined, undefined); + const shape = newShape(label); + bpmnInColorParsingExtension.onShapeDeserialized(shape, { BPMNLabel: { labelStyle: 'id' } } as BPMNShape); + expect(Object.hasOwn(label.extensions, 'color')).toBeFalse(); + }); + }); + + describe('onEdgeDeserialized', () => { + it('reads BPMN in Color border-color attribute', () => { + const edge = newMessageFlowEdge(); + bpmnInColorParsingExtension.onEdgeDeserialized(edge, { 'border-color': '#edge-stroke' } as BPMNEdge); + expect(edge.extensions.strokeColor).toBe('#edge-stroke'); + }); + + it('falls back to bpmn.io stroke attribute', () => { + const edge = newMessageFlowEdge(); + bpmnInColorParsingExtension.onEdgeDeserialized(edge, { stroke: '#edge-io-stroke' } as BPMNEdge); + expect(edge.extensions.strokeColor).toBe('#edge-io-stroke'); + }); + + it('reads label color when edge has a label and BPMNLabel carries a color', () => { + const label = new Label(undefined, undefined); + const edge = newMessageFlowEdge(label); + bpmnInColorParsingExtension.onEdgeDeserialized(edge, { BPMNLabel: { color: '#edge-label' } } as BPMNEdge); + expect(label.extensions.color).toBe('#edge-label'); + }); + + it('does not pollute edge extensions when no stroke attribute is present', () => { + const edge = newMessageFlowEdge(); + bpmnInColorParsingExtension.onEdgeDeserialized(edge, {} as BPMNEdge); + expect(Object.hasOwn(edge.extensions, 'strokeColor')).toBeFalse(); + }); + }); + + describe('hasLabelExtensionData', () => { + it.each([ + ['undefined', undefined, false], + ['null', null, false], + ['empty string', '', false], + ['non-empty string', 'a label id', false], + ['object without color', { labelStyle: 'id' }, false], + ['object with color', { color: '#abc' }, true], + ['object with color and other keys', { color: '#abc', labelStyle: 'id' }, true], + ])('returns %s for %s', (_name, input, expected) => { + expect(bpmnInColorParsingExtension.hasLabelExtensionData(input)).toBe(expected); + }); + }); +}); diff --git a/test/unit/component/extension/bpmn-in-color/style-extension.test.ts b/test/unit/component/extension/bpmn-in-color/style-extension.test.ts new file mode 100644 index 0000000000..5e8fa285f3 --- /dev/null +++ b/test/unit/component/extension/bpmn-in-color/style-extension.test.ts @@ -0,0 +1,153 @@ +/** + * @jest-environment jsdom + */ +/* +Copyright 2026 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { bpmnInColorStyleExtension } from '@lib/component/extension/bpmn-in-color/style-extension'; +import { mxConstants } from '@lib/component/mxgraph/initializer'; +import { ShapeBpmnElementKind } from '@lib/model/bpmn/internal'; +import { Edge } from '@lib/model/bpmn/internal/edge/edge'; +import { MessageFlow, SequenceFlow } from '@lib/model/bpmn/internal/edge/flows'; +import { SequenceFlowKind } from '@lib/model/bpmn/internal/edge/kinds'; +import Label from '@lib/model/bpmn/internal/Label'; +import Shape from '@lib/model/bpmn/internal/shape/Shape'; +import ShapeBpmnElement from '@lib/model/bpmn/internal/shape/ShapeBpmnElement'; + +function newShape(kind: ShapeBpmnElementKind, label?: Label): Shape { + return new Shape('id', new ShapeBpmnElement('id', 'name', kind), undefined, label); +} + +function newLabel(color?: string): Label { + const label = new Label(undefined, undefined); + if (color !== undefined) label.extensions.color = color; + return label; +} + +function newSequenceFlowEdge(label?: Label): Edge { + return new Edge('id', new SequenceFlow('id', 'name', undefined, undefined, SequenceFlowKind.NORMAL), undefined, label); +} + +function newMessageFlowEdge(): Edge { + return new Edge('id', new MessageFlow('id', 'name', undefined, undefined)); +} + +describe('BPMN in Color — style extension', () => { + describe('enrichShapeStyle', () => { + it('sets fill and stroke color when present on shape extensions', () => { + const shape = newShape(ShapeBpmnElementKind.TASK); + shape.extensions.fillColor = '#aabbcc'; + shape.extensions.strokeColor = '#112233'; + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichShapeStyle(shape, styleValues); + + expect(styleValues.get(mxConstants.STYLE_FILLCOLOR)).toBe('#aabbcc'); + expect(styleValues.get(mxConstants.STYLE_STROKECOLOR)).toBe('#112233'); + expect(styleValues.has(mxConstants.STYLE_SWIMLANE_FILLCOLOR)).toBeFalse(); + }); + + it.each([ShapeBpmnElementKind.POOL, ShapeBpmnElementKind.LANE])('also sets swimlane fill color for %s', (kind: ShapeBpmnElementKind) => { + const shape = newShape(kind); + shape.extensions.fillColor = '#abcdef'; + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichShapeStyle(shape, styleValues); + + expect(styleValues.get(mxConstants.STYLE_FILLCOLOR)).toBe('#abcdef'); + expect(styleValues.get(mxConstants.STYLE_SWIMLANE_FILLCOLOR)).toBe('#abcdef'); + }); + + it('does not set swimlane fill color when fillColor is absent on a pool', () => { + const shape = newShape(ShapeBpmnElementKind.POOL); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichShapeStyle(shape, styleValues); + + expect(styleValues.has(mxConstants.STYLE_FILLCOLOR)).toBeFalse(); + expect(styleValues.has(mxConstants.STYLE_SWIMLANE_FILLCOLOR)).toBeFalse(); + }); + + it('sets font color from label extensions', () => { + const shape = newShape(ShapeBpmnElementKind.TASK, newLabel('#fontcolor')); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichShapeStyle(shape, styleValues); + + expect(styleValues.get(mxConstants.STYLE_FONTCOLOR)).toBe('#fontcolor'); + }); + + it('produces an empty map when no extension data is present', () => { + const shape = newShape(ShapeBpmnElementKind.TASK); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichShapeStyle(shape, styleValues); + + expect(styleValues.size).toBe(0); + }); + }); + + describe('enrichEdgeStyle', () => { + it('sets stroke color from edge extensions', () => { + const edge = newSequenceFlowEdge(); + edge.extensions.strokeColor = '#abc123'; + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichEdgeStyle(edge, styleValues); + + expect(styleValues.get(mxConstants.STYLE_STROKECOLOR)).toBe('#abc123'); + }); + + it('sets font color from label extensions', () => { + const edge = newSequenceFlowEdge(newLabel('#labelcolor')); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichEdgeStyle(edge, styleValues); + + expect(styleValues.get(mxConstants.STYLE_FONTCOLOR)).toBe('#labelcolor'); + }); + + it('produces an empty map when no extension data is present', () => { + const edge = newSequenceFlowEdge(); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichEdgeStyle(edge, styleValues); + + expect(styleValues.size).toBe(0); + }); + }); + + describe('enrichMessageFlowIconStyle', () => { + it('sets stroke color from the edge stroke color extension', () => { + const edge = newMessageFlowEdge(); + edge.extensions.strokeColor = '#deadbe'; + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichMessageFlowIconStyle(edge, styleValues); + + expect(styleValues.get(mxConstants.STYLE_STROKECOLOR)).toBe('#deadbe'); + }); + + it('does not set anything when no stroke color is present', () => { + const edge = newMessageFlowEdge(); + const styleValues = new Map(); + + bpmnInColorStyleExtension.enrichMessageFlowIconStyle(edge, styleValues); + + expect(styleValues.size).toBe(0); + }); + }); +}); From 805080e00ee6c94c2b2a692e08ae357825fddb65 Mon Sep 17 00:00:00 2001 From: Thomas Bouffard <27200110+tbouffard@users.noreply.github.com> Date: Sat, 16 May 2026 07:05:26 +0200 Subject: [PATCH 2/3] Final: remove explore, plan, tasks doc used during the implementation --- .../01-bpmn-in-color-extension-poc/explore.md | 108 ---------------- .../01-bpmn-in-color-extension-poc/plan.md | 119 ------------------ .../tasks/index.md | 19 --- .../tasks/task-01.md | 28 ----- .../tasks/task-02.md | 30 ----- .../tasks/task-03.md | 35 ------ 6 files changed, 339 deletions(-) delete mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/explore.md delete mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/plan.md delete mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/tasks/index.md delete mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-01.md delete mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-02.md delete mode 100644 .claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-03.md diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/explore.md b/.claude/tasks/01-bpmn-in-color-extension-poc/explore.md deleted file mode 100644 index 83b54ad6a3..0000000000 --- a/.claude/tasks/01-bpmn-in-color-extension-poc/explore.md +++ /dev/null @@ -1,108 +0,0 @@ -# Task: PoC — Manage BPMN in Color as extension - -Based on the ADR `docs/contributors/adr/001-bpmn-extensions-management.md`, implement the minimal extension mechanism to refactor BPMN in Color as a built-in extension. - -## Codebase Context - -### Current BPMN in Color implementation - -**3 touchpoints** to refactor: - -#### 1. Parsing — DiagramConverter -`src/component/parser/json/converter/DiagramConverter.ts` -- `setColorExtensionsOnShape(shape, bpmnShape)` (lines 191-202): reads `background-color`/`fill` → `shape.extensions.fillColor`, `border-color`/`stroke` → `shape.extensions.strokeColor` -- `setColorExtensionsOnEdge(edge, bpmnEdge)` (lines 212-218): reads `border-color`/`stroke` → `edge.extensions.strokeColor` -- Label color handling inline in `deserializeLabel()` (lines 166-169): reads `color` → `label.extensions.color` -- Called from: `deserializeShape()` line 127, edge loop line 151, `deserializeLabel()` line 166 - -#### 2. Style computing — StyleComputer -`src/component/mxgraph/renderer/StyleComputer.ts` -- `computeShapeStyleValues()` lines 85-95: reads `shape.extensions.fillColor`/`strokeColor`, sets mxConstants. Special case for pools/lanes (`STYLE_SWIMLANE_FILLCOLOR`) -- `computeEdgeStyleValues()` lines 103-106: reads `edge.extensions.strokeColor` -- `computeFontStyleValues()` lines 121-124: reads `bpmnCell.label?.extensions.color` -- `computeMessageFlowIconStyle()` lines 132-134: reads `edge.extensions.strokeColor` -- All gated by `if (!this.ignoreBpmnColors)` flag -- `ignoreBpmnColors` defaults to `true` (colors disabled by default), set in constructor line 45 - -#### 3. Extension types — Internal model -`src/model/bpmn/internal/types.ts` -- `ShapeExtensions` (type alias, lines 20-23): `{ fillColor?: string; strokeColor?: string }` -- `EdgeExtensions` (type alias, lines 28-30): `{ strokeColor?: string }` -- `LabelExtensions` (type alias, lines 35-37): `{ color?: string }` -- Used in Shape (line 26), Edge (line 27), Label (line 24) as `readonly extensions: XxxExtensions = {}` -- **NOT publicly exported** — internal only - -### Initialization & wiring chain - -``` -BpmnVisualization(options: GlobalOptions) - ├─ options.renderer → this.rendererOptions - ├─ createNewBpmnGraph(container, rendererOptions) → BpmnGraph - └─ load(xml): - ├─ newBpmnParser(this.parserOptions).parse(xml) - │ ├─ BpmnXmlParser.parse(xml) → JSON - │ └─ BpmnJsonParser.parse(json) → BpmnModel - │ └─ DiagramConverter.deserialize() ← COLOR PARSING HERE - ├─ bpmnModelRegistry.load(bpmnModel) - └─ newBpmnRenderer(graph, rendererOptions).render(renderedModel) - └─ StyleComputer ← COLOR STYLE HERE -``` - -**Parser creation**: `newBpmnParser()` in `src/component/parser/BpmnParser.ts` lines 43-45. Creates `BpmnJsonParser` which creates `DiagramConverter`. - -**BpmnJsonParser factory**: `src/component/parser/json/BpmnJsonParser.ts` lines 57-67. Creates all 6 converters. `DiagramConverter` is one of them. - -**Renderer creation**: `newBpmnRenderer()` in `src/component/mxgraph/BpmnRenderer.ts` lines 162-164. Creates `StyleComputer(options)`. - -### GlobalOptions / RendererOptions -`src/component/options.ts` -- `GlobalOptions` (lines 23-32): `{ container, navigation?, parser?, renderer? }` -- `RendererOptions` (lines 201-267): `{ iconPainter?, ignoreActivityLabelBounds?, ignoreBpmnColors?, ignoreLabelStyles?, ignoreTaskLabelBounds? }` - -## Key Files - -- `src/model/bpmn/internal/types.ts` — Extension type definitions to migrate (type → interface) -- `src/model/bpmn/internal/shape/Shape.ts:26` — Shape.extensions property -- `src/model/bpmn/internal/edge/edge.ts:27` — Edge.extensions property -- `src/model/bpmn/internal/Label.ts:24` — Label.extensions property -- `src/component/parser/json/converter/DiagramConverter.ts:127,151,166-169,191-218` — Color parsing functions -- `src/component/mxgraph/renderer/StyleComputer.ts:40-47,85-95,103-106,121-124,132-134` — Color style computing -- `src/component/parser/json/BpmnJsonParser.ts:57-67` — Parser factory (where to inject parsing extension) -- `src/component/parser/BpmnParser.ts:43-45` — Top-level parser factory -- `src/component/mxgraph/BpmnRenderer.ts:162-164` — Renderer factory (where to inject style extension) -- `src/component/BpmnVisualization.ts:78-87,95-103` — Constructor and load() method -- `src/component/options.ts:23-32,201-267` — GlobalOptions and RendererOptions - -## Tests that must keep passing - -| Category | File | Focus | -|----------|------|-------| -| Unit: Parsing | `test/unit/component/parser/BpmnParser.test.ts` lines 58-297 | Color attribute parsing, bpmn.io fallback | -| Unit: Style | `test/unit/component/mxgraph/renderer/StyleComputer.test.ts` lines 537-610 | Color style computation, ignoreBpmnColors flag | -| Integration | `test/integration/mxGraph.model.bpmn.colors.test.ts` | Default behavior (colors ignored) | -| E2E | `test/e2e/bpmn.colors.test.ts` lines 150-170 | Visual regression with/without colors | - -**Test fixtures**: `test/fixtures/bpmn/bpmn-in-color/` and `test/fixtures/bpmn/xml-parsing/bpmn-in-color/` - -**Test helpers**: `test/unit/helpers/bpmn-model-expect.ts` (ExpectedShape/Edge/Label with extensions), `test/unit/helpers/JsonTestUtils.ts` (verifyLabel with extensions) - -## Patterns to Follow - -1. **Extension types are `readonly` on model objects** but contents are mutated after construction (e.g., `shape.extensions.fillColor = ...`) -2. **`ignoreBpmnColors` flag** gates all color application in StyleComputer — this must be preserved -3. **BPMN in Color supports fallback** to bpmn.io attributes (`fill`/`stroke` vs `background-color`/`border-color`) -4. **Pools/lanes have special handling**: fillColor also sets `STYLE_SWIMLANE_FILLCOLOR` -5. **Message flow icon** has separate style computation using `push()` instead of `set()` on a different data structure - -## Scope for PoC (minimal) - -Based on the ADR migration plan, the minimal PoC should: - -1. **Create extension point interfaces**: `ParsingExtensionPoint`, `StyleExtensionPoint`, `BpmnExtension` -2. **Migrate types**: `ShapeExtensions`, `EdgeExtensions`, `LabelExtensions` from type alias to empty interface -3. **Create built-in BPMN in Color extension** implementing the interfaces -4. **Wire into DiagramConverter**: call parsing extension points instead of hardcoded functions -5. **Wire into StyleComputer**: call style extension points instead of hardcoded color logic -6. **All existing tests must pass unchanged** - -NOT in scope: `RenderingExtensionPoint`, `BpmnVisualization` constructor API for custom extensions, `iconPainter` injection. diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/plan.md b/.claude/tasks/01-bpmn-in-color-extension-poc/plan.md deleted file mode 100644 index cd04fc3189..0000000000 --- a/.claude/tasks/01-bpmn-in-color-extension-poc/plan.md +++ /dev/null @@ -1,119 +0,0 @@ -# Implementation Plan: PoC — BPMN in Color as Extension - -## Overview - -Refactor the hardcoded BPMN in Color support into the new extension mechanism defined in the ADR. This is a pure internal refactoring — no user-facing behavior change, no new public API for custom extensions. - -The approach: -1. Define extension point interfaces (internal) -2. Migrate extension types from type aliases to empty interfaces + module augmentation -3. Extract BPMN in Color logic into a `BpmnExtension` implementation -4. Wire extension points into DiagramConverter and StyleComputer -5. Remove hardcoded color code from core - -Key design decisions: -- **Parsing**: DiagramConverter's constructor does **not** change. It internally imports and always uses the BPMN in Color parsing extension. Colors are always parsed from XML into the model — this matches the current behavior. The extension usage is an implementation detail. -- **Style**: StyleComputer's constructor does **not** change. It keeps its `options?: RendererOptions` parameter. Internally, when `ignoreBpmnColors` is false, it uses the BPMN in Color style extension. The extension registration is an internal implementation detail. - -## Dependencies - -Files must be changed in this order due to type dependencies: -1. Extension point interfaces (new file) — no dependencies -2. Extension types migration — needed by everything else -3. BPMN in Color extension implementation — depends on interfaces + types -4. DiagramConverter wiring — depends on extension interfaces -5. StyleComputer wiring — depends on extension interfaces - -## File Changes - -### NEW `src/component/extension/extension-points.ts` - -- Define `ParsingExtensionPoint` interface with two optional methods: `onShapeDeserialized(shape, bpmnShape)` and `onEdgeDeserialized(edge, bpmnEdge)`. Both receive the internal model object and the raw JSON object. -- Define `StyleExtensionPoint` interface with two optional methods: `enrichShapeStyle(shape, styleValues)` and `enrichEdgeStyle(edge, styleValues)`. The `styleValues` parameter is a `Map` that the extension mutates directly. -- Define `BpmnExtension` interface grouping `parsing?: ParsingExtensionPoint` and `style?: StyleExtensionPoint` -- All interfaces marked `@internal` - -### `src/model/bpmn/internal/types.ts` - -- Convert `ShapeExtensions` from type alias to empty interface: `export interface ShapeExtensions {}` -- Convert `EdgeExtensions` from type alias to empty interface: `export interface EdgeExtensions {}` -- Convert `LabelExtensions` from type alias to empty interface: `export interface LabelExtensions {}` -- Keep `@internal` JSDoc tags - -### NEW `src/component/extension/bpmn-in-color-extension.ts` - -- Add module augmentation block: `declare module` targeting the internal types file to add color properties (`fillColor`, `strokeColor` on ShapeExtensions, `strokeColor` on EdgeExtensions, `color` on LabelExtensions) -- Important: since extension types are internal (not publicly exported), the module augmentation path must target the relative internal types file, not `'bpmn-visualization'` -- Implement a `ParsingExtensionPoint` object with: - - `onShapeDeserialized`: move logic from `setColorExtensionsOnShape` (DiagramConverter lines 191-202) + label color handling (lines 166-169, read from bpmnShape's BPMNLabel) - - `onEdgeDeserialized`: move logic from `setColorExtensionsOnEdge` (DiagramConverter lines 212-218) + label color handling for edge labels -- Implement a `StyleExtensionPoint` object with: - - `enrichShapeStyle`: move logic from StyleComputer `computeShapeStyleValues` lines 85-95 (fillColor, strokeColor, swimlaneFillColor for pools/lanes). Receives the Shape and the existing styleValues Map. - - `enrichEdgeStyle`: move logic from StyleComputer `computeEdgeStyleValues` lines 103-106 (strokeColor) -- Consider: `computeFontStyleValues` reads `bpmnCell.label?.extensions.color` — this is label styling on both shapes and edges. The style extension's `enrichShapeStyle` and `enrichEdgeStyle` methods handle this by accessing `shape.label` / `edge.label` to set font color. -- Consider: `computeMessageFlowIconStyle` uses a different data structure (`[string, string][]` with `push()` instead of `Map` with `set()`). For the PoC, keep message flow icon color logic inline in StyleComputer — it's just one line and uses a different structure. It still reads from the extension data populated by the parsing extension. -- Export `bpmnInColorParsingExtension` (the `ParsingExtensionPoint`) and `bpmnInColorStyleExtension` (the `StyleExtensionPoint`) separately, so they can be consumed independently by DiagramConverter and StyleComputer - -### `src/component/parser/json/converter/DiagramConverter.ts` - -- Constructor signature **does not change**. Keep `constructor(convertedElements, parsingMessageCollector)`. -- Add a private field `private readonly parsingExtensions: ParsingExtensionPoint[]` initialized to `[bpmnInColorParsingExtension]`. Import `bpmnInColorParsingExtension` from the extension module. -- In `deserializeShape()` (after line 126, after shape construction): replace `setColorExtensionsOnShape(shape, bpmnShape)` with a loop calling `ext.onShapeDeserialized?.(shape, bpmnShape)` for each parsing extension -- In `deserializeEdges()` (after line 150, after edge construction): replace `setColorExtensionsOnEdge(edge, bpmnEdge)` with a loop calling `ext.onEdgeDeserialized?.(edge, bpmnEdge)` for each parsing extension -- In `deserializeLabel()` (lines 166-169): remove the inline `if ('color' in bpmnLabel)` block — this is now handled by the parsing extension's `onShapeDeserialized`/`onEdgeDeserialized` which access the label via `shape.label` / `edge.label` and read color from the raw `bpmnShape.BPMNLabel` / `bpmnEdge.BPMNLabel` -- Delete `setColorExtensionsOnShape` function (lines 190-202) -- Delete `setColorExtensionsOnEdge` function (lines 211-218) - -### `src/component/mxgraph/renderer/StyleComputer.ts` - -- Constructor signature **does not change**. Keep `constructor(options?: RendererOptions)`. -- Add a private field `private readonly styleExtensions: StyleExtensionPoint[]` initialized in the constructor: if `!this.ignoreBpmnColors`, include `bpmnInColorStyleExtension` in the array; otherwise, empty array. -- Import `bpmnInColorStyleExtension` from the extension module -- In `computeShapeStyleValues()`: replace lines 85-95 (the `if (!this.ignoreBpmnColors)` block) with a loop calling `ext.enrichShapeStyle?.(shape, styleValues)` for each style extension -- In `computeEdgeStyleValues()`: replace lines 103-106 (the `if (!this.ignoreBpmnColors)` block) with a loop calling `ext.enrichEdgeStyle?.(edge, styleValues)` for each style extension -- In `computeFontStyleValues()`: remove lines 121-124 (the `if (!this.ignoreBpmnColors)` block for label color) — now handled by the style extension within `enrichShapeStyle`/`enrichEdgeStyle` -- In `computeMessageFlowIconStyle()`: keep the color logic inline (lines 132-134) — it uses a `[string, string][]` structure different from the `Map`. It still reads `edge.extensions.strokeColor` which is populated by the parsing extension. Keep the `if (!this.ignoreBpmnColors)` guard here for now. -- The `ignoreBpmnColors` private field is kept for: (a) deciding which style extensions to register, (b) the message flow icon inline color logic - -### `src/component/parser/json/BpmnJsonParser.ts` - -- No change needed. DiagramConverter handles its own extension registration internally. - -### `src/component/parser/BpmnParser.ts` - -- No change needed. - -### `src/component/mxgraph/BpmnRenderer.ts` - -- No change needed. StyleComputer handles its own extension registration internally. - -### `src/component/BpmnVisualization.ts` - -- No change needed. The `ignoreBpmnColors` flag flows through `RendererOptions` to `StyleComputer` as before. Parsing extensions are wired in `newBpmnJsonParser`. - -### `src/component/options.ts` - -- No change needed. - -## Testing Strategy - -### No new tests needed for behavior -This is a pure refactoring — all existing tests must pass. The test suite is the validation. - -### Tests to verify pass (run after each step): -- `npm run test:unit` — particularly `BpmnParser.test.ts` (color parsing) and `StyleComputer.test.ts` (color styling) -- `npm run test:integration` — particularly `mxGraph.model.bpmn.colors.test.ts` -- `npm run test:e2e` — particularly `bpmn.colors.test.ts` - -### Note on test compatibility -- `StyleComputer.test.ts` tests create `StyleComputer` directly with `{ ignoreBpmnColors: false }`. Since StyleComputer's constructor signature is unchanged and it internally registers the BPMN in Color style extension based on that flag, **these tests require no changes**. -- `BpmnParser.test.ts` tests use the parser factory which now always includes the parsing extension. Since colors were always parsed before, **these tests require no changes**. - -## Rollout Considerations - -- No breaking changes — this is internal refactoring only -- `ignoreBpmnColors` public API behavior is preserved -- Extension types are internal, so the type→interface migration has no public API impact -- Module augmentation for BPMN in Color is internal, not user-facing -- No changes to BpmnVisualization, BpmnRenderer, BpmnParser, BpmnJsonParser, or options — minimal blast radius -- DiagramConverter and StyleComputer constructors are unchanged — no ripple effect on callers or tests diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/index.md b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/index.md deleted file mode 100644 index 02dbea5190..0000000000 --- a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/index.md +++ /dev/null @@ -1,19 +0,0 @@ -# Tasks: BPMN in Color Extension PoC - -## Overview - -Refactor the hardcoded BPMN in Color support into the new extension mechanism defined in the ADR. Pure internal refactoring — no user-facing behavior change. - -## Task List - -- [ ] **Task 1**: Define extension point interfaces and migrate extension types - `task-01.md` -- [ ] **Task 2**: Implement BPMN in Color as extension - `task-02.md` (depends on Task 1) -- [ ] **Task 3**: Wire extension into DiagramConverter and StyleComputer - `task-03.md` (depends on Task 2) - -## Execution Order - -Sequential: Task 1 → Task 2 → Task 3 (each depends on the previous) - -## Validation - -After Task 3, run `npm run all` to verify the full check passes (lint, build, all tests). diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-01.md b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-01.md deleted file mode 100644 index 3ae7e4f98d..0000000000 --- a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-01.md +++ /dev/null @@ -1,28 +0,0 @@ -# Task: Define extension point interfaces and migrate extension types - -## Problem - -There are no extension point interfaces in the codebase. The extension types (`ShapeExtensions`, `EdgeExtensions`, `LabelExtensions`) are type aliases, which prevents TypeScript module augmentation. - -## Proposed Solution - -Create the extension point interfaces (`ParsingExtensionPoint`, `StyleExtensionPoint`, `BpmnExtension`) in a new file. Migrate the three extension types from type aliases to empty interfaces. - -## Dependencies - -- None (can start immediately) - -## Context - -- New file: `src/component/extension/extension-points.ts` -- Types to migrate: `src/model/bpmn/internal/types.ts` (lines 20-37) -- ADR describes the interfaces: `docs/contributors/adr/001-bpmn-extensions-management.md` -- `ParsingExtensionPoint`: `onShapeDeserialized(shape, bpmnShape)`, `onEdgeDeserialized(edge, bpmnEdge)` -- `StyleExtensionPoint`: `enrichShapeStyle(shape, styleValues)`, `enrichEdgeStyle(edge, styleValues)` -- `styleValues` is `Map` mutated in place - -## Success Criteria - -- Extension point interfaces exist and compile -- Extension types are empty interfaces (not type aliases) -- All existing tests pass unchanged (the empty interfaces are structurally compatible with the old types) diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-02.md b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-02.md deleted file mode 100644 index 341c281319..0000000000 --- a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-02.md +++ /dev/null @@ -1,30 +0,0 @@ -# Task: Implement BPMN in Color as extension - -## Problem - -The BPMN in Color parsing and style logic is hardcoded in `DiagramConverter` and `StyleComputer`. It needs to be extracted into a `BpmnExtension` implementation using the new extension point interfaces. - -## Proposed Solution - -Create a new module that implements `ParsingExtensionPoint` and `StyleExtensionPoint` for BPMN in Color. Move the existing color logic from DiagramConverter and StyleComputer into this module, including module augmentation to declare the color properties on the extension interfaces. - -## Dependencies - -- Task #1: Extension point interfaces and migrated types must exist - -## Context - -- New file: `src/component/extension/bpmn-in-color-extension.ts` -- Parsing logic to move: `src/component/parser/json/converter/DiagramConverter.ts` lines 166-169 (label color), 191-202 (`setColorExtensionsOnShape`), 212-218 (`setColorExtensionsOnEdge`) -- Style logic to move: `src/component/mxgraph/renderer/StyleComputer.ts` lines 85-95 (shape colors), 103-106 (edge colors), 121-124 (label font color) -- Module augmentation targets internal types file to add `fillColor`, `strokeColor`, `color` properties -- Label color handling moves from `deserializeLabel` into `onShapeDeserialized`/`onEdgeDeserialized` (label accessible via `shape.label` / `edge.label`, raw data via `bpmnShape.BPMNLabel`) -- `enrichShapeStyle`/`enrichEdgeStyle` also handle font color (via `shape.label.extensions.color`) -- Pools/lanes special case: fillColor also sets `STYLE_SWIMLANE_FILLCOLOR` -- Export `bpmnInColorParsingExtension` and `bpmnInColorStyleExtension` separately - -## Success Criteria - -- Module compiles and exports both extension point implementations -- Module augmentation adds color properties to the empty extension interfaces -- All color-related logic from DiagramConverter and StyleComputer is replicated in the extension diff --git a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-03.md b/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-03.md deleted file mode 100644 index 594369bbc8..0000000000 --- a/.claude/tasks/01-bpmn-in-color-extension-poc/tasks/task-03.md +++ /dev/null @@ -1,35 +0,0 @@ -# Task: Wire extension into DiagramConverter and StyleComputer - -## Problem - -DiagramConverter and StyleComputer still contain hardcoded BPMN in Color logic. They need to use the new extension points instead. - -## Proposed Solution - -Update DiagramConverter to internally register the BPMN in Color parsing extension and call it via the extension point loop. Update StyleComputer to internally register the BPMN in Color style extension (when `ignoreBpmnColors` is false) and call it via the extension point loop. Remove the hardcoded color functions and blocks. - -## Dependencies - -- Task #2: BPMN in Color extension implementation must exist - -## Context - -- DiagramConverter (`src/component/parser/json/converter/DiagramConverter.ts`): - - Constructor unchanged. Add private field `parsingExtensions` initialized to `[bpmnInColorParsingExtension]` - - Replace `setColorExtensionsOnShape(shape, bpmnShape)` (line 127) with extension loop - - Replace `setColorExtensionsOnEdge(edge, bpmnEdge)` (line 151) with extension loop - - Remove label color block in `deserializeLabel` (lines 166-169) - - Delete `setColorExtensionsOnShape` and `setColorExtensionsOnEdge` functions -- StyleComputer (`src/component/mxgraph/renderer/StyleComputer.ts`): - - Constructor unchanged. Add private field `styleExtensions` populated based on `ignoreBpmnColors` - - Replace `if (!this.ignoreBpmnColors)` blocks in `computeShapeStyleValues` (lines 85-95) and `computeEdgeStyleValues` (lines 103-106) with extension loops - - Remove font color block in `computeFontStyleValues` (lines 121-124) — handled by style extension - - Keep `computeMessageFlowIconStyle` color logic inline (line 132-134) — different data structure - - Keep `ignoreBpmnColors` field for message flow icon logic and extension registration decision - -## Success Criteria - -- All existing tests pass unchanged (unit, integration, e2e) -- No BPMN in Color-specific logic remains in DiagramConverter except the extension array -- StyleComputer's color logic is delegated to the extension (except message flow icon) -- `npm run lint-check` passes From 32c1ee9d8944153b35a55f8867f3657247d28e70 Mon Sep 17 00:00:00 2001 From: Thomas Bouffard <27200110+tbouffard@users.noreply.github.com> Date: Tue, 19 May 2026 18:54:47 +0200 Subject: [PATCH 3/3] Add DF-BPMN example to BPMN extensions list Added example of DF-BPMN extension for data modeling. [skip ci] --- docs/contributors/adr/001-bpmn-extensions-management.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/contributors/adr/001-bpmn-extensions-management.md b/docs/contributors/adr/001-bpmn-extensions-management.md index 5adc0ac592..b99806c187 100644 --- a/docs/contributors/adr/001-bpmn-extensions-management.md +++ b/docs/contributors/adr/001-bpmn-extensions-management.md @@ -22,6 +22,7 @@ Extensions can be applied to both the semantic model (process elements like task Examples of BPMN extensions include: - [BPMN in Color](https://github.com/bpmn-miwg/bpmn-in-color): Adds color attributes (`background-color`, `border-color`, ...) to shapes, edges, and labels. - [Bonita Connector](https://documentation.bonitasoft.com/bonita/2025.2/process/connectivity-overview): Adds connector metadata to tasks and pools, displayed as a specific icon on the top-right of the Tasks (not displayed on Pools). +- [DF-BPMN](github.com/NourEldin-Ali/df-bpmn/): DataFlow BPMN (DF-BPMN), is a low-coding visual solution, for modeling and analyzing the relationship between process and data. Managing BPMN extensions in a consistent and extensible way is important to allow `bpmn-visualization` to support known extensions and to let users implement their own.