forma-diagrams 0.5.2

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 (109) hide show
  1. package/Dockerfile +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -0
  4. package/THIRD_PARTY_NOTICES.md +20 -0
  5. package/bin/forma +3 -0
  6. package/deploy/.env.example +11 -0
  7. package/deploy/Caddyfile +4 -0
  8. package/deploy/compose.yml +38 -0
  9. package/dist/assets/IBMPlexSans-Regular-Bl2SjS7V.ttf +0 -0
  10. package/dist/assets/IBMPlexSans-SemiBold-B9auKknr.ttf +0 -0
  11. package/dist/assets/elk.bundled-Y7ymokgr.js +24 -0
  12. package/dist/assets/index-_kg9cdik.css +1 -0
  13. package/dist/assets/index-al0xDdTG.js +452 -0
  14. package/dist/fonts/IBMPlexSans-Regular.ttf +0 -0
  15. package/dist/fonts/IBMPlexSans-SemiBold.ttf +0 -0
  16. package/dist/fonts/OFL.txt +93 -0
  17. package/dist/index.html +14 -0
  18. package/dist/licenses/FORMA_LICENSE.txt +21 -0
  19. package/dist/licenses/THIRD_PARTY_LICENSES.txt +11601 -0
  20. package/docs/api.md +41 -0
  21. package/docs/decisions/001-foundation.md +57 -0
  22. package/docs/decisions/002-composition-and-human-edits.md +47 -0
  23. package/docs/decisions/003-general-composition.md +30 -0
  24. package/docs/decisions/004-library-and-distribution.md +33 -0
  25. package/docs/decisions/005-organizational-hosting.md +33 -0
  26. package/docs/decisions/006-hosted-agent-access.md +25 -0
  27. package/docs/format.md +132 -0
  28. package/docs/gallery.md +73 -0
  29. package/docs/images/architecture.png +0 -0
  30. package/docs/images/editor.png +0 -0
  31. package/docs/images/gallery/architecture.png +0 -0
  32. package/docs/images/gallery/decision.png +0 -0
  33. package/docs/images/gallery/development-signal.png +0 -0
  34. package/docs/images/gallery/development.png +0 -0
  35. package/docs/images/gallery/editor-roundtrip.png +0 -0
  36. package/docs/images/gallery/entities.png +0 -0
  37. package/docs/images/gallery/mindmap.png +0 -0
  38. package/docs/images/gallery/organization.png +0 -0
  39. package/docs/images/gallery/timeline.png +0 -0
  40. package/docs/images/release.png +0 -0
  41. package/docs/install.md +50 -0
  42. package/docs/plans/mvp.md +24 -0
  43. package/docs/self-hosting.md +167 -0
  44. package/docs/verification-v2.md +41 -0
  45. package/docs/verification-v3.md +16 -0
  46. package/docs/verification-v4.1.md +35 -0
  47. package/docs/verification-v4.2.md +20 -0
  48. package/docs/verification-v4.3.md +5 -0
  49. package/docs/verification-v4.md +31 -0
  50. package/docs/verification-v5.1.md +7 -0
  51. package/docs/verification-v5.2.md +7 -0
  52. package/docs/verification-v5.md +7 -0
  53. package/docs/verification.md +91 -0
  54. package/examples/design-systems/atelier.json +53 -0
  55. package/examples/design-systems/signal.json +53 -0
  56. package/examples/gallery/architecture.forma.json +178 -0
  57. package/examples/gallery/decision.forma.json +182 -0
  58. package/examples/gallery/development-signal.forma.json +299 -0
  59. package/examples/gallery/development.forma.json +299 -0
  60. package/examples/gallery/entities.forma.json +123 -0
  61. package/examples/gallery/mindmap.forma.json +158 -0
  62. package/examples/gallery/organization.forma.json +151 -0
  63. package/examples/gallery/timeline.forma.json +138 -0
  64. package/examples/platform.forma.json +116 -0
  65. package/examples/release.forma.json +58 -0
  66. package/examples/roundtrip/agent-continued.forma.json +310 -0
  67. package/examples/roundtrip/agent-patch.json +7 -0
  68. package/examples/roundtrip/human-edited.forma.json +309 -0
  69. package/package.json +81 -0
  70. package/packages/cli/bin.mjs +3 -0
  71. package/packages/cli/src/agent-access.ts +232 -0
  72. package/packages/cli/src/blob-store.ts +241 -0
  73. package/packages/cli/src/file-library.ts +179 -0
  74. package/packages/cli/src/google.ts +51 -0
  75. package/packages/cli/src/hosted.ts +498 -0
  76. package/packages/cli/src/http.ts +67 -0
  77. package/packages/cli/src/index.ts +397 -0
  78. package/packages/cli/src/mcp.ts +114 -0
  79. package/packages/cli/src/remote.ts +228 -0
  80. package/packages/cli/src/server.ts +69 -0
  81. package/packages/cli/src/signed-cookie.ts +42 -0
  82. package/packages/core/src/align.ts +237 -0
  83. package/packages/core/src/document.ts +408 -0
  84. package/packages/core/src/font-metrics.json +1 -0
  85. package/packages/core/src/geometry.ts +463 -0
  86. package/packages/core/src/index.ts +12 -0
  87. package/packages/core/src/inspect.ts +230 -0
  88. package/packages/core/src/layout.ts +450 -0
  89. package/packages/core/src/render.ts +182 -0
  90. package/packages/core/src/resolve-style.ts +29 -0
  91. package/packages/core/src/scene.ts +40 -0
  92. package/packages/core/src/styles.ts +122 -0
  93. package/packages/core/src/text.ts +81 -0
  94. package/packages/core/src/theme.ts +33 -0
  95. package/public/fonts/IBMPlexSans-Regular.ttf +0 -0
  96. package/public/fonts/IBMPlexSans-SemiBold.ttf +0 -0
  97. package/public/fonts/OFL.txt +93 -0
  98. package/schema/design-system.v1.schema.json +734 -0
  99. package/schema/forma.v1.schema.json +273 -0
  100. package/schema/forma.v2.schema.json +1756 -0
  101. package/skills/forma/SKILL.md +20 -0
  102. package/skills/forma/references/composition.md +11 -0
  103. package/skills/forma/references/hosted.md +28 -0
  104. package/skills/forma-architecture/SKILL.md +14 -0
  105. package/skills/forma-entities/SKILL.md +14 -0
  106. package/skills/forma-flow/SKILL.md +14 -0
  107. package/skills/forma-mindmap/SKILL.md +14 -0
  108. package/skills/forma-organization/SKILL.md +14 -0
  109. package/skills/forma-timeline/SKILL.md +14 -0
