@yalehwang/plantuml-js 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +20 -0
  2. package/dist/core.d.ts +106 -13
  3. package/dist/core.js +92 -52
  4. package/dist/foundation/labels.d.ts +44 -0
  5. package/dist/foundation/labels.js +231 -0
  6. package/dist/foundation/layers.d.ts +25 -0
  7. package/dist/foundation/layers.js +101 -0
  8. package/dist/foundation/layout.d.ts +116 -0
  9. package/dist/foundation/layout.js +298 -0
  10. package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
  11. package/dist/foundation/relation-semantics.d.ts +49 -0
  12. package/dist/foundation/relation-semantics.js +112 -0
  13. package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
  14. package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/layout/compound.d.ts +74 -0
  18. package/dist/layout/compound.js +1204 -0
  19. package/dist/layout/elk-graph.d.ts +36 -0
  20. package/dist/layout/elk-graph.js +220 -0
  21. package/dist/layout/elk-result.d.ts +18 -0
  22. package/dist/layout/elk-result.js +127 -0
  23. package/dist/layout/elk.d.ts +80 -33
  24. package/dist/layout/elk.js +229 -320
  25. package/dist/layout/fanout-trunk.d.ts +18 -0
  26. package/dist/layout/fanout-trunk.js +380 -0
  27. package/dist/layout/grid-router.d.ts +69 -0
  28. package/dist/layout/grid-router.js +615 -0
  29. package/dist/layout/lane-spread.d.ts +11 -0
  30. package/dist/layout/lane-spread.js +302 -0
  31. package/dist/layout/package-endpoints.d.ts +10 -0
  32. package/dist/layout/package-endpoints.js +54 -0
  33. package/dist/layout/placement-strategy.d.ts +98 -0
  34. package/dist/layout/placement-strategy.js +26 -0
  35. package/dist/layout/strategy.d.ts +27 -0
  36. package/dist/layout/strategy.js +19 -0
  37. package/dist/layout/trunk-lanes.d.ts +29 -0
  38. package/dist/layout/trunk-lanes.js +308 -0
  39. package/dist/layout/viz.d.ts +3 -3
  40. package/dist/layout/viz.js +13 -17
  41. package/dist/metrics/layout-metrics.d.ts +183 -0
  42. package/dist/metrics/layout-metrics.js +766 -0
  43. package/dist/parse/parser.d.ts +43 -0
  44. package/dist/{parser.js → parse/parser.js} +111 -5
  45. package/dist/parse/serialize.d.ts +32 -0
  46. package/dist/parse/serialize.js +218 -0
  47. package/dist/render/legend.d.ts +15 -0
  48. package/dist/render/legend.js +42 -0
  49. package/dist/render/svg.d.ts +60 -3
  50. package/dist/render/svg.js +326 -98
  51. package/dist/render/theme.d.ts +109 -0
  52. package/dist/render/theme.js +99 -0
  53. package/dist/{transform.d.ts → transform/transform.d.ts} +55 -1
  54. package/dist/{transform.js → transform/transform.js} +277 -29
  55. package/package.json +4 -3
  56. package/dist/layout-metrics.d.ts +0 -42
  57. package/dist/layout-metrics.js +0 -241
  58. package/dist/layout.d.ts +0 -75
  59. package/dist/layout.js +0 -51
  60. package/dist/parser.d.ts +0 -17
  61. /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
  62. /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
  63. /package/dist/{model.js → foundation/model.js} +0 -0
  64. /package/dist/{render-dot.d.ts → layout/render-dot.d.ts} +0 -0
  65. /package/dist/{render-dot.js → layout/render-dot.js} +0 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Hand-written recursive-descent parser for the PlantUML class-diagram subset.
