@yalehwang/plantuml-js 0.5.0 → 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
@@ -79,8 +79,8 @@ export interface RenderResult {
79
79
  }
80
80
  /**
81
81
  * Everything {@link renderDetailed} computed for one diagram: the model that was
82
- * actually laid out and drawn, the geometry the renderer used, the SVG it
83
- * 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.
84
84
  *
85
85
  * This is a **public, stable API** (available from both the `.` and `./elk`
86
86
  * entries). Consumers such as an architecture tab should read geometry from
@@ -101,8 +101,20 @@ export interface RenderResult {
101
101
  * they can be translated into a thumbnail directly.
102
102
  */
103
103
  export interface DetailedRender {
104
- /** 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
+ */
105
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;
106
118
  /** Geometry actually used by the renderer; its node/package/edge rectangles are a stable contract (see above). */
107
119
  layout: LayoutResult;
108
120
  svg: string;
@@ -138,6 +150,8 @@ export declare function createRenderer(options?: RendererOptions): Renderer;
138
150
  * Render one diagram through the exact pipeline `createRenderer` uses
139
151
  * (view/transforms → detail → layout → svg) and return every intermediate, so
140
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.
141
155
  *
142
156
  * Accepts either a puml string (the counterpart of
143
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,16 +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, {
72
73
  memberMaxRows: options.memberMaxRows,
73
74
  memberMaxChars: options.memberMaxChars,
74
75
  });
75
76
  const started = performance.now();
76
- const layout = await engine.layout(model);
77
+ const raw = await engine.layout(model);
77
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));
78
85
  const svg = renderEngine.render(model, layout, { hiddenTransitive });
79
- return { model, layout, svg, layoutMs, hiddenTransitive };
86
+ return { sourceModel, model, layout, svg, layoutMs, hiddenTransitive };
80
87
  }
81
88
  export async function renderDetailed(input, options = {}) {
82
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
@@ -125,6 +147,12 @@ export interface MemberDisplayLimits {
125
147
  * of each drawn line (`memberMaxChars`). Truncation only sets
126
148
  * `PlantUmlMember.display`; `raw` keeps the full declaration. Without limits
127
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.
128
156
  */
129
157
  export declare function applyDetail(model: PlantUmlModel, detail?: DetailLevel, limits?: MemberDisplayLimits): PlantUmlModel;
130
158
  /**
@@ -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
@@ -104,6 +162,12 @@ export function autoDetail(entityCount) {
104
162
  * of each drawn line (`memberMaxChars`). Truncation only sets
105
163
  * `PlantUmlMember.display`; `raw` keeps the full declaration. Without limits
106
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.
107
171
  */
108
172
  export function applyDetail(model, detail, limits = {}) {
109
173
  const level = detail ?? autoDetail(model.entities.length);
@@ -122,24 +186,28 @@ export function applyDetail(model, detail, limits = {}) {
122
186
  return {
123
187
  ...model,
124
188
  entities: model.entities.map((entity) => {
125
- const total = entity.members.length;
189
+ const authored = entity.members.filter((member) => member.synthetic !== true);
190
+ const total = authored.length;
126
191
  if (level === 'names')
127
- return total === 0 ? entity : { ...entity, members: [] };
192
+ return entity.members.length === 0 ? entity : { ...entity, members: [] };
128
193
  const rowCap = memberMaxRows !== undefined
129
194
  ? memberMaxRows
130
195
  : level === 'compact'
131
196
  ? COMPACT_MEMBER_ROWS
132
197
  : total;
133
198
  if (total <= rowCap) {
134
- return hasLimits ? { ...entity, members: entity.members.map(limitMember) } : entity;
199
+ if (!hasLimits && authored.length === entity.members.length)
200
+ return entity;
201
+ return { ...entity, members: authored.map(limitMember) };
135
202
  }
136
- const kept = entity.members.slice(0, rowCap).map(limitMember);
203
+ const kept = authored.slice(0, rowCap).map(limitMember);
137
204
  const more = {
138
205
  visibility: 'public',
139
206
  name: '…',
140
207
  kind: 'field',
141
208
  modifiers: [],
142
209
  raw: `… +${total - rowCap} more (${total} members)`,
210
+ synthetic: true,
143
211
  };
144
212
  return { ...entity, members: [...kept, more] };
145
213
  }),
@@ -27,6 +27,14 @@ export interface PlantUmlMember {
27
27
  * inspector views keep the full declaration.
28
28
  */
29
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;
30
38
  }
31
39
  export interface PlantUmlEntity {
32
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yalehwang/plantuml-js",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Pure-TypeScript PlantUML class diagram renderer (MIT)",
5
5
  "type": "module",
6
6
  "license": "MIT",