@yalehwang/plantuml-js 0.4.1 → 0.5.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.
package/README.md CHANGED
@@ -72,7 +72,7 @@ console.log(svg);
72
72
  |---|---|
73
73
  | `createRenderer({ layout })` | Build a renderer. `layout` is `'elk'` (default), `'viz'` (experimental), or a custom `LayoutEngine`. |
74
74
  | `renderer.render(puml)` | Parse + layout + emit SVG. Returns `{ svg, diagramType, warnings }`. Throws `RenderError` on structurally invalid puml. |
75
- | `renderDetailed(pumlOrModel, options?)` | Run the same pipeline as `renderer.render` and return every intermediate: `{ model, layout, svg, layoutMs, hiddenTransitive }`. Accepts a puml string (throws `RenderError` on invalid input) or an already-parsed `PlantUmlModel`. `DetailedRender.layout` carries the **stable** node/package/edge rectangles — see [Stable layout geometry](#stable-layout-geometry-renderdetailed) — so tooling never has to re-read bounding boxes from the SVG. Exported from both `.` and `./elk`. |
75
+ | `renderDetailed(pumlOrModel, options?)` | Run the same pipeline as `renderer.render` and return every intermediate: `{ model, sourceModel, layout, svg, layoutMs, hiddenTransitive }`. Accepts a puml string (throws `RenderError` on invalid input) or an already-parsed `PlantUmlModel`. `model` is the post-detail model that was drawn — **not suitable for export** (detail truncation may have replaced member rows); `sourceModel` is the complete pre-detail model, so export with `toPlantUml(sourceModel)`. `DetailedRender.layout` carries the **stable** node/package/edge rectangles — see [Stable layout geometry](#stable-layout-geometry-renderdetailed) — so tooling never has to re-read bounding boxes from the SVG. Exported from both `.` and `./elk`. |
76
76
  | `parsePlantUml(puml)` | Parse a puml string into a `PlantUmlModel`. |
77
77
  | `checkSyntax(puml)` | Validate without throwing. Returns `{ ok, errors, warnings }` of line-accurate `SyntaxDiagnostic`s. |
78
78
  | `explain(puml)` | Explain each line. Returns `LineExplanation[]` (line, kind, explanation). |
@@ -98,6 +98,28 @@ without re-routing. `model` is the diagram actually drawn (after view,
98
98
  transforms and detail), and `hiddenTransitive` counts the edges dropped by
99
99
  transitive reduction.
100
100
 
101
+ `model` is a **drawing** model: `detail`/`memberMaxRows` may have replaced rows
102
+ with a synthetic `… +N more (M members)` marker, so serializing it is lossy.
103
+ For a lossless export (copy/download the current diagram as PlantUML), serialize
104
+ `sourceModel` — the same model after view/transforms but before detail:
105
+
106
+ ```ts
107
+ const { sourceModel } = await renderDetailed(puml, { detail: 'compact' });
108
+ const text = toPlantUml(sourceModel); // every authored member, no marker rows
109
+ ```
110
+
111
+ `toPlantUml` skips synthetic members, so `toPlantUml(model)` never emits a
112
+ `+ … +N more` line either — it is simply only as complete as what was drawn.
113
+
114
+ The canvas is fitted to **the routes and labels that are drawn**, not only to
115
+ the boxes the layout engine measures: `layout.width`/`height` (and therefore
116
+ the SVG `viewBox`) contain every edge route point and every edge label box, with
117
+ a margin of at least **16px** on all four sides. An edge label beside an outer
118
+ detour route is therefore never clipped at the SVG border, and a host can rely
119
+ on `0 0 width height` containing the whole drawing. Engine geometry that
120
+ already fits is left untouched, so the canvas never shrinks and unchanged
121
+ diagrams render identically.
122
+
101
123
  ### Error semantics
102
124
 
103
125
  `createRenderer` fails closed on an unknown layout name (`'elk'`, `'viz'`, or a
package/dist/core.d.ts CHANGED
@@ -46,6 +46,18 @@ export interface RendererOptions {
46
46
  * Default: chosen by entity count (≤40 full, ≤150 compact, else names).
47
47
  */
48
48
  detail?: DetailLevel;
49
+ /**
50
+ * Maximum member rows drawn per entity; surplus members collapse into the
51
+ * existing `… +N more (M members)` row. Applied before layout, so the box is
52
+ * sized for what is drawn. Default: unlimited for `full`, 4 for `compact`.
53
+ */
54
+ memberMaxRows?: number;
55
+ /**
56
+ * Cut each drawn member line to this many characters (ending with `…`).
57
+ * Only the drawn text changes: `PlantUmlMember.raw` keeps the full
58
+ * declaration. Default: unlimited.
59
+ */
60
+ memberMaxChars?: number;
49
61
  /**
50
62
  * `'aggregate'` folds the relations between two packages into one weighted
51
63
  * box-to-box corridor edge (originals kept in its `<title>`); `'raw'` draws
@@ -67,8 +79,8 @@ export interface RenderResult {
67
79
  }
68
80
  /**
69
81
  * Everything {@link renderDetailed} computed for one diagram: the model that was
70
- * actually laid out and drawn, the geometry the renderer used, the SVG it
71
- * emitted, and the reduction bookkeeping.
82
+ * actually laid out and drawn, the pre-detail model to export from, the geometry
83
+ * the renderer used, the SVG it emitted, and the reduction bookkeeping.
72
84
  *
73
85
  * This is a **public, stable API** (available from both the `.` and `./elk`
74
86
  * entries). Consumers such as an architecture tab should read geometry from
@@ -89,8 +101,20 @@ export interface RenderResult {
89
101
  * they can be translated into a thumbnail directly.
90
102
  */
91
103
  export interface DetailedRender {
92
- /** The model actually laid out and drawn (after view, transforms and detail). */
104
+ /**
105
+ * The model actually laid out and drawn (after view, transforms **and**
106
+ * detail). Detail truncation has already dropped members here — row caps
107
+ * replace them with a `synthetic` `… +N more` marker — so this model is for
108
+ * drawing/inspection, **not for export**. Export from {@link sourceModel}.
109
+ */
93
110
  model: PlantUmlModel;
111
+ /**
112
+ * The complete model after view/transforms but **before** detail truncation:
113
+ * every authored member, with `raw` intact. Serialize this to export what was
114
+ * parsed — `toPlantUml(renderDetailed(input).sourceModel)` — instead of
115
+ * `model`, whose rows detail may have capped.
116
+ */
117
+ sourceModel: PlantUmlModel;
94
118
  /** Geometry actually used by the renderer; its node/package/edge rectangles are a stable contract (see above). */
95
119
  layout: LayoutResult;
96
120
  svg: string;
@@ -126,6 +150,8 @@ export declare function createRenderer(options?: RendererOptions): Renderer;
126
150
  * Render one diagram through the exact pipeline `createRenderer` uses
127
151
  * (view/transforms → detail → layout → svg) and return every intermediate, so
128
152
  * tooling can read real geometry instead of reverse-engineering the SVG.
153
+ * `model` is the post-detail model that was drawn; `sourceModel` is the
154
+ * pre-detail model to serialize for a lossless export.
129
155
  *
130
156
  * Accepts either a puml string (the counterpart of
131
157
  * `createRenderer(options).render(puml)`, with the same fail-closed
package/dist/core.js CHANGED
@@ -7,7 +7,8 @@
7
7
  * engine + `renderDot`.
8
8
  */
9
9
  import { parsePlantUml, parseWithDiagnostics, ENTITY_LINE, ENTITY_KEYWORD, extractDeclaredName, stripComment } from './parse/parser.js';
10
- import { applyDetail } from './foundation/layout.js';
10
+ import { applyDetail, fitLayoutToContent } from './foundation/layout.js';
11
+ import { placeEdgeLabels } from './foundation/labels.js';
11
12
  import { ElkLayoutEngine } from './layout/elk.js';
12
13
  import { renderSvg, SvgRenderer, } from './render/svg.js';
13
14
  import { applyView, transformModel, transformModelDetailed, shouldAutoReduce, INTER_PACKAGE_AGGREGATE_ENTITY_THRESHOLD } from './transform/transform.js';
@@ -67,13 +68,22 @@ export function createRenderer(options = {}) {
67
68
  };
68
69
  }
69
70
  async function renderParsed(parsed, options, engine, renderEngine) {
70
- const { model: transformed, hiddenTransitive } = transformForRender(parsed, options);
71
- const model = applyDetail(transformed, options.detail);
71
+ const { model: sourceModel, hiddenTransitive } = transformForRender(parsed, options);
72
+ const model = applyDetail(sourceModel, options.detail, {
73
+ memberMaxRows: options.memberMaxRows,
74
+ memberMaxChars: options.memberMaxChars,
75
+ });
72
76
  const started = performance.now();
73
- const layout = await engine.layout(model);
77
+ const raw = await engine.layout(model);
74
78
  const layoutMs = Math.round((performance.now() - started) * 100) / 100;
79
+ // The engines size the canvas from boxes and routes; edge labels are placed
80
+ // by the renderer and can hang outside that box (a plain label beside an
81
+ // outer detour route lands at a negative x). Fit the canvas to the routes and
82
+ // labels, so the returned layout and the SVG viewBox both contain them with
83
+ // a margin.
84
+ const layout = fitLayoutToContent(raw, placeEdgeLabels(model, raw).map((label) => label.rect));
75
85
  const svg = renderEngine.render(model, layout, { hiddenTransitive });
76
- return { model, layout, svg, layoutMs, hiddenTransitive };
86
+ return { sourceModel, model, layout, svg, layoutMs, hiddenTransitive };
77
87
  }
78
88
  export async function renderDetailed(input, options = {}) {
79
89
  const parsed = typeof input === 'string' ? parseForDetailed(input) : input;
@@ -40,6 +40,28 @@ export interface LayoutResult {
40
40
  export interface LayoutEngine {
41
41
  layout(model: PlantUmlModel): Promise<LayoutResult>;
42
42
  }
43
+ /** Empty border the canvas keeps around every drawn mark (px). */
44
+ export declare const CANVAS_CONTENT_MARGIN = 16;
45
+ /** Axis-aligned rectangle, structurally compatible with `labels`/`edge-router` rects. */
46
+ export interface ContentRect {
47
+ x: number;
48
+ y: number;
49
+ width: number;
50
+ height: number;
51
+ }
52
+ /**
53
+ * Grow and translate a layout so every edge route point and the supplied extra
54
+ * rectangles (edge labels) stays at least `margin` from the canvas side. Routes
55
+ * and labels are drawn from the layout coordinates but are not part of
56
+ * `layout.width`/`height` as produced by the engines, so a label hung beside an
57
+ * outer detour route can sit at a negative x and be clipped by a `0 0 w h`
58
+ * viewBox.
59
+ *
60
+ * Boxes are left alone — the engines already place them inside their own
61
+ * margin — so this never moves a diagram whose routes and labels already fit:
62
+ * the input is returned untouched and the canvas never shrinks.
63
+ */
64
+ export declare function fitLayoutToContent(layout: LayoutResult, extraRects?: readonly ContentRect[], margin?: number): LayoutResult;
43
65
  /**
44
66
  * Shared drawing metrics. The renderer reads these so entity sizing and the
45
67
  * emitted SVG stay in lockstep: the font family and size are bound to the same
@@ -105,11 +127,34 @@ export declare const DETAIL_COMPACT_MAX_ENTITIES = 150;
105
127
  /** Member rows kept per entity in `compact` mode. */
106
128
  export declare const COMPACT_MEMBER_ROWS = 4;
107
129
  export declare function autoDetail(entityCount: number): DetailLevel;
130
+ /**
131
+ * Optional caps on the member text a diagram draws. Both are applied by
132
+ * [[applyDetail]] before layout, so an entity is sized for exactly what the
133
+ * renderer emits; neither mutates `PlantUmlMember.raw`.
134
+ */
135
+ export interface MemberDisplayLimits {
136
+ /** Maximum member rows drawn per entity; the rest collapse into the count row. */
137
+ memberMaxRows?: number;
138
+ /** Cut each drawn member line to this many characters, ending with `…`. */
139
+ memberMaxChars?: number;
140
+ }
108
141
  /**
109
142
  * Reduce member rows to the requested detail level *before* layout, so boxes
110
143
  * are sized for what is actually drawn. `undefined` picks by entity count.
144
+ *
145
+ * `limits` caps the drawn rows per entity (`memberMaxRows`, replacing
146
+ * [[COMPACT_MEMBER_ROWS]] for `compact` and also capping `full`) and the width
147
+ * of each drawn line (`memberMaxChars`). Truncation only sets
148
+ * `PlantUmlMember.display`; `raw` keeps the full declaration. Without limits
149
+ * the result is byte-for-byte what it always was.
150
+ *
151
+ * Row caps replace dropped members with one `… +N more (M members)` row marked
152
+ * `synthetic: true`: it is drawing scaffolding, not authored content, so the
153
+ * serializer skips it and a lossless export reads the pre-detail model
154
+ * (`renderDetailed().sourceModel`). Re-applying `applyDetail` to an already
155
+ * detailed model counts only the real members, so markers never stack.
111
156
  */
112
- export declare function applyDetail(model: PlantUmlModel, detail?: DetailLevel): PlantUmlModel;
157
+ export declare function applyDetail(model: PlantUmlModel, detail?: DetailLevel, limits?: MemberDisplayLimits): PlantUmlModel;
113
158
  /**
114
159
  * Group nodes into rows: within one package (or the top level), nodes whose
115
160
  * vertical extents overlap belong to the same layered-layout rank. Returns
@@ -5,6 +5,64 @@
5
5
  * engines (ELK, Graphviz, a custom tuner) is an injection, not a rewrite.
6
6
  */
7
7
  import { measureTextWidth, MONOSPACE_FONT_SIZE } from './text-measure.js';
8
+ /** Empty border the canvas keeps around every drawn mark (px). */
9
+ export const CANVAS_CONTENT_MARGIN = 16;
10
+ /**
11
+ * Grow and translate a layout so every edge route point and the supplied extra
12
+ * rectangles (edge labels) stays at least `margin` from the canvas side. Routes
13
+ * and labels are drawn from the layout coordinates but are not part of
14
+ * `layout.width`/`height` as produced by the engines, so a label hung beside an
15
+ * outer detour route can sit at a negative x and be clipped by a `0 0 w h`
16
+ * viewBox.
17
+ *
18
+ * Boxes are left alone — the engines already place them inside their own
19
+ * margin — so this never moves a diagram whose routes and labels already fit:
20
+ * the input is returned untouched and the canvas never shrinks.
21
+ */
22
+ export function fitLayoutToContent(layout, extraRects = [], margin = CANVAS_CONTENT_MARGIN) {
23
+ let minX = Infinity;
24
+ let minY = Infinity;
25
+ let maxX = -Infinity;
26
+ let maxY = -Infinity;
27
+ const take = (x, y) => {
28
+ if (x < minX)
29
+ minX = x;
30
+ if (x > maxX)
31
+ maxX = x;
32
+ if (y < minY)
33
+ minY = y;
34
+ if (y > maxY)
35
+ maxY = y;
36
+ };
37
+ for (const edge of layout.edges ?? []) {
38
+ for (const point of edge.points)
39
+ take(point.x, point.y);
40
+ }
41
+ for (const rect of extraRects) {
42
+ take(rect.x, rect.y);
43
+ take(rect.x + rect.width, rect.y + rect.height);
44
+ }
45
+ if (minX === Infinity)
46
+ return layout;
47
+ const dx = Math.max(0, margin - minX);
48
+ const dy = Math.max(0, margin - minY);
49
+ const width = Math.max(layout.width + dx, maxX + dx + margin);
50
+ const height = Math.max(layout.height + dy, maxY + dy + margin);
51
+ if (dx === 0 && dy === 0 && width === layout.width && height === layout.height) {
52
+ return layout;
53
+ }
54
+ return {
55
+ ...layout,
56
+ nodes: layout.nodes.map((n) => ({ ...n, x: n.x + dx, y: n.y + dy })),
57
+ edges: layout.edges.map((e) => ({
58
+ ...e,
59
+ points: e.points.map((p) => ({ x: p.x + dx, y: p.y + dy })),
60
+ })),
61
+ packages: layout.packages?.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
62
+ width,
63
+ height,
64
+ };
65
+ }
8
66
  /**
9
67
  * Shared drawing metrics. The renderer reads these so entity sizing and the
10
68
  * emitted SVG stay in lockstep: the font family and size are bound to the same
@@ -74,7 +132,7 @@ export function compartmentHeights(entity) {
74
132
  export function estimateEntitySize(entity) {
75
133
  const lines = [
76
134
  displayName(entity),
77
- ...entity.members.map((m) => m.raw),
135
+ ...entity.members.map((m) => m.display ?? m.raw),
78
136
  ];
79
137
  const longest = lines.reduce((max, line) => Math.max(max, measureTextWidth(line, METRICS.fontSize)), 0);
80
138
  const width = snapUp(Math.max(METRICS.minWidth, longest + METRICS.paddingX * 2));
@@ -98,26 +156,58 @@ export function autoDetail(entityCount) {
98
156
  /**
99
157
  * Reduce member rows to the requested detail level *before* layout, so boxes
100
158
  * are sized for what is actually drawn. `undefined` picks by entity count.
159
+ *
160
+ * `limits` caps the drawn rows per entity (`memberMaxRows`, replacing
161
+ * [[COMPACT_MEMBER_ROWS]] for `compact` and also capping `full`) and the width
162
+ * of each drawn line (`memberMaxChars`). Truncation only sets
163
+ * `PlantUmlMember.display`; `raw` keeps the full declaration. Without limits
164
+ * the result is byte-for-byte what it always was.
165
+ *
166
+ * Row caps replace dropped members with one `… +N more (M members)` row marked
167
+ * `synthetic: true`: it is drawing scaffolding, not authored content, so the
168
+ * serializer skips it and a lossless export reads the pre-detail model
169
+ * (`renderDetailed().sourceModel`). Re-applying `applyDetail` to an already
170
+ * detailed model counts only the real members, so markers never stack.
101
171
  */
102
- export function applyDetail(model, detail) {
172
+ export function applyDetail(model, detail, limits = {}) {
103
173
  const level = detail ?? autoDetail(model.entities.length);
104
- if (level === 'full')
174
+ const { memberMaxRows, memberMaxChars } = limits;
175
+ const hasLimits = memberMaxRows !== undefined || memberMaxChars !== undefined;
176
+ if (level === 'full' && !hasLimits)
105
177
  return model;
178
+ const limitMember = (member) => {
179
+ if (memberMaxChars === undefined || member.raw.length <= memberMaxChars)
180
+ return member;
181
+ return {
182
+ ...member,
183
+ display: `${member.raw.slice(0, Math.max(0, memberMaxChars - 1))}…`,
184
+ };
185
+ };
106
186
  return {
107
187
  ...model,
108
188
  entities: model.entities.map((entity) => {
109
- const total = entity.members.length;
189
+ const authored = entity.members.filter((member) => member.synthetic !== true);
190
+ const total = authored.length;
110
191
  if (level === 'names')
111
- return total === 0 ? entity : { ...entity, members: [] };
112
- if (total <= COMPACT_MEMBER_ROWS)
113
- return entity;
114
- const kept = entity.members.slice(0, COMPACT_MEMBER_ROWS);
192
+ return entity.members.length === 0 ? entity : { ...entity, members: [] };
193
+ const rowCap = memberMaxRows !== undefined
194
+ ? memberMaxRows
195
+ : level === 'compact'
196
+ ? COMPACT_MEMBER_ROWS
197
+ : total;
198
+ if (total <= rowCap) {
199
+ if (!hasLimits && authored.length === entity.members.length)
200
+ return entity;
201
+ return { ...entity, members: authored.map(limitMember) };
202
+ }
203
+ const kept = authored.slice(0, rowCap).map(limitMember);
115
204
  const more = {
116
205
  visibility: 'public',
117
206
  name: '…',
118
207
  kind: 'field',
119
208
  modifiers: [],
120
- raw: `… +${total - COMPACT_MEMBER_ROWS} more (${total} members)`,
209
+ raw: `… +${total - rowCap} more (${total} members)`,
210
+ synthetic: true,
121
211
  };
122
212
  return { ...entity, members: [...kept, more] };
123
213
  }),
@@ -20,6 +20,21 @@ export interface PlantUmlMember {
20
20
  optional?: boolean;
21
21
  /** Full authored declaration incl. visibility symbol, for faithful display. */
22
22
  raw: string;
23
+ /**
24
+ * Text actually drawn for this member, when it differs from `raw` (e.g. cut
25
+ * to a display width by `applyDetail`). Layout and rendering read
26
+ * `display ?? raw`; `raw` itself is never modified, so serialization and
27
+ * inspector views keep the full declaration.
28
+ */
29
+ display?: string;
30
+ /**
31
+ * True when the member never existed in the source: it is a drawn-only
32
+ * stand-in synthesized by `applyDetail` (the `… +N more (M members)` count
33
+ * row) to stand for the rows the detail level dropped. It is not part of the
34
+ * parsed model, so it must never be exported: [[toPlantUml]] skips it, and
35
+ * the lossless export path serializes `renderDetailed().sourceModel` instead.
36
+ */
37
+ synthetic?: true;
23
38
  }
24
39
  export interface PlantUmlEntity {
25
40
  /** Stable node identity inside the diagram (the alias when `as` is used). */
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * PlantUmlModel → PlantUML class-diagram text.
3
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
4
+ * The inverse of [[parsePlantUml]]. The model is the only input, so the caller
5
+ * composes the scope by picking the model
8
6
  * (`toPlantUml(applyView(model, {kind: 'neighborhood', entity, hops}))`); this
9
7
  * function never chooses a range on its own.
10
8
  *
9
+ * For a **lossless export** of what `renderDetailed` parsed, pass its
10
+ * `sourceModel` (the post-view/transform, pre-detail model):
11
+ * `toPlantUml(renderDetailed(input).sourceModel)`. Passing `model` instead
12
+ * serializes the model that was drawn — detail truncation has already dropped
13
+ * rows there, so the text is only as complete as the drawing. Members marked
14
+ * `synthetic` (the `… +N more (M members)` count row `applyDetail` adds) are
15
+ * drawing scaffolding, not authored content, and are always skipped.
16
+ *
11
17
  * Shapes that only exist after parsing (stub/external/usageCount, aggregated
12
18
  * edge weights, cycle markers) are rendering semantics with no PlantUML
13
19
  * spelling; they serialize as their plain-text equivalent (stub → class,
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * PlantUmlModel → PlantUML class-diagram text.
3
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
4
+ * The inverse of [[parsePlantUml]]. The model is the only input, so the caller
5
+ * composes the scope by picking the model
8
6
  * (`toPlantUml(applyView(model, {kind: 'neighborhood', entity, hops}))`); this
9
7
  * function never chooses a range on its own.
10
8
  *
9
+ * For a **lossless export** of what `renderDetailed` parsed, pass its
10
+ * `sourceModel` (the post-view/transform, pre-detail model):
11
+ * `toPlantUml(renderDetailed(input).sourceModel)`. Passing `model` instead
12
+ * serializes the model that was drawn — detail truncation has already dropped
13
+ * rows there, so the text is only as complete as the drawing. Members marked
14
+ * `synthetic` (the `… +N more (M members)` count row `applyDetail` adds) are
15
+ * drawing scaffolding, not authored content, and are always skipped.
16
+ *
11
17
  * Shapes that only exist after parsing (stub/external/usageCount, aggregated
12
18
  * edge weights, cycle markers) are rendering semantics with no PlantUML
13
19
  * spelling; they serialize as their plain-text equivalent (stub → class,
@@ -131,10 +137,13 @@ function entityLines(entity, depth, aliases) {
131
137
  const indent = INDENT.repeat(depth);
132
138
  const alias = aliases.alias(entity.id, entity.name);
133
139
  const head = `${entityKeyword(entity)} ${quoteName(entity.name)}${genericSuffix(entity)} as ${alias}`;
134
- if (entity.members.length === 0)
140
+ // Synthetic rows (applyDetail's `… +N more` marker) stand in for members the
141
+ // drawing dropped; they are not authored content, so they never serialize.
142
+ const members = entity.members.filter((member) => member.synthetic !== true);
143
+ if (members.length === 0)
135
144
  return [`${indent}${head}`];
136
145
  const lines = [`${indent}${head} {`];
137
- for (const member of entity.members) {
146
+ for (const member of members) {
138
147
  lines.push(`${indent}${INDENT}${memberLine(member)}`);
139
148
  }
140
149
  lines.push(`${indent}}`);
@@ -146,10 +155,9 @@ function genericSuffix(entity) {
146
155
  }
147
156
  /**
148
157
  * 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.
158
+ * visibility symbol and the exact signature); a declaration without a leading
159
+ * visibility symbol gets one prefixed so the row stays parseable. Synthetic
160
+ * detail markers never reach this function (see [[entityLines]]).
153
161
  */
154
162
  function memberLine(member) {
155
163
  const raw = (member.raw ?? '').trim();
@@ -166,7 +166,7 @@ function renderEntity(entity, node, theme) {
166
166
  parts.push(rule(opsTop, 'ops'));
167
167
  const titleBaseline = node.y + METRICS.paddingY + METRICS.fontSize + 1;
168
168
  parts.push(textEl(displayName(entity), node.x + node.width / 2, titleBaseline, 'middle', font.size.title, font.weight.title, color.textTitle, theme, fontStyle));
169
- const memberText = (member, idx, top, cls) => textEl(member.raw, node.x + METRICS.paddingX, top + METRICS.lineHeight * idx + METRICS.fontSize + 1, 'start', font.size.member, font.weight.member, color.textMember, theme, ` class="${cls}"`);
169
+ const memberText = (member, idx, top, cls) => textEl(member.display ?? member.raw, node.x + METRICS.paddingX, top + METRICS.lineHeight * idx + METRICS.fontSize + 1, 'start', font.size.member, font.weight.member, color.textMember, theme, ` class="${cls}"`);
170
170
  attrs.forEach((member, i) => {
171
171
  parts.push(memberText(member, i, titleBottom, 'uml-attr'));
172
172
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yalehwang/plantuml-js",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Pure-TypeScript PlantUML class diagram renderer (MIT)",
5
5
  "type": "module",
6
6
  "license": "MIT",