3
+ *
4
+ * M2 scope adds members (visibility `-`/`+`/`#`/`~`, fields, method
5
+ * signatures), relations (inheritance / implementation / dependency /
6
+ * association / aggregation / composition with cardinality and labels),
7
+ * generics (`List~Item~`, `BaseEntity<T>`), package/namespace grouping,
8
+ * aliases, and tolerant skipping of skinparam/hide/note directives.
9
+ */
10
+ import { PlantUmlModel } from '../foundation/model.js';
11
+ export declare const ENTITY_LINE: RegExp;
12
+ export declare const ENTITY_KEYWORD: RegExp;
13
+ /**
14
+ * Parse a PlantUML source string into a PlantUmlModel.
15
+ *
16
+ * The parser is total: unrecognised lines (skinparam, hide, note, title,
17
+ * legend, …) are tolerated and skipped rather than failing the parse.
18
+ */
19
+ export declare function parsePlantUml(source: string): PlantUmlModel;
20
+ /** An undeclared relation endpoint that the parser resolved on the author's behalf. */
21
+ export interface EndpointDiagnostic {
22
+ /** 1-based source line of the relation. */
23
+ line: number;
24
+ message: string;
25
+ }
26
+ /**
27
+ * Parse and also report how relation endpoints were resolved: endpoints naming
28
+ * a package are re-attached to a representative entity inside it, and endpoints
29
+ * naming nothing declared become implicit (empty) classes, as PlantUML does.
30
+ */
31
+ export declare function parseWithDiagnostics(source: string): {
32
+ model: PlantUmlModel;
33
+ diagnostics: EndpointDiagnostic[];
34
+ };
35
+ /** Strip PlantUML `'` line comments (a leading `'` or a trailing inline `'`). */
36
+ export declare function stripComment(line: string): string;
37
+ /**
38
+ * Extract a declaration name from an entity line's tail, mirroring the parser:
39
+ * accepts a bare token (`class A`) or a quoted name (`class "User Repo"`), and
40
+ * tolerates a trailing `as <alias>`. Returns null when the name is missing or
41
+ * the quoted name is unterminated.
42
+ */
43
+ export declare function extractDeclaredName(rest: string): string | null;
@@ -7,8 +7,9 @@
7
7
  * generics (`List~Item~`, `BaseEntity<T>`), package/namespace grouping,
8
8
  * aliases, and tolerant skipping of skinparam/hide/note directives.
9
9
  */