@@ -0,0 +1,450 @@
1
+ import { resolveNodeStyle, resolveEdgeStyle, resolveGroupStyle } from './resolve-style';
2
+ import type { ElkNode, ElkExtendedEdge, ELK } from 'elkjs';
3
+ import { fingerprint, parseDocument, type Diagram, type Point } from './document';
4
+ import { nodeText, styledNodeText, textWidth, wrapText } from './text';
5
+ import type { Box, Scene, SceneEdge, SceneGroup, SceneNode } from './scene';
6
+ import {
7
+ arriveSide,
8
+ attachmentPoint,
9
+ departSide,
10
+ isOrthogonal,
11
+ overlaps,
12
+ retargetRoute,
13
+ routeBetween,
14
+ routesCross,
15
+ routeThrough,
16
+ segmentHitsBox,
17
+ shapeBoundary,
18
+ simplify,
19
+ } from './geometry';
20
+ let elkPromise: Promise<ELK> | undefined;
21
+ const groupMinWidth = (label: string) =>
22
+ Math.max(180, Math.ceil(textWidth(label.toUpperCase(), 11, 600) + label.length * 1.2 + 64));
23
+
24
+ export async function layoutDiagram(input: Diagram): Promise<Scene> {
25
+ const elk = await (elkPromise ??= import('elkjs/lib/elk.bundled.js').then(
26
+ (module) => new module.default(),
27
+ ));
28
+ const doc = parseDocument(input),
29
+ compact = doc.layout.spacing === 'compact';
30
+ const system = doc.presentation.designSystem;
31
+ const nodeStyles = new Map(doc.nodes.map((n) => [n.id, resolveNodeStyle(doc, n)]));
32
+ const measured = new Map(
33
+ doc.nodes.map((n) => {
34
+ const style = nodeStyles.get(n.id)!;
35
+ return [
36
+ n.id,
37
+ Object.keys(style).length
38
+ ? styledNodeText(n.label, n.description, style)
39
+ : nodeText(n.label, n.description),
40
+ ];
41
+ }),
42
+ );
43
+ const buildChildren = (parent?: string): ElkNode[] => [
44
+ ...doc.groups
45
+ .filter((g) => g.parent === parent)
46
+ .map((g) => ({
47
+ id: g.id,
48
+ children: buildChildren(g.id),
49
+ width: groupMinWidth(g.label),
50
+ height: 100,
51
+ layoutOptions: {
52
+ 'elk.padding': '[top=64,left=24,bottom=24,right=24]',
53
+ 'elk.nodeSize.constraints': 'MINIMUM_SIZE',
54
+ 'elk.nodeSize.minimum': `(${groupMinWidth(g.label)},100)`,
55
+ },
56
+ })),
57
+ ...doc.nodes
58
+ .filter((n) => n.group === parent)
59
+ .map((n) => ({
60
+ id: n.id,
61
+ width: measured.get(n.id)!.width,
62
+ height: measured.get(n.id)!.height,
63
+ })),
64
+ ];
65
+ const graph = await elk.layout({
66
+ id: '__forma_root__',
67
+ layoutOptions: {
68
+ 'elk.algorithm': 'layered',
69
+ 'elk.direction': doc.layout.direction,
70
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
71
+ 'elk.edgeRouting': 'ORTHOGONAL',
72
+ 'elk.spacing.nodeNode': String(system?.spacing?.node ?? (compact ? 32 : 48)),
73
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(
74
+ system?.spacing?.layer ?? (compact ? 56 : 88),
75
+ ),
76
+ 'elk.layered.spacing.edgeNodeBetweenLayers': '28',
77
+ 'elk.spacing.edgeNode': '24',
78
+ 'elk.spacing.edgeEdge': '0',
79
+ 'elk.layered.spacing.edgeEdgeBetweenLayers': '0',
80
+ 'elk.padding': '[top=0,left=0,bottom=0,right=0]',
81
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
82
+ 'elk.randomSeed': '1',
83
+ 'elk.separateConnectedComponents': 'true',
84
+ },
85
+ children: buildChildren(),
86
+ edges: doc.edges.map((e) => ({
87
+ id: e.id,
88
+ sources: [e.source],
89
+ targets: [e.target],
90
+ ...(e.label
91
+ ? { labels: [{ text: e.label, width: textWidth(e.label, 11) + 20, height: 24 }] }
92
+ : {}),
93
+ })),
94
+ });
95
+ const nodes: SceneNode[] = [],
96
+ groups: SceneGroup[] = [],
97
+ edges: SceneEdge[] = [];
98
+ const nodeMap = new Map(doc.nodes.map((n) => [n.id, n])),
99
+ groupMap = new Map(doc.groups.map((g) => [g.id, g]));
100
+ const rawEdges: { edge: ElkExtendedEdge; x: number; y: number }[] = [];
101
+ function walk(container: ElkNode, x: number, y: number, depth: number) {
102
+ for (const e of container.edges ?? []) rawEdges.push({ edge: e, x, y });
103
+ for (const child of container.children ?? []) {
104
+ const box = {
105
+ x: x + (child.x ?? 0),
106
+ y: y + (child.y ?? 0),
107
+ width: child.width ?? 224,
108
+ height: child.height ?? 120,
109
+ };
110
+ const semantic = nodeMap.get(child.id);
111
+ if (semantic) {
112
+ const override = doc.presentation.nodes[child.id];
113
+ nodes.push({
114
+ ...box,
115
+ ...measured.get(child.id)!,
116
+ id: child.id,
117
+ semantic,
118
+ style: nodeStyles.get(child.id),
119
+ accent:
120
+ override?.color ??
121
+ (semantic.emphasis === 'primary'
122
+ ? 'blue'
123
+ : semantic.group
124
+ ? groupMap.get(semantic.group)!.color
125
+ : doc.type === 'flow'
126
+ ? 'blue'
127
+ : 'slate'),
128
+ pinned: !!override?.position,
129
+ });
130
+ } else {
131
+ const semantic = groupMap.get(child.id)!;
132
+ groups.push({
133
+ ...box,
134
+ id: child.id,
135
+ semantic,
136
+ depth,
137
+ style: resolveGroupStyle(doc, semantic),
138
+ });
139
+ walk(child, box.x, box.y, depth + 1);
140
+ }
141
+ }
142
+ }
143
+ walk(graph, 0, 0, 0);
144
+ const allNodes = new Map(nodes.map((n) => [n.id, n]));
145
+ const rawMap = new Map(
146
+ rawEdges.map((e) => {
147
+ const container = groups.find(
148
+ (g) => g.id === (e.edge as ElkExtendedEdge & { container?: string }).container,
149
+ );
150
+ return [e.edge.id, { ...e, x: container?.x ?? e.x, y: container?.y ?? e.y }];
151
+ }),
152
+ );
153
+ const labelBoxes: Box[] = [];
154
+ // ELK reserves generous compound boundary channels. Compress only wholly empty
155
+ // bands between top-level containers, keeping enough space for labels.
156
+ const axis = doc.layout.direction === 'RIGHT' ? 'x' : 'y';
157
+ const extent = axis === 'x' ? 'width' : 'height';
158
+ const occupied = [
159
+ ...groups.filter((g) => !g.semantic.parent),
160
+ ...nodes.filter((n) => !n.semantic.group),
161
+ ]
162
+ .map((b) => [b[axis], b[axis] + b[extent]])
163
+ .sort((a, b) => a[0] - b[0]);
164
+ const bands: { start: number; end: number; remove: number }[] = [];
165
+ let end = occupied[0]?.[1] ?? 0;
166
+ for (const interval of occupied.slice(1)) {
167
+ if (interval[0] - end > 120)
168
+ bands.push({ start: end, end: interval[0], remove: interval[0] - end - 120 });
169
+ end = Math.max(end, interval[1]);
170
+ }
171
+ const mapAxis = (value: number) =>
172
+ value -
173
+ bands.reduce(
174
+ (sum, b) =>
175
+ sum +
176
+ (value >= b.end
177
+ ? b.remove
178
+ : value > b.start
179
+ ? ((value - b.start) / (b.end - b.start)) * b.remove
180
+ : 0),
181
+ 0,
182
+ );
183
+ const composePoint = (point: Point) => ({
184
+ ...point,
185
+ [axis]: Math.round(mapAxis(point[axis]) * 100) / 100,
186
+ });
187
+ for (const box of [...nodes, ...groups]) {
188
+ const max = mapAxis(box[axis] + box[extent]);
189
+ box[axis] = mapAxis(box[axis]);
190
+ box[extent] = max - box[axis];
191
+ }
192
+ if (doc.layout.mode === 'grid') {
193
+ const columns = new Map<number, number>(),
194
+ rows = new Map<number, number>();
195
+ for (const [i, n] of nodes.entries()) {
196
+ const p = n.semantic.placement ?? {
197
+ column: doc.nodes.findIndex((item) => item.id === n.id),
198
+ row: 0,
199
+ };
200
+ columns.set(p.column, Math.max(columns.get(p.column) ?? 0, n.width));
201
+ rows.set(p.row, Math.max(rows.get(p.row) ?? 0, n.height));
202
+ }
203
+ const offset = (map: Map<number, number>, index: number, gap: number) =>
204
+ [...map].filter(([key]) => key < index).reduce((sum, [, size]) => sum + size + gap, 0);
205
+ for (const [i, n] of nodes.entries()) {
206
+ const p = n.semantic.placement ?? {
207
+ column: doc.nodes.findIndex((item) => item.id === n.id),
208
+ row: 0,
209
+ };
210
+ n.x =
211
+ offset(columns, p.column, system?.spacing?.layer ?? 88) +
212
+ ((columns.get(p.column) ?? n.width) - n.width) / 2;
213
+ n.y =
214
+ offset(rows, p.row, system?.spacing?.node ?? 64) +
215
+ ((rows.get(p.row) ?? n.height) - n.height) / 2;
216
+ }
217
+ }
218
+ for (const n of nodes)
219
+ if (doc.presentation.nodes[n.id]?.position)
220
+ Object.assign(n, doc.presentation.nodes[n.id].position);
221
+ // Bounds follow human-moved descendants, preserving the semantic container.
222
+ if (nodes.some((n) => n.pinned) || doc.layout.mode === 'grid') {
223
+ for (const g of [...groups].sort((a, b) => b.depth - a.depth)) {
224
+ const children: Box[] = [
225
+ ...nodes.filter((n) => n.semantic.group === g.id),
226
+ ...groups.filter((c) => c.semantic.parent === g.id),
227
+ ];
228
+ if (!children.length) continue;
229
+ const x = Math.min(...children.map((c) => c.x)) - 24,
230
+ y = Math.min(...children.map((c) => c.y)) - 64;
231
+ Object.assign(g, {
232
+ x,
233
+ y,
234
+ width: Math.max(
235
+ groupMinWidth(g.semantic.label),
236
+ Math.max(...children.map((c) => c.x + c.width)) + 24 - x,
237
+ ),
238
+ height: Math.max(...children.map((c) => c.y + c.height)) + 24 - y,
239
+ });
240
+ }
241
+ }
242
+ const down = doc.layout.direction === 'DOWN';
243
+ const routed: { points: Point[]; custom: boolean; straight: boolean }[] = [];
244
+ const commit = (points: Point[], custom: boolean, straight: boolean) => {
245
+ routed.push({ points, custom, straight });
246
+ return points;
247
+ };
248
+ for (const semantic of doc.edges) {
249
+ const edgeStyle = resolveEdgeStyle(doc, semantic);
250
+ const source = allNodes.get(semantic.source)!,
251
+ target = allNodes.get(semantic.target)!;
252
+ const skip = new Set([source.id, target.id]);
253
+ const blocked = (points: Point[]) =>
254
+ nodes.some(
255
+ (n) =>
256
+ !skip.has(n.id) && points.some((p, i) => i > 0 && segmentHitsBox(points[i - 1], p, n)),
257
+ );
258
+ const peers = () => routed.filter((r) => !r.straight).map((r) => r.points);
259
+ const raw = rawMap.get(semantic.id),
260
+ sections = raw?.edge.sections;
261
+ const elkPoints = sections?.length
262
+ ? simplify(
263
+ sections
264
+ .flatMap((s) => [s.startPoint, ...(s.bendPoints ?? []), s.endPoint])
265
+ .map((p) => ({
266
+ x: Math.round((p.x + raw!.x) * 100) / 100,
267
+ y: Math.round((p.y + raw!.y) * 100) / 100,
268
+ }))
269
+ .map(composePoint),
270
+ )
271
+ : null;
272
+ const explicitSide = !!(edgeStyle.sourcePort || edgeStyle.targetPort);
273
+ const elkUsable = !!(elkPoints && elkPoints.length > 1 && doc.layout.mode !== 'grid');
274
+ const sourceSide =
275
+ edgeStyle.sourcePort ??
276
+ (elkUsable ? departSide(elkPoints![0], elkPoints![1]) : undefined) ??
277
+ (down ? 'bottom' : 'right');
278
+ const targetSide =
279
+ edgeStyle.targetPort ??
280
+ (elkUsable ? arriveSide(elkPoints!.at(-2)!, elkPoints!.at(-1)!) : undefined) ??
281
+ (down ? 'top' : 'left');
282
+ const from = attachmentPoint(
283
+ source,
284
+ source.semantic.ports,
285
+ sourceSide,
286
+ edgeStyle.sourceIndex,
287
+ sourceSide,
288
+ );
289
+ const to = attachmentPoint(
290
+ target,
291
+ target.semantic.ports,
292
+ targetSide,
293
+ edgeStyle.targetIndex,
294
+ targetSide,
295
+ );
296
+ let points: Point[];
297
+ const straight = edgeStyle.routing === 'straight' && source.id !== target.id;
298
+ if (straight) {
299
+ if (semantic.path?.length)
300
+ points = routeThrough(from.point, semantic.path, to.point, from.side, to.side);
301
+ else {
302
+ const center = (b: Box) => ({ x: b.x + b.width / 2, y: b.y + b.height / 2 });
303
+ points =
304
+ edgeStyle.sourcePort || edgeStyle.targetPort
305
+ ? [from.point, to.point]
306
+ : [
307
+ shapeBoundary(source, center(target), source.style?.shape),
308
+ shapeBoundary(target, center(source), target.style?.shape),
309
+ ];
310
+ }
311
+ commit(points, !!semantic.path?.length, true);
312
+ } else if (semantic.path?.length && source.id !== target.id) {
313
+ points = commit(
314
+ routeThrough(from.point, semantic.path, to.point, from.side, to.side),
315
+ true,
316
+ false,
317
+ );
318
+ } else if (source.id === target.id) {
319
+ points = commit(
320
+ routeBetween(
321
+ source,
322
+ target,
323
+ nodes,
324
+ down,
325
+ true,
326
+ from.side,
327
+ to.side,
328
+ from.index,
329
+ to.index,
330
+ from.count,
331
+ to.count,
332
+ ),
333
+ false,
334
+ false,
335
+ );
336
+ } else {
337
+ let kept: Point[] | null = null;
338
+ if (elkPoints && doc.layout.mode !== 'grid') {
339
+ const adjusted =
340
+ retargetRoute(elkPoints, from.point, to.point, from.side, to.side) ??
341
+ (explicitSide ? null : elkPoints);
342
+ if (
343
+ adjusted &&
344
+ isOrthogonal(adjusted) &&
345
+ !blocked(adjusted) &&
346
+ !peers().some((route) => routesCross(adjusted, route))
347
+ )
348
+ kept = adjusted;
349
+ }
350
+ points = commit(
351
+ kept ??
352
+ routeBetween(
353
+ source,
354
+ target,
355
+ nodes,
356
+ down,
357
+ false,
358
+ from.side,
359
+ to.side,
360
+ from.index,
361
+ to.index,
362
+ from.count,
363
+ to.count,
364
+ peers(),
365
+ ),
366
+ false,
367
+ false,
368
+ );
369
+ }
370
+ const style = edgeStyle;
371
+ const edge: SceneEdge = { id: semantic.id, semantic, points, style };
372
+ if (semantic.label) {
373
+ const lines = wrapText(semantic.label, 156, 11),
374
+ width = Math.max(...lines.map((l) => textWidth(l, 11))) + 16,
375
+ height = lines.length * 15 + 8;
376
+ const candidates: { box: Box; score: number }[] = [];
377
+ for (let i = 1; i < points.length; i++) {
378
+ const a = points[i - 1],
379
+ b = points[i],
380
+ length = Math.hypot(b.x - a.x, b.y - a.y);
381
+ for (const ratio of [0.5, 0.3, 0.7]) {
382
+ const x = a.x + (b.x - a.x) * ratio,
383
+ y = a.y + (b.y - a.y) * ratio;
384
+ const horizontal = a.y === b.y;
385
+ const box = {
386
+ x: horizontal ? x - width / 2 : x + 9,
387
+ y: horizontal ? y - height - 7 : y - height / 2,
388
+ width,
389
+ height,
390
+ };
391
+ const collisions = [
392
+ ...nodes,
393
+ ...labelBoxes,
394
+ ...groups.map((g) => ({ x: g.x, y: g.y, width: g.width, height: 48 })),
395
+ ].filter((n) => overlaps(n, box, 4)).length;
396
+ candidates.push({
397
+ box,
398
+ score:
399
+ collisions * 100000 +
400
+ (length < (horizontal ? width : height) + 12 ? 5000 : 0) -
401
+ length +
402
+ Math.abs(0.5 - ratio),
403
+ });
404
+ }
405
+ }
406
+ candidates.sort((a, b) => a.score - b.score);
407
+ const box = candidates[0]?.box ?? {
408
+ x: points[0]?.x ?? 0,
409
+ y: points[0]?.y ?? 0,
410
+ width,
411
+ height,
412
+ };
413
+ edge.label = { ...box, lines };
414
+ labelBoxes.push(box);
415
+ }
416
+ edges.push(edge);
417
+ }
418
+ const content: Box[] = [
419
+ ...nodes,
420
+ ...groups,
421
+ ...labelBoxes,
422
+ ...edges.flatMap((e) => e.points.map((p) => ({ ...p, width: 0, height: 0 }))),
423
+ ];
424
+ const minX = Math.min(0, ...content.map((c) => c.x)),
425
+ minY = Math.min(0, ...content.map((c) => c.y));
426
+ const maxX = Math.max(620, ...content.map((c) => c.x + c.width)),
427
+ maxY = Math.max(
428
+ doc.version === 1 || !nodes.length ? 240 : 0,
429
+ ...content.map((c) => c.y + c.height),
430
+ );
431
+ const width = maxX - minX + 112;
432
+ const titleLines = wrapText(doc.title, width - 112, 30, 600),
433
+ descriptionLines = wrapText(doc.description ?? '', width - 112, 13);
434
+ const header = Math.max(128, 48 + titleLines.length * 38 + descriptionLines.length * 19 + 28);
435
+ return {
436
+ document: doc,
437
+ fingerprint: fingerprint(doc),
438
+ nodes,
439
+ groups,
440
+ edges,
441
+ bounds: {
442
+ x: minX - 56,
443
+ y: minY - header,
444
+ width,
445
+ height: maxY - minY + header + (doc.presentation.footer?.hidden ? 28 : 76),
446
+ },
447
+ titleLines,
448
+ descriptionLines,
449
+ };
450
+ }
@@ -0,0 +1,182 @@
1
+ import { dashArray } from './styles';
2
+ import type { Scene, SceneNode, SceneGroup, SceneEdge } from './scene';
3
+ import { accents, themes } from './theme';
4
+ import { polylinePath } from './geometry';
5
+ export const escapeXml = (v: string | number) =>
6
+ String(v)
7
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
8
+ .replace(
9
+ /[&<>"']/g,
10
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]!,
11
+ );
12
+ const t = (
13
+ x: number,
14
+ y: number,
15
+ value: string,
16
+ size: number,
17
+ fill: string,
18
+ weight = 400,
19
+ extra = '',
20
+ ) =>
21
+ `<text x="${x}" y="${y}" font-size="${size}" font-weight="${weight}" fill="${fill}" ${extra}>${escapeXml(value)}</text>`;
22
+ const icons: Record<string, string> = {
23
+ service:
24
+ '<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.01M7 17.5h.01M12 6.5h5M12 17.5h5"/>',
25
+ database:
26
+ '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 4 16 4 16 0V5M4 12c0 4 16 4 16 0"/>',
27
+ queue:
28
+ '<rect x="2" y="6" width="5" height="12" rx="1.5"/><rect x="9.5" y="6" width="5" height="12" rx="1.5"/><rect x="17" y="6" width="5" height="12" rx="1.5"/>',
29
+ client:
30
+ '<rect x="2" y="3" width="20" height="15" rx="2"/><path d="M8 22h8M12 18v4M2 8h20M6 5.5h.01M9 5.5h.01"/>',
31
+ person: '<circle cx="12" cy="7" r="4"/><path d="M4 22v-3a8 8 0 0 1 16 0v3"/>',
32
+ process: '<path d="m13 2-9 12h7l-1 8 10-12h-8z"/>',
33
+ decision: '<path d="m12 2 10 10-10 10L2 12zM12 7v6M12 17h.01"/>',
34
+ };
35
+ export function iconMarkup(kind: string, color: string, x = 0, y = 0, size = 20): string {
36
+ return `<g transform="translate(${x} ${y}) scale(${size / 24})" fill="none" stroke="${color}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${icons[kind] ?? icons.service}</g>`;
37
+ }
38
+ export function nodeMarkup(n: SceneNode, themeName: 'paper' | 'midnight'): string {
39
+ if (n.style && Object.keys(n.style).length) return styledNodeMarkup(n, themeName);
40
+ const theme = themes[themeName],
41
+ accent = accents[n.accent],
42
+ dark = themeName === 'midnight',
43
+ primary = n.semantic.emphasis === 'primary';
44
+ const ink = dark ? accent.dark : accent.ink,
45
+ fill = dark ? '#2b394e' : accent.fill;
46
+ const body = `<rect x="0.5" y="1.5" width="${n.width - 1}" height="${n.height - 1}" rx="10" fill="${theme.shadow}" opacity=".04"/>
47
+ <rect x=".5" y=".5" width="${n.width - 1}" height="${n.height - 1}" rx="10" fill="${primary ? fill : theme.card}" stroke="${primary ? (dark ? ink : accent.border) : theme.border}"/>
48
+ <rect x="20" y="18" width="32" height="32" rx="8" fill="${fill}"/>
49
+ ${iconMarkup(n.semantic.kind, ink, 26, 24, 20)}
50
+ ${t(n.width - 20, 38, n.semantic.kind.toUpperCase(), 9, theme.secondary, 500, 'text-anchor="end" letter-spacing="1.1"')}
51
+ ${n.titleLines.map((line, i) => t(20, 76 + i * 22, line, 16, theme.text, 600)).join('')}
52
+ ${n.descriptionLines.map((line, i) => t(20, 76 + n.titleLines.length * 22 + 4 + i * 17, line, 12, theme.secondary)).join('')}`;
53
+ return `<g opacity="${n.semantic.emphasis === 'muted' ? 0.62 : 1}">${body}</g>`;
54
+ }
55
+ function styledNodeMarkup(n: SceneNode, themeName: 'paper' | 'midnight'): string {
56
+ const s = n.style!,
57
+ theme = themes[themeName],
58
+ w = n.width,
59
+ h = n.height;
60
+ const shape = s.shape ?? 'rect',
61
+ radius = shape === 'pill' ? h / 2 : (s.radius ?? 10);
62
+ const attrs = `fill="${s.fill ?? theme.card}" stroke="${s.stroke ?? theme.border}" stroke-width="${s.strokeWidth ?? 1}" stroke-dasharray="${dashArray(s.dash)}"`;
63
+ let body = `<rect x=".5" y=".5" width="${w - 1}" height="${h - 1}" rx="${radius}" ${attrs}/>`;
64
+ if (shape === 'diamond')
65
+ body = `<path d="M${w / 2} 0 L${w} ${h / 2} L${w / 2} ${h} L0 ${h / 2}Z" ${attrs}/>`;
66
+ if (shape === 'ellipse')
67
+ body = `<ellipse cx="${w / 2}" cy="${h / 2}" rx="${w / 2 - 0.5}" ry="${h / 2 - 0.5}" ${attrs}/>`;
68
+ if (shape === 'cylinder')
69
+ body = `<path d="M0 14 C0 -4 ${w} -4 ${w} 14 V${h - 14} C${w} ${h + 4} 0 ${h + 4} 0 ${h - 14}Z M0 14 C0 32 ${w} 32 ${w} 14" ${attrs}/>`;
70
+ if (shape === 'text') body = '';
71
+ const size = s.fontSize ?? 16,
72
+ weight = s.fontWeight ?? 600;
73
+ const align = s.align ?? (['diamond', 'ellipse', 'pill'].includes(shape) ? 'center' : 'left');
74
+ const inset = shape === 'diamond' ? w * 0.23 : shape === 'ellipse' ? w * 0.16 : (s.padding ?? 20);
75
+ const x = align === 'center' ? w / 2 : align === 'right' ? w - inset : inset;
76
+ const anchor =
77
+ align === 'center' ? 'text-anchor="middle"' : align === 'right' ? 'text-anchor="end"' : '';
78
+ const blockHeight =
79
+ n.titleLines.length * size * 1.4 +
80
+ (n.descriptionLines.length ? 8 + n.descriptionLines.length * 17 : 0);
81
+ const valign = s.verticalAlign ?? 'middle';
82
+ const vInset = shape === 'diamond' ? h * 0.28 : shape === 'ellipse' ? h * 0.2 : (s.padding ?? 20);
83
+ const blockTop =
84
+ valign === 'top'
85
+ ? vInset
86
+ : valign === 'bottom'
87
+ ? h - vInset - blockHeight
88
+ : (h - blockHeight) / 2;
89
+ const top = blockTop + size;
90
+ body += n.titleLines
91
+ .map((line, i) => t(x, top + i * size * 1.4, line, size, s.text ?? theme.text, weight, anchor))
92
+ .join('');
93
+ body += n.descriptionLines
94
+ .map((line, i) =>
95
+ t(
96
+ x,
97
+ top + n.titleLines.length * size * 1.4 + 8 + i * 17,
98
+ line,
99
+ 12,
100
+ s.secondary ?? theme.secondary,
101
+ 400,
102
+ anchor,
103
+ ),
104
+ )
105
+ .join('');
106
+ return `<g font-family="${escapeXml(s.fontFamily ?? 'IBM Plex Sans')}" opacity="${s.opacity ?? (n.semantic.emphasis === 'muted' ? 0.62 : 1)}">${body}</g>`;
107
+ }
108
+ export function groupMarkup(g: SceneGroup, themeName: 'paper' | 'midnight'): string {
109
+ const theme = themes[themeName],
110
+ accent = accents[g.semantic.color],
111
+ s = g.style ?? {};
112
+ const ink = s.text ?? (themeName === 'midnight' ? accent.dark : accent.ink);
113
+ return `<g opacity="${s.opacity ?? 1}" font-family="${escapeXml(s.fontFamily ?? 'IBM Plex Sans')}"><rect x="${g.x}" y="${g.y}" width="${g.width}" height="${g.height}" rx="${s.radius ?? 14}" fill="${s.fill ?? theme.group}" stroke="${s.stroke ?? theme.border}" stroke-width="${s.strokeWidth ?? 1}" stroke-dasharray="${dashArray(s.dash ?? 'dashed')}"/><rect x="${g.x + 24}" y="${g.y + 26}" width="6" height="6" rx="2" fill="${ink}"/>${t(g.x + 39, g.y + 33, g.semantic.label.toUpperCase(), 11, ink, 600, 'letter-spacing="1.2"')}</g>`;
114
+ }
115
+ export function edgeMarkup(e: SceneEdge, themeName: 'paper' | 'midnight'): string {
116
+ const s = e.style ?? {},
117
+ ink = s.stroke ?? themes[themeName].line,
118
+ width = s.strokeWidth ?? 1.5;
119
+ const arrow = (start: boolean) => {
120
+ const kind = start ? (s.arrowStart ?? 'none') : (s.arrowEnd ?? 'open');
121
+ if (kind === 'none' || e.points.length < 2) return '';
122
+ const b = start ? e.points[0] : e.points.at(-1)!,
123
+ a = start ? e.points[1] : e.points.at(-2)!;
124
+ const angle = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
125
+ const path =
126
+ kind === 'diamond'
127
+ ? 'M0 0 L-6 -4 L-12 0 L-6 4Z'
128
+ : kind === 'filled'
129
+ ? 'M0 0 L-8 -4 L-8 4Z'
130
+ : 'M-7 -3.5 L0 0 L-7 3.5';
131
+ return `<g transform="translate(${b.x} ${b.y}) rotate(${angle})" stroke="${ink}" stroke-width="${width}" fill="${kind === 'open' ? 'none' : ink}">${kind === 'circle' ? '<circle cx="-4" cy="0" r="4"/>' : `<path d="${path}"/>`}</g>`;
132
+ };
133
+ return `<g opacity="${s.opacity ?? 1}"><path d="${polylinePath(e.points)}" fill="none" stroke="${ink}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="${dashArray(s.dash ?? e.semantic.style)}"/>${arrow(true)}${arrow(false)}</g>`;
134
+ }
135
+ export function edgeLabelMarkup(e: SceneEdge, themeName: 'paper' | 'midnight'): string {
136
+ if (!e.label) return '';
137
+ const theme = themes[themeName],
138
+ b = e.label;
139
+ return `<rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="4" fill="${e.style?.fill ?? theme.background}" fill-opacity=".96"/>${b.lines.map((l, i) => t(b.x + b.width / 2, b.y + 15 + i * 15, l, 11, e.style?.text ?? theme.secondary, 400, 'text-anchor="middle"')).join('')}`;
140
+ }
141
+ export function frameMarkup(scene: Scene): string {
142
+ const { bounds: b, document: doc } = scene,
143
+ theme = { ...themes[doc.presentation.theme], ...doc.presentation.designSystem?.canvas };
144
+ const x = b.x + 56,
145
+ titleY = b.y + 54;
146
+ const heading = `${scene.titleLines.map((l, i) => t(x, titleY + i * 38, l, 30, theme.text, 600, 'letter-spacing="-.7"')).join('')}
147
+ ${scene.descriptionLines.map((l, i) => t(x, titleY + scene.titleLines.length * 38 - 7 + i * 19, l, 13, theme.secondary)).join('')}`;
148
+ const footer = doc.presentation.footer;
149
+ if (footer?.hidden) return heading;
150
+ const label = footer?.label ?? `FORMA / ${doc.type.toUpperCase()}`;
151
+ const detail =
152
+ footer?.detail ?? `${scene.nodes.length} components · ${scene.edges.length} relationships`;
153
+ return `${heading}
154
+ <line x1="${x}" y1="${b.y + b.height - 44}" x2="${b.x + b.width - 56}" y2="${b.y + b.height - 44}" stroke="${theme.border}"/>
155
+ ${label ? t(x, b.y + b.height - 22, label, 9, theme.secondary, 500, 'letter-spacing="1.6"') : ''}
156
+ ${detail ? t(b.x + b.width - 56, b.y + b.height - 22, detail, 10, theme.secondary, 400, 'text-anchor="end"') : ''}`;
157
+ }
158
+ export interface SvgOptions {
159
+ fontDataUri?: string;
160
+ boldFontDataUri?: string;
161
+ }
162
+ export function renderSvg(scene: Scene, options: SvgOptions = {}): string {
163
+ const b = scene.bounds,
164
+ theme = scene.document.presentation.theme;
165
+ const font = options.fontDataUri
166
+ ? `<style>@font-face{font-family:'IBM Plex Sans';src:url('${options.fontDataUri}') format('truetype');font-weight:400;} ${options.boldFontDataUri ? `@font-face{font-family:'IBM Plex Sans';src:url('${options.boldFontDataUri}') format('truetype');font-weight:600;}` : ''}</style>`
167
+ : '';
168
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.ceil(b.width)}" height="${Math.ceil(b.height)}" viewBox="${b.x} ${b.y} ${b.width} ${b.height}" role="img" aria-labelledby="diagram-title diagram-description"><title id="diagram-title">${escapeXml(scene.document.title)}</title><desc id="diagram-description">${escapeXml(scene.document.description ?? '')}</desc>${font}<g font-family="${escapeXml(scene.document.presentation.designSystem?.canvas?.fontFamily ?? 'IBM Plex Sans, Arial, sans-serif')}"><rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" fill="${scene.document.presentation.designSystem?.canvas?.background ?? themes[theme].background}"/>${frameMarkup(scene)}${scene.groups.map((g) => groupMarkup(g, theme)).join('')}${scene.edges.map((e) => edgeMarkup(e, theme)).join('')}${scene.nodes.map((n) => `<g transform="translate(${n.x} ${n.y})">${nodeMarkup(n, theme)}</g>`).join('')}${scene.edges.map((e) => edgeLabelMarkup(e, theme)).join('')}</g></svg>`;
169
+ }
170
+ /** Adapters can emit native editable shapes from Scene, without parsing SVG. */
171
+ export interface Exporter<T> {
172
+ id: string;
173
+ mimeType: string;
174
+ extension: string;
175
+ export(scene: Scene): Promise<T> | T;
176
+ }
177
+ export const svgExporter: Exporter<string> = {
178
+ id: 'svg',
179
+ mimeType: 'image/svg+xml',
180
+ extension: '.svg',
181
+ export: renderSvg,
182
+ };
@@ -0,0 +1,29 @@
1
+ import type { Diagram, DiagramNode, DiagramEdge, DiagramGroup } from './document';
2
+ import type { ElementStyle } from './styles';
3
+
4
+ /** Shared by layout and editing controls; renderer consumes the resolved scene style. */
5
+ export function resolveNodeStyle(doc: Diagram, node: DiagramNode): ElementStyle {
6
+ const system = doc.presentation.designSystem;
7
+ const role =
8
+ node.role ??
9
+ (node.emphasis === 'primary' ? 'emphasis' : node.emphasis === 'muted' ? 'quiet' : '');
10
+ return {
11
+ ...system?.node,
12
+ ...system?.roles?.[role],
13
+ ...node.style,
14
+ ...doc.presentation.nodes[node.id]?.style,
15
+ };
16
+ }
17
+ export function resolveEdgeStyle(doc: Diagram, edge: DiagramEdge): ElementStyle {
18
+ const system = doc.presentation.designSystem;
19
+ return {
20
+ ...(system?.canvas ? { fill: system.canvas.background, text: system.canvas.secondary } : {}),
21
+ ...system?.edge,
22
+ ...system?.roles?.[edge.role ?? ''],
23
+ ...edge.appearance,
24
+ };
25
+ }
26
+ export function resolveGroupStyle(doc: Diagram, group: DiagramGroup): ElementStyle {
27
+ const system = doc.presentation.designSystem;
28
+ return { ...system?.group, ...system?.roles?.[group.role ?? ''], ...group.style };
29
+ }