10
- import { emptyModel, } from './model.js';
11
- const ENTITY_LINE = /^(abstract\s+)?(class|interface|enum)\s+(.+)$/i;
10
+ import { emptyModel, } from '../foundation/model.js';
11
+ export const ENTITY_LINE = /^(abstract\s+)?(class|interface|enum)\s+(.+)$/i;
12
+ export const ENTITY_KEYWORD = /^(?:abstract\s+)?(?:class|interface|enum)\b/i;
12
13
  const ARROWS = {
13
14
  '--|>': { type: 'inheritance' },
14
15
  '<|--': { type: 'inheritance', swap: true },
@@ -31,6 +32,91 @@ const ARROWS = {
31
32
  * legend, …) are tolerated and skipped rather than failing the parse.
32
33
  */
33
34
  export function parsePlantUml(source) {
35
+ return parseWithDiagnostics(source).model;
36
+ }
37
+ /**
38
+ * Parse and also report how relation endpoints were resolved: endpoints naming
39
+ * a package are re-attached to a representative entity inside it, and endpoints
40
+ * naming nothing declared become implicit (empty) classes, as PlantUML does.
41
+ */
42
+ export function parseWithDiagnostics(source) {
43
+ const relationLines = [];
44
+ const model = parseRaw(source, relationLines);
45
+ const diagnostics = resolveEndpoints(model, relationLines);
46
+ return { model, diagnostics };
47
+ }
48
+ function resolveEndpoints(model, relationLines) {
49
+ const diagnostics = [];
50
+ const entityIds = new Set(model.entities.map((e) => e.id));
51
+ const packageIds = new Set(model.packages.map((p) => p.id));
52
+ const parentOf = new Map(model.packages.map((p) => [p.id, p.parentId]));
53
+ const inPackage = (entityPkg, pkg) => {
54
+ for (let cur = entityPkg; cur !== undefined; cur = parentOf.get(cur)) {
55
+ if (cur === pkg)
56
+ return true;
57
+ }
58
+ return false;
59
+ };
60
+ const representative = (pkg) => model.entities.find((e) => inPackage(e.packageId, pkg))?.id;
61
+ // An endpoint may name a package by its id or by the identifier form of its
62
+ // display name (`CLI_Layer` for `"CLI Layer (命令层)"`), as authors write it.
63
+ const packageByKey = new Map();
64
+ for (const p of model.packages) {
65
+ for (const key of [p.id, packageKey(p.name)]) {
66
+ if (!packageByKey.has(key))
67
+ packageByKey.set(key, p.id);
68
+ }
69
+ }
70
+ const asPackage = (id) => packageIds.has(id) ? id : packageByKey.get(id);
71
+ const resolve = (id, line, pkgOut) => {
72
+ if (entityIds.has(id))
73
+ return id;
74
+ const pkg = asPackage(id);
75
+ if (pkg !== undefined) {
76
+ const rep = representative(pkg);
77
+ if (rep !== undefined) {
78
+ diagnostics.push({
79
+ line,
80
+ message: `relation endpoint "${id}" is a package; edge attached to "${rep}" inside it`,
81
+ });
82
+ pkgOut(pkg);
83
+ return rep;
84
+ }
85
+ }
86
+ model.entities.push({
87
+ id,
88
+ name: id,
89
+ generics: [],
90
+ kind: 'class',
91
+ members: [],
92
+ external: true,
93
+ });
94
+ entityIds.add(id);
95
+ diagnostics.push({
96
+ line,
97
+ message: `relation endpoint "${id}" is not declared; implicit class created`,
98
+ });
99
+ return id;
100
+ };
101
+ model.relations.forEach((r, i) => {
102
+ const line = relationLines[i] ?? 0;
103
+ r.source = resolve(r.source, line, (p) => {
104
+ r.sourcePackageId = p;
105
+ });
106
+ r.target = resolve(r.target, line, (p) => {
107
+ r.targetPackageId = p;
108
+ });
109
+ });
110
+ return diagnostics;
111
+ }
112
+ /** Identifier form of a package display name: parenthetical dropped, non-word runs → `_`. */
113
+ function packageKey(name) {
114
+ return name
115
+ .replace(/\s*\([^)]*\)\s*$/, '')
116
+ .trim()
117
+ .replace(/[^\p{L}\p{N}]+/gu, '_');
118
+ }
119
+ function parseRaw(source, relationLines) {
34
120
  const model = emptyModel();
35
121
  const lines = source.split(/\r?\n/);
36
122
  let inDiagram = false;
@@ -40,8 +126,8 @@ export function parsePlantUml(source) {
40
126
  stack.push(frame);
41
127
  lastDeclaration = null;
42
128
  };
43
- for (const raw of lines) {
44
- const line = raw.trim();
129
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
130
+ const line = lines[lineIdx].trim();
45
131
  if (line === '')
46
132
  continue;
47
133
  if (/^@startuml\b/i.test(line)) {
@@ -116,6 +202,7 @@ export function parsePlantUml(source) {
116
202
  const relation = parseRelationLine(code);
117
203
  if (relation) {
118
204
  model.relations.push(relation);
205
+ relationLines.push(lineIdx + 1);
119
206
  lastDeclaration = null;
120
207
  continue;
121
208
  }
@@ -366,9 +453,28 @@ function stripQuotes(token) {
366
453
  return token;
367
454
  }
368
455
  /** Strip PlantUML `'` line comments (a leading `'` or a trailing inline `'`). */
369
- function stripComment(line) {
456
+ export function stripComment(line) {
370
457
  const idx = line.indexOf("'");
371
458
  if (idx === -1)
372
459
  return line.trim();
373
460
  return line.slice(0, idx).trim();
374
461
  }
462
+ /**
463
+ * Extract a declaration name from an entity line's tail, mirroring the parser:
464
+ * accepts a bare token (`class A`) or a quoted name (`class "User Repo"`), and
465
+ * tolerates a trailing `as <alias>`. Returns null when the name is missing or
466
+ * the quoted name is unterminated.
467
+ */
468
+ export function extractDeclaredName(rest) {
469
+ const asIndex = rest.search(/\s+as\s+\S+\s*$/i);
470
+ const core = asIndex === -1 ? rest.trim() : rest.slice(0, asIndex).trim();
471
+ if (core === '')
472
+ return null;
473
+ if (core.startsWith('"')) {
474
+ const close = core.indexOf('"', 1);
475
+ if (close === -1)
476
+ return null;
477
+ return core.slice(1, close);
478
+ }
479
+ return core.split(/\s+/)[0] || null;
480
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * PlantUmlModel → PlantUML class-diagram text.
3
+ *
4
+ * The inverse of [[parsePlantUml]]: `toPlantUml(renderDetailed(...).model)`
5
+ * reproduces the diagram that was actually drawn (after view/transform/detail)
6
+ * as source a user can copy and paste. Pure and synchronous — the model is the
7
+ * only input, so the caller composes the scope by picking the model
8
+ * (`toPlantUml(applyView(model, {kind: 'neighborhood', entity, hops}))`); this
9
+ * function never chooses a range on its own.
10
+ *
11
+ * Shapes that only exist after parsing (stub/external/usageCount, aggregated
12
+ * edge weights, cycle markers) are rendering semantics with no PlantUML
13
+ * spelling; they serialize as their plain-text equivalent (stub → class,
14
+ * weighted edge → one edge). The parts that do have a spelling — packages
15
+ * (nested), entity kind/generics/alias, member rows, relation arrow/label/
16
+ * cardinality — round-trip back through [[parsePlantUml]] unchanged.
17
+ *
18
+ * The displayed name is always emitted quoted (`class "<<function>> halt"
19
+ * as n0`), so names carrying a stereotype prefix or `/` path separators survive
20
+ * the round trip as authored.
21
+ */
22
+ import type { PlantUmlModel } from '../foundation/model.js';
23
+ /**
24
+ * Serialize a model to `@startuml … @enduml` class-diagram source.
25
+ *
26
+ * Emitted layout: nested `package` blocks first (each holding the entities that
27
+ * carry its `packageId`), then package-less entities, then every relation.
28
+ * Entities always use the quoted form with an explicit alias, which is the only
29
+ * form that carries a display name containing whitespace, `/`, or a
30
+ * `<<stereotype>>` prefix.
31
+ */
32
+ export declare function toPlantUml(model: PlantUmlModel): string;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * PlantUmlModel → PlantUML class-diagram text.
3
+ *
4
+ * The inverse of [[parsePlantUml]]: `toPlantUml(renderDetailed(...).model)`
5
+ * reproduces the diagram that was actually drawn (after view/transform/detail)
6
+ * as source a user can copy and paste. Pure and synchronous — the model is the
7
+ * only input, so the caller composes the scope by picking the model
8
+ * (`toPlantUml(applyView(model, {kind: 'neighborhood', entity, hops}))`); this
9
+ * function never chooses a range on its own.
10
+ *
11
+ * Shapes that only exist after parsing (stub/external/usageCount, aggregated
12
+ * edge weights, cycle markers) are rendering semantics with no PlantUML
13
+ * spelling; they serialize as their plain-text equivalent (stub → class,
14
+ * weighted edge → one edge). The parts that do have a spelling — packages
15
+ * (nested), entity kind/generics/alias, member rows, relation arrow/label/
16
+ * cardinality — round-trip back through [[parsePlantUml]] unchanged.
17
+ *
18
+ * The displayed name is always emitted quoted (`class "<<function>> halt"
19
+ * as n0`), so names carrying a stereotype prefix or `/` path separators survive
20
+ * the round trip as authored.
21
+ */
22
+ /** A bare PlantUML identifier (what `as <alias>` accepts). */
23
+ const PLANTUML_ID = /^[A-Za-z_][A-Za-z0-9_]*$/;
24
+ const INDENT = ' ';
25
+ const VISIBILITY_SYMBOL = {
26
+ public: '+',
27
+ private: '-',
28
+ protected: '#',
29
+ 'package-private': '~',
30
+ };
31
+ /**
32
+ * One arrow per relation type, written in canonical source→target direction.
33
+ * The parser's own arrow table also accepts the swapped mirror forms
34
+ * (`<|--`, `--o`, …); emitting the canonical form is what makes the round trip
35
+ * stable.
36
+ */
37
+ const RELATION_ARROW = {
38
+ inheritance: '--|>',
39
+ implementation: '..|>',
40
+ dependency: '..>',
41
+ association: '-->',
42
+ aggregation: 'o--',
43
+ composition: '*--',
44
+ };
45
+ /**
46
+ * Serialize a model to `@startuml … @enduml` class-diagram source.
47
+ *
48
+ * Emitted layout: nested `package` blocks first (each holding the entities that
49
+ * carry its `packageId`), then package-less entities, then every relation.
50
+ * Entities always use the quoted form with an explicit alias, which is the only
51
+ * form that carries a display name containing whitespace, `/`, or a
52
+ * `<<stereotype>>` prefix.
53
+ */
54
+ export function toPlantUml(model) {
55
+ const entities = new AliasBook('entity');
56
+ const packages = new AliasBook('package');
57
+ const lines = ['@startuml'];
58
+ const nodes = packageNodes(model);
59
+ const emitted = new Set();
60
+ for (const node of nodes.roots)
61
+ emitPackage(node, 0, lines, entities, packages, emitted);
62
+ // Defensive: a parentId cycle leaves nodes unreachable from any root; emit
63
+ // each remaining package as a root rather than silently dropping it.
64
+ for (const node of nodes.all) {
65
+ if (!emitted.has(node.pkg.id))
66
+ emitPackage(node, 0, lines, entities, packages, emitted);
67
+ }
68
+ for (const entity of nodes.topLevel) {
69
+ lines.push(...entityLines(entity, 0, entities));
70
+ }
71
+ for (const relation of model.relations) {
72
+ lines.push(relationLine(relation, entities));
73
+ }
74
+ lines.push('@enduml');
75
+ return lines.join('\n');
76
+ }
77
+ /**
78
+ * Index packages into a tree, attaching each entity to the innermost package it
79
+ * names. An entity whose `packageId` names no declared package is emitted at
80
+ * the top level instead of being dropped.
81
+ */
82
+ function packageNodes(model) {
83
+ const byId = new Map();
84
+ const all = [];
85
+ for (const pkg of model.packages) {
86
+ // A duplicated id keeps its first package; the parser never produces one.
87
+ if (byId.has(pkg.id))
88
+ continue;
89
+ const node = { pkg, children: [], entities: [] };
90
+ byId.set(pkg.id, node);
91
+ all.push(node);
92
+ }
93
+ const roots = [];
94
+ for (const node of all) {
95
+ const parentId = node.pkg.parentId;
96
+ const parent = parentId === undefined ? undefined : byId.get(parentId);
97
+ if (parent !== undefined && parent !== node)
98
+ parent.children.push(node);
99
+ else
100
+ roots.push(node);
101
+ }
102
+ const topLevel = [];
103
+ for (const entity of model.entities) {
104
+ const owner = entity.packageId === undefined ? undefined : byId.get(entity.packageId);
105
+ if (owner !== undefined)
106
+ owner.entities.push(entity);
107
+ else
108
+ topLevel.push(entity);
109
+ }
110
+ return { roots, all, topLevel };
111
+ }
112
+ function emitPackage(node, depth, lines, entities, packages, emitted) {
113
+ if (emitted.has(node.pkg.id))
114
+ return;
115
+ emitted.add(node.pkg.id);
116
+ const indent = INDENT.repeat(depth);
117
+ const alias = packages.alias(node.pkg.id, node.pkg.name);
118
+ lines.push(`${indent}package ${quoteName(node.pkg.name)} as ${alias} {`);
119
+ for (const entity of node.entities) {
120
+ lines.push(...entityLines(entity, depth + 1, entities));
121
+ }
122
+ for (const child of node.children) {
123
+ emitPackage(child, depth + 1, lines, entities, packages, emitted);
124
+ }
125
+ lines.push(`${indent}}`);
126
+ }
127
+ function entityKeyword(entity) {
128
+ return entity.kind === 'abstract' ? 'abstract class' : entity.kind;
129
+ }
130
+ function entityLines(entity, depth, aliases) {
131
+ const indent = INDENT.repeat(depth);
132
+ const alias = aliases.alias(entity.id, entity.name);
133
+ const head = `${entityKeyword(entity)} ${quoteName(entity.name)}${genericSuffix(entity)} as ${alias}`;
134
+ if (entity.members.length === 0)
135
+ return [`${indent}${head}`];
136
+ const lines = [`${indent}${head} {`];
137
+ for (const member of entity.members) {
138
+ lines.push(`${indent}${INDENT}${memberLine(member)}`);
139
+ }
140
+ lines.push(`${indent}}`);
141
+ return lines;
142
+ }
143
+ function genericSuffix(entity) {
144
+ const generics = entity.generics ?? [];
145
+ return generics.length === 0 ? '' : `~${generics.join(',')}~`;
146
+ }
147
+ /**
148
+ * One member row. The authored `raw` text is authoritative (it carries the
149
+ * visibility symbol and the exact signature), except for synthesized rows —
150
+ * the detail pipeline's `… +N more (M members)` truncation marker has no
151
+ * visibility symbol, so it gets one prefixed to stay parseable as a member
152
+ * row while keeping the marker text verbatim.
153
+ */
154
+ function memberLine(member) {
155
+ const raw = (member.raw ?? '').trim();
156
+ const symbol = VISIBILITY_SYMBOL[member.visibility] ?? '+';
157
+ if (raw === '')
158
+ return symbol;
159
+ if (/^[+\-#~]/.test(raw))
160
+ return raw;
161
+ return `${symbol} ${raw}`;
162
+ }
163
+ function relationLine(relation, aliases) {
164
+ const source = aliases.alias(relation.source, relation.source);
165
+ const target = aliases.alias(relation.target, relation.target);
166
+ const parts = [source];
167
+ if (relation.sourceCardinality !== undefined)
168
+ parts.push(quoteName(relation.sourceCardinality));
169
+ parts.push(RELATION_ARROW[relation.type] ?? '-->');
170
+ if (relation.targetCardinality !== undefined)
171
+ parts.push(quoteName(relation.targetCardinality));
172
+ parts.push(target);
173
+ let line = parts.join(' ');
174
+ if (relation.label !== undefined && relation.label !== '') {
175
+ line += ` : ${relation.label.replace(/[\r\n]+/g, ' ')}`;
176
+ }
177
+ return line;
178
+ }
179
+ /** Quote a display name; a `"` inside a PlantUML name is written as `'`. */
180
+ function quoteName(name) {
181
+ const safe = name.replace(/[\r\n\t]+/g, ' ').replace(/"/g, "'");
182
+ return `"${safe}"`;
183
+ }
184
+ /**
185
+ * Deterministic, collision-free alias assignment, in emission order. A
186
+ * plant-legal id is kept verbatim (so a parsed model round-trips its ids); an
187
+ * id PlantUML cannot spell (view-internal ids such as `ov::src`, `stub::x`)
188
+ * falls back to an identifier derived from the display name.
189
+ */
190
+ class AliasBook {
191
+ used = new Set();
192
+ byId = new Map();
193
+ fallback;
194
+ constructor(fallback) {
195
+ this.fallback = fallback;
196
+ }
197
+ alias(id, name) {
198
+ const existing = this.byId.get(id);
199
+ if (existing !== undefined)
200
+ return existing;
201
+ const base = PLANTUML_ID.test(id) ? id : identifierFrom(name, this.fallback);
202
+ let alias = base;
203
+ let suffix = 2;
204
+ while (this.used.has(alias))
205
+ alias = `${base}_${suffix++}`;
206
+ this.used.add(alias);
207
+ this.byId.set(id, alias);
208
+ return alias;
209
+ }
210
+ }
211
+ /** Derive a bare identifier from a display name (`Src Layer` → `Src_Layer`). */
212
+ function identifierFrom(name, fallback) {
213
+ const cleaned = name
214
+ .replace(/[^A-Za-z0-9_]+/g, '_')
215
+ .replace(/^_+|_+$/g, '');
216
+ const base = cleaned === '' ? fallback : cleaned;
217
+ return /^[A-Za-z_]/.test(base) ? base : `_${base}`;
218
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Legend — a small key explaining the visual encodings of a rendered diagram:
3
+ * feedback (back) edges, hub badges, `×N` weights and the dashed/solid node
4
+ * frames. Drawn below the diagram, so it never covers layout geometry.
5
+ */
6
+ import { Theme } from './theme.js';
7
+ /** Entity count above which the legend is drawn by default. */
8
+ export declare const LEGEND_DEFAULT_NODE_THRESHOLD = 30;
9
+ export interface Legend {
10
+ /** `<g class="uml-legend">` markup positioned at (x, y). */
11
+ svg: string;
12
+ width: number;
13
+ height: number;
14
+ }
15
+ export declare function renderLegend(x: number, y: number, theme: Theme): Legend;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Legend — a small key explaining the visual encodings of a rendered diagram:
3
+ * feedback (back) edges, hub badges, `×N` weights and the dashed/solid node
4
+ * frames. Drawn below the diagram, so it never covers layout geometry.
5
+ */
6
+ const ROW_HEIGHT = 20;
7
+ const PAD = 10;
8
+ const SWATCH_WIDTH = 34;
9
+ const FONT_SIZE = 11;
10
+ /** Entity count above which the legend is drawn by default. */
11
+ export const LEGEND_DEFAULT_NODE_THRESHOLD = 30;
12
+ const ITEMS = [
13
+ 'feedback edge (dash-dot; closes a dependency cycle)',
14
+ 'hub badge: number of dependents (edges hidden)',
15
+ '×N: number of merged relations',
16
+ 'dashed frame: grouped / external package',
17
+ 'solid frame: single module',
18
+ ];
19
+ export function renderLegend(x, y, theme) {
20
+ const { color } = theme;
21
+ const width = PAD * 2 + SWATCH_WIDTH + 8 + 300;
22
+ const height = PAD * 2 + ROW_HEIGHT * ITEMS.length;
23
+ const rowY = (i) => y + PAD + ROW_HEIGHT * i + ROW_HEIGHT / 2;
24
+ const sx = x + PAD;
25
+ const tx = sx + SWATCH_WIDTH + 8;
26
+ const swatches = [
27
+ `<line x1="${sx}" y1="${rowY(0)}" x2="${sx + SWATCH_WIDTH}" y2="${rowY(0)}" stroke="${color.warn}" stroke-width="${theme.stroke.edge}" stroke-dasharray="${theme.dash.feedback}"/>`,
28
+ `<circle cx="${sx + SWATCH_WIDTH / 2}" cy="${rowY(1)}" r="8" fill="${color.accent}"/>` +
29
+ `<text x="${sx + SWATCH_WIDTH / 2}" y="${rowY(1) + 3.5}" text-anchor="middle" font-family="${theme.font.family}" font-size="10" fill="${color.accentText}">N</text>`,
30
+ `<line x1="${sx}" y1="${rowY(2)}" x2="${sx + SWATCH_WIDTH}" y2="${rowY(2)}" stroke="${color.edge}" stroke-width="2.5" stroke-opacity="${theme.stroke.opacity.strong}"/>`,
31
+ `<rect x="${sx}" y="${rowY(3) - 7}" width="${SWATCH_WIDTH}" height="14" rx="3" fill="${color.stubFill}" stroke="${color.strokeStub}" stroke-width="${theme.stroke.node}" stroke-dasharray="${theme.dash.stub}"/>`,
32
+ `<rect x="${sx}" y="${rowY(4) - 7}" width="${SWATCH_WIDTH}" height="14" rx="3" fill="${color.nodeFill}" stroke="${color.stroke}" stroke-width="${theme.stroke.node}"/>`,
33
+ ];
34
+ const texts = ITEMS.map((item, i) => `<text x="${tx}" y="${rowY(i) + 4}" text-anchor="start" font-family="${theme.font.family}"` +
35
+ ` font-size="${FONT_SIZE}" fill="${color.textMember}">${item.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</text>`);
36
+ const svg = `<g class="uml-legend">` +
37
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" fill="${color.canvas}" stroke="${color.legendStroke}" stroke-width="${theme.stroke.node}"/>` +
38
+ swatches.join('') +
39
+ texts.join('') +
40
+ `</g>`;
41
+ return { svg, width, height };
42
+ }
@@ -3,7 +3,64 @@
3
3
  * The renderer consumes only the LayoutResult (positions/sizes) plus the
4
4
  * model's display fields; it never reaches back into the parser.
5
5
  */
6
- import { PlantUmlModel } from '../model.js';
7
- import { LayoutResult } from '../layout.js';
6
+ import { PlantUmlModel } from '../foundation/model.js';
7
+ import { LayoutResult, Point } from '../foundation/layout.js';
8
+ import { ThemeOverrides } from './theme.js';
8
9
  export declare function escapeXml(text: string): string;
9
- export declare function renderSvg(model: PlantUmlModel, layout: LayoutResult): string;
10
+ export interface RenderSvgOptions {
11
+ /** Draw a legend of the visual encodings. Default: on for models with more than 30 entities. */
12
+ legend?: boolean;
13
+ /** Theme token overrides over `defaultTheme`. */
14
+ theme?: ThemeOverrides;
15
+ /** Edges hidden by transitive reduction; > 0 forces the legend and is reported there and in `data-hidden-transitive`. */
16
+ hiddenTransitive?: number;
17
+ }
18
+ /**
19
+ * Per-frame values handed to {@link RenderEngine.render}. Only values computed
20
+ * for the frame being rendered belong here; configuration that outlives a
21
+ * frame (theme, legend override) is constructor state of the engine.
22
+ */
23
+ export interface RenderFrameOptions {
24
+ /** Edges hidden by transitive reduction in this frame; > 0 forces the legend and is reported there and in `data-hidden-transitive`. */
25
+ hiddenTransitive?: number;
26
+ }
27
+ /**
28
+ * The rendering counterpart of `LayoutEngine`: draw an already laid-out model.
29
+ * Synchronous because SVG assembly is synchronous, unlike layout. Inject one
30
+ * through `RendererOptions.renderer` to swap the rendering backend.
31
+ */
32
+ export interface RenderEngine {
33
+ render(model: PlantUmlModel, layout: LayoutResult, frame?: RenderFrameOptions): string;
34
+ }
35
+ /** Configuration that outlives a frame — constructor state of {@link SvgRenderer}. */
36
+ export interface SvgRendererOptions {
37
+ /** Draw a legend of the visual encodings. Default: on for models with more than 30 entities. */
38
+ legend?: boolean;
39
+ /** Theme token overrides over `defaultTheme`. */
40
+ theme?: ThemeOverrides;
41
+ }
42
+ /**
43
+ * The built-in {@link RenderEngine}: the SVG backend. Theme and legend
44
+ * configuration are fixed at construction; the per-frame `hiddenTransitive`
45
+ * count is passed to [[SvgRenderer.render]].
46
+ */
47
+ export declare class SvgRenderer implements RenderEngine {
48
+ private readonly legend;
49
+ private readonly theme;
50
+ constructor(options?: SvgRendererOptions);
51
+ render(model: PlantUmlModel, layout: LayoutResult, frame?: RenderFrameOptions): string;
52
+ }
53
+ /**
54
+ * Convenience wrapper over {@link SvgRenderer}, kept for callers that build the
55
+ * SVG directly from a model + layout (the renderer class is preferred when
56
+ * injecting into `RendererOptions.renderer`).
57
+ */
58
+ export declare function renderSvg(model: PlantUmlModel, layout: LayoutResult, options?: RenderSvgOptions): string;
59
+ /** Edges sharing a source and exit direction beyond this count are drawn as one bundle. */
60
+ export declare const EDGE_BUNDLE_MIN = 5;
61
+ /**
62
+ * Polyline as an SVG path whose interior corners are rounded with quadratic
63
+ * curves of radius ≤ `radius` (clamped to half of each adjoining segment). The
64
+ * routing — the vertices — is unchanged.
65
+ */
66
+ export declare function roundedPath(points: Point[], radius: number): string;