@bpmn-nova/studio 0.3.0-preview → 0.3.2-preview

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 (63) hide show
  1. package/README.md +245 -20
  2. package/dist/canvas.js +7 -4
  3. package/dist/context-menu.js +2 -2
  4. package/dist/controller.js +84 -6
  5. package/dist/index.d.ts +138 -38
  6. package/dist/index.js +12 -11
  7. package/dist/interactions.js +1 -1
  8. package/dist/modules/bpmn-model/index.d.ts +11 -0
  9. package/dist/modules/bpmn-model/index.js +703 -0
  10. package/dist/modules/core/containment.js +590 -0
  11. package/dist/modules/core/gateway.js +72 -0
  12. package/dist/modules/core/history.js +32 -0
  13. package/dist/modules/core/index.d.ts +268 -0
  14. package/dist/modules/core/index.js +7 -0
  15. package/dist/modules/core/layout.js +644 -0
  16. package/dist/modules/core/model.js +287 -0
  17. package/dist/modules/core/runtime-transition-route.js +360 -0
  18. package/dist/modules/core/scope.js +99 -0
  19. package/dist/modules/designer/index.d.ts +79 -0
  20. package/dist/modules/designer/index.js +607 -0
  21. package/dist/modules/engine-activiti/index.d.ts +19 -0
  22. package/dist/modules/engine-activiti/index.js +160 -0
  23. package/dist/modules/engine-flowable/index.d.ts +19 -0
  24. package/dist/modules/engine-flowable/index.js +160 -0
  25. package/dist/modules/export-svg/index.d.ts +112 -0
  26. package/dist/modules/export-svg/index.js +2 -0
  27. package/dist/modules/export-svg/preview.js +327 -0
  28. package/dist/modules/export-svg/render.js +718 -0
  29. package/dist/modules/icons/index.d.ts +24 -0
  30. package/dist/modules/icons/index.js +264 -0
  31. package/dist/modules/palette/index.d.ts +74 -0
  32. package/dist/modules/palette/index.js +99 -0
  33. package/dist/modules/palette/panel.js +99 -0
  34. package/dist/modules/properties/index.d.ts +20 -0
  35. package/dist/modules/properties/index.js +19 -0
  36. package/dist/modules/properties-activiti/index.d.ts +3 -0
  37. package/dist/modules/properties-activiti/index.js +97 -0
  38. package/dist/modules/properties-bpmn/index.d.ts +3 -0
  39. package/dist/modules/properties-bpmn/index.js +518 -0
  40. package/dist/modules/properties-core/index.d.ts +124 -0
  41. package/dist/modules/properties-core/index.js +312 -0
  42. package/dist/modules/properties-flowable/index.d.ts +3 -0
  43. package/dist/modules/properties-flowable/index.js +114 -0
  44. package/dist/modules/properties-renderer/index.d.ts +25 -0
  45. package/dist/modules/properties-renderer/index.js +491 -0
  46. package/dist/modules/renderer-svg/index.d.ts +118 -0
  47. package/dist/modules/renderer-svg/index.js +1460 -0
  48. package/dist/modules/runtime/index.d.ts +169 -0
  49. package/dist/modules/runtime/index.js +535 -0
  50. package/dist/modules/theme/index.d.ts +95 -0
  51. package/dist/modules/theme/index.js +368 -0
  52. package/dist/modules/viewer/index.d.ts +265 -0
  53. package/dist/modules/viewer/index.js +1011 -0
  54. package/dist/modules/viewer/runtime-content.js +123 -0
  55. package/dist/modules/viewer/runtime-details-motion.js +228 -0
  56. package/dist/modules/viewer/runtime-trace.js +574 -0
  57. package/dist/modules/viewer/timeline.js +276 -0
  58. package/dist/selection-layout.js +1 -1
  59. package/dist/shell.js +210 -26
  60. package/dist/styles.css +116 -7
  61. package/llms-full.txt +3082 -0
  62. package/llms.txt +225 -0
  63. package/package.json +39 -16
@@ -0,0 +1,718 @@
1
+ import { NODE_DEFINITIONS, edgeWaypoints, roundedPath, routeRuntimeTransition, smoothPath } from '../core/index.js';
2
+ import { createDefaultIconRegistry, resolveNodeVisual } from '../icons/index.js';
3
+ import { createRuntimePresentation } from '../runtime/index.js';
4
+ import { createRuntimeAppearance } from '../theme/index.js';
5
+
6
+ const SVG_NS = 'http://www.w3.org/2000/svg';
7
+ const DEFAULT_SIZE = Object.freeze({ width: 960, height: 540 });
8
+ const DEFAULT_PADDING = 32;
9
+ const textMeasureContexts = new WeakMap();
10
+ const graphemeSegmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
11
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
12
+ : null;
13
+
14
+ function svgElement(document, tag, attributes = {}, text = null) {
15
+ const node = document.createElementNS(SVG_NS, tag);
16
+ for (const [name, value] of Object.entries(attributes)) {
17
+ if (value !== undefined && value !== null && value !== '') node.setAttribute(name, String(value));
18
+ }
19
+ if (text !== null && text !== undefined) node.textContent = String(text);
20
+ return node;
21
+ }
22
+
23
+ function graphemes(value) {
24
+ const source = String(value || '');
25
+ if (!source) return [];
26
+ if (graphemeSegmenter) return [...graphemeSegmenter.segment(source)].map((part) => part.segment);
27
+ return Array.from(source);
28
+ }
29
+
30
+ function fallbackTextWidth(value, fontSize = 13, fontWeight = 400) {
31
+ const weightFactor = Number(fontWeight) >= 600 ? 1.025 : 1;
32
+ const width = graphemes(value).reduce((total, character) => {
33
+ if (/^\s$/u.test(character)) return total + fontSize * 0.33;
34
+ if (/^[\u0000-\u00ff]$/u.test(character)) {
35
+ if (/^[ilI1|.,'`:;!]$/u.test(character)) return total + fontSize * 0.3;
36
+ if (/^[mwMW@#%&]$/u.test(character)) return total + fontSize * 0.82;
37
+ return total + fontSize * 0.56;
38
+ }
39
+ if (/^[\p{P}\p{S}]$/u.test(character)) return total + fontSize * 0.72;
40
+ return total + fontSize;
41
+ }, 0);
42
+ return width * weightFactor;
43
+ }
44
+
45
+ function textMeasureContext(document) {
46
+ if (!document || typeof document !== 'object') return null;
47
+ if (textMeasureContexts.has(document)) return textMeasureContexts.get(document);
48
+ let context = null;
49
+ try {
50
+ context = document.createElement?.('canvas')?.getContext?.('2d') || null;
51
+ } catch {
52
+ context = null;
53
+ }
54
+ textMeasureContexts.set(document, context);
55
+ return context;
56
+ }
57
+
58
+ function measureText(document, value, { fontSize = 13, fontWeight = 400, fontFamily = 'sans-serif' } = {}) {
59
+ const source = String(value || '');
60
+ const context = textMeasureContext(document);
61
+ if (!context?.measureText) return fallbackTextWidth(source, fontSize, fontWeight);
62
+ try {
63
+ context.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
64
+ const measured = context.measureText(source).width;
65
+ if (Number.isFinite(measured)) return measured;
66
+ } catch {
67
+ // Fall back to deterministic Unicode-aware estimates for non-browser documents.
68
+ }
69
+ return fallbackTextWidth(source, fontSize, fontWeight);
70
+ }
71
+
72
+ function fitText(document, value, maxWidth, typography = {}) {
73
+ const source = String(value || '');
74
+ const width = Math.max(0, finite(maxWidth, 0));
75
+ if (!source || width <= 0) return '';
76
+ if (measureText(document, source, typography) <= width) return source;
77
+
78
+ const ellipsis = '\u2026';
79
+ const ellipsisWidth = measureText(document, ellipsis, typography);
80
+ if (ellipsisWidth > width) return '';
81
+ const characters = graphemes(source);
82
+ let low = 0;
83
+ let high = characters.length;
84
+ while (low < high) {
85
+ const middle = Math.ceil((low + high) / 2);
86
+ const candidate = `${characters.slice(0, middle).join('')}${ellipsis}`;
87
+ if (measureText(document, candidate, typography) <= width) low = middle;
88
+ else high = middle - 1;
89
+ }
90
+ return `${characters.slice(0, low).join('')}${ellipsis}`;
91
+ }
92
+
93
+ function safeSvgId(value) {
94
+ const source = String(value || 'node');
95
+ const normalized = source.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'node';
96
+ let hash = 2166136261;
97
+ for (const character of source) {
98
+ hash ^= character.codePointAt(0);
99
+ hash = Math.imul(hash, 16777619);
100
+ }
101
+ return `${normalized}-${(hash >>> 0).toString(36)}`;
102
+ }
103
+
104
+ function appendClipPath(document, parent, id, bounds) {
105
+ const clipPath = svgElement(document, 'clipPath', { id });
106
+ clipPath.appendChild(svgElement(document, 'rect', {
107
+ x: bounds.x,
108
+ y: bounds.y,
109
+ width: Math.max(0, bounds.width),
110
+ height: Math.max(0, bounds.height),
111
+ }));
112
+ parent.appendChild(clipPath);
113
+ return `url(#${id})`;
114
+ }
115
+
116
+ function resolvedDocument(context) {
117
+ const document = context?.document || context?.root?.ownerDocument || globalThis.document;
118
+ if (!document?.createElementNS) throw new Error('SVG export requires a DOM Document.');
119
+ return document;
120
+ }
121
+
122
+ function fallbackTheme(resolvedTheme = 'light') {
123
+ const dark = resolvedTheme === 'dark';
124
+ const colors = dark
125
+ ? { canvas: '#10131a', surface: '#171b24', surfaceRaised: '#202633', surfaceSubtle: '#141821', surfaceMuted: '#252b38', text: '#edf1f7', textSecondary: '#bdc6d5', textMuted: '#8f9aad', border: '#303847', borderStrong: '#465164', divider: '#29313f', primary: '#918cff', primarySoft: '#2a2852', edge: '#657188' }
126
+ : { canvas: '#f8f9fb', surface: '#ffffff', surfaceRaised: '#ffffff', surfaceSubtle: '#f7f8fb', surfaceMuted: '#f0f2f7', text: '#1f2430', textSecondary: '#596477', textMuted: '#7d8798', border: '#e1e5ec', borderStrong: '#cbd2dd', divider: '#e9ecf2', primary: '#635bff', primarySoft: '#f0efff', edge: '#9aa8bb' };
127
+ const tones = dark
128
+ ? {
129
+ primary: { foreground: '#b6b3ff', background: '#2a2852', border: '#555097', strong: '#918cff' },
130
+ success: { foreground: '#71d6ab', background: '#17382d', border: '#2b6550', strong: '#42bd88' },
131
+ danger: { foreground: '#ff9aa3', background: '#42232a', border: '#7a3b45', strong: '#f06c78' },
132
+ warning: { foreground: '#edbd6d', background: '#3c301d', border: '#71582b', strong: '#d99d3d' },
133
+ info: { foreground: '#98b8ff', background: '#202f50', border: '#3a568d', strong: '#7298ee' },
134
+ neutral: { foreground: '#aeb8c8', background: '#252b36', border: '#414a59', strong: '#8f9aad' },
135
+ }
136
+ : {
137
+ primary: { foreground: '#5149e8', background: '#f0efff', border: '#c9c6ff', strong: '#635bff' },
138
+ success: { foreground: '#19875b', background: '#eaf8f1', border: '#bde6d3', strong: '#22a06b' },
139
+ danger: { foreground: '#c93f4b', background: '#fff0f1', border: '#f2b9bf', strong: '#df4b55' },
140
+ warning: { foreground: '#a36b13', background: '#fff6e8', border: '#ecd2a4', strong: '#c48218' },
141
+ info: { foreground: '#3665c5', background: '#eef3ff', border: '#c7d5f5', strong: '#527bd4' },
142
+ neutral: { foreground: '#667186', background: '#f1f3f6', border: '#dce1e8', strong: '#8791a3' },
143
+ };
144
+ return { resolvedTheme, colors, tones, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif' };
145
+ }
146
+
147
+ function themeSnapshot(context, options) {
148
+ const requested = options.theme || 'current';
149
+ const snapshot = context.themeSnapshot
150
+ || context.themeController?.getSnapshot?.(requested)
151
+ || fallbackTheme(requested === 'dark' ? 'dark' : 'light');
152
+ const fallback = fallbackTheme(snapshot.resolvedTheme);
153
+ return {
154
+ ...fallback,
155
+ ...snapshot,
156
+ colors: { ...fallback.colors, ...(snapshot.colors || {}) },
157
+ tones: { ...fallback.tones, ...(snapshot.tones || {}) },
158
+ };
159
+ }
160
+
161
+ function tone(theme, name) {
162
+ return theme.tones?.[name] || theme.tones?.neutral || fallbackTheme(theme.resolvedTheme).tones.neutral;
163
+ }
164
+
165
+ function finite(value, fallback = 0) {
166
+ const number = Number(value);
167
+ return Number.isFinite(number) ? number : fallback;
168
+ }
169
+
170
+ function formatTime(value) {
171
+ return value ? String(value).replace('T', ' ') : '';
172
+ }
173
+
174
+ function formatSize(value) {
175
+ const bytes = Number(value);
176
+ if (!Number.isFinite(bytes) || bytes < 0) return '';
177
+ if (bytes < 1024) return `${bytes} B`;
178
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`;
179
+ return `${(bytes / (1024 * 1024)).toFixed(bytes < 1024 * 10240 ? 1 : 0)} MB`;
180
+ }
181
+
182
+ function routePath(points, style = 'rounded', radius = 14) {
183
+ if (!points.length) return '';
184
+ if (style === 'straight') return points.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.y}`).join(' ');
185
+ if (style === 'smooth') return smoothPath(points, Math.max(24, radius * 1.7));
186
+ return roundedPath(points, radius);
187
+ }
188
+
189
+ function longestSegmentCenter(points) {
190
+ let candidate = null;
191
+ for (let index = 0; index < points.length - 1; index += 1) {
192
+ const start = points[index];
193
+ const end = points[index + 1];
194
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
195
+ if (!candidate || length > candidate.length) candidate = { length, x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
196
+ }
197
+ return candidate || points[0] || { x: 0, y: 0 };
198
+ }
199
+
200
+ function wrapText(value, maxWidth, fontSize = 13) {
201
+ const source = String(value || '');
202
+ if (!source) return [];
203
+ const average = fontSize * 0.78;
204
+ const limit = Math.max(1, Math.floor(maxWidth / average));
205
+ const lines = [];
206
+ for (const paragraph of source.split(/\r?\n/)) {
207
+ if (!paragraph) { lines.push(''); continue; }
208
+ let current = '';
209
+ for (const character of Array.from(paragraph)) {
210
+ if (current && Array.from(current).length >= limit) {
211
+ lines.push(current);
212
+ current = character;
213
+ } else current += character;
214
+ }
215
+ if (current) lines.push(current);
216
+ }
217
+ return lines;
218
+ }
219
+
220
+ function appendTextLines(document, parent, lines, { x, y, lineHeight = 18, attributes = {} } = {}) {
221
+ const text = svgElement(document, 'text', { x, y, ...attributes });
222
+ lines.forEach((line, index) => text.appendChild(svgElement(document, 'tspan', { x, dy: index ? lineHeight : 0 }, line)));
223
+ parent.appendChild(text);
224
+ return text;
225
+ }
226
+
227
+ function appendIcon(document, parent, descriptor, bounds, color) {
228
+ if (!descriptor?.paths?.length) return;
229
+ const viewBox = String(descriptor.viewBox || '0 0 24 24').split(/\s+/).map(Number);
230
+ const sourceWidth = viewBox[2] || 24;
231
+ const sourceHeight = viewBox[3] || 24;
232
+ const scale = Math.min(bounds.width / sourceWidth, bounds.height / sourceHeight);
233
+ const tx = bounds.x + (bounds.width - sourceWidth * scale) / 2 - (viewBox[0] || 0) * scale;
234
+ const ty = bounds.y + (bounds.height - sourceHeight * scale) / 2 - (viewBox[1] || 0) * scale;
235
+ const group = svgElement(document, 'g', { transform: `translate(${tx} ${ty}) scale(${scale})`, color, fill: descriptor.fill || 'none', stroke: color });
236
+ descriptor.paths.forEach((path) => {
237
+ const attributes = {};
238
+ for (const [key, value] of Object.entries(path)) attributes[key === 'dash' ? 'stroke-dasharray' : key] = value === 'currentColor' ? color : value;
239
+ group.appendChild(svgElement(document, 'path', attributes));
240
+ });
241
+ parent.appendChild(group);
242
+ }
243
+
244
+ function includeBounds(bounds, rect) {
245
+ if (!rect) return;
246
+ bounds.left = Math.min(bounds.left, rect.x);
247
+ bounds.top = Math.min(bounds.top, rect.y);
248
+ bounds.right = Math.max(bounds.right, rect.x + rect.width);
249
+ bounds.bottom = Math.max(bounds.bottom, rect.y + rect.height);
250
+ }
251
+
252
+ function createRoot(document, { minX, minY, width, height, title, theme, transparentBackground }) {
253
+ const svg = svgElement(document, 'svg', {
254
+ xmlns: SVG_NS,
255
+ width,
256
+ height,
257
+ viewBox: `${minX} ${minY} ${width} ${height}`,
258
+ role: 'img',
259
+ 'aria-label': title,
260
+ 'data-nova-svg-export': 'true',
261
+ 'data-nova-theme': theme.resolvedTheme,
262
+ });
263
+ svg.appendChild(svgElement(document, 'title', {}, title));
264
+ if (!transparentBackground) svg.appendChild(svgElement(document, 'rect', { x: minX, y: minY, width, height, fill: theme.colors.canvas }));
265
+ return svg;
266
+ }
267
+
268
+ function serializeArtifact(document, svg, { filename, width, height, viewBox, warnings }) {
269
+ const serializer = new (document.defaultView?.XMLSerializer || globalThis.XMLSerializer)();
270
+ const source = `<?xml version="1.0" encoding="UTF-8"?>\n${serializer.serializeToString(svg)}`;
271
+ const BlobType = document.defaultView?.Blob || globalThis.Blob;
272
+ const blob = new BlobType([source], { type: 'image/svg+xml;charset=utf-8' });
273
+ return Object.freeze({ svg: source, blob, filename, mimeType: 'image/svg+xml', width, height, viewBox, warnings: Object.freeze([...warnings]) });
274
+ }
275
+
276
+ export function sanitizeSvgFilename(value, fallback = 'bpmn-nova') {
277
+ const clean = String(value || fallback)
278
+ .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
279
+ .replace(/\s+/g, ' ')
280
+ .replace(/[. ]+$/g, '')
281
+ .trim()
282
+ .slice(0, 120) || fallback;
283
+ return clean.toLowerCase().endsWith('.svg') ? clean : `${clean}.svg`;
284
+ }
285
+
286
+ function addDefinitions(document, svg, theme) {
287
+ const defs = svgElement(document, 'defs');
288
+ const shadow = svgElement(document, 'filter', { id: 'nova-export-shadow', x: '-20%', y: '-30%', width: '140%', height: '160%' });
289
+ shadow.appendChild(svgElement(document, 'feDropShadow', { dx: 0, dy: 2, stdDeviation: 2, 'flood-color': theme.resolvedTheme === 'dark' ? '#000000' : '#1f2841', 'flood-opacity': theme.resolvedTheme === 'dark' ? 0.34 : 0.12 }));
290
+ defs.appendChild(shadow);
291
+ const markers = new Map([['edge', theme.colors.edge]]);
292
+ for (const name of Object.keys(theme.tones || {})) {
293
+ if (/^[a-z][a-z0-9-]{0,47}$/.test(name)) markers.set(name, tone(theme, name).strong);
294
+ }
295
+ for (const [id, color] of markers) {
296
+ const marker = svgElement(document, 'marker', { id: `nova-arrow-${id}`, viewBox: '0 0 10 10', refX: 8.5, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
297
+ marker.appendChild(svgElement(document, 'path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: color }));
298
+ defs.appendChild(marker);
299
+ }
300
+ svg.appendChild(defs);
301
+ }
302
+
303
+ function nodeToneName(node) {
304
+ return ({ userTask: 'user-task', serviceTask: 'service-task', scriptTask: 'script-task', businessRuleTask: 'rule-task', sendTask: 'message-task', receiveTask: 'message-task', manualTask: 'manual-task' })[node.type] || 'primary';
305
+ }
306
+
307
+ function renderDefaultNode({ document, group, node, definition, visual, presentation, theme, iconRegistry }) {
308
+ const colors = theme.colors;
309
+ const statusTone = tone(theme, presentation?.status === 'rejected' ? 'danger' : presentation?.status === 'active' ? 'primary' : presentation?.status === 'completed' ? 'success' : 'neutral');
310
+ const typeTone = tone(theme, nodeToneName(node));
311
+ const icon = iconRegistry.resolve(visual.iconId, null);
312
+ const { x, y, width, height } = node;
313
+ if (['event', 'boundary'].includes(definition.kind)) {
314
+ const radius = Math.min(width, height) / 2 - 2;
315
+ group.appendChild(svgElement(document, 'circle', { cx: x + width / 2, cy: y + height / 2, r: radius, fill: colors.surface, stroke: presentation?.status && presentation.status !== 'idle' ? statusTone.strong : colors.borderStrong, 'stroke-width': definition.eventStage === 'end' ? 3 : 2, 'stroke-dasharray': definition.kind === 'boundary' && node.properties?.cancelActivity === false ? '5 3' : null }));
316
+ if (definition.eventStage === 'intermediate' || definition.kind === 'boundary') group.appendChild(svgElement(document, 'circle', { cx: x + width / 2, cy: y + height / 2, r: radius - 5, fill: 'none', stroke: colors.borderStrong, 'stroke-width': 1.4 }));
317
+ appendIcon(document, group, icon, { x: x + width * 0.3, y: y + height * 0.3, width: width * 0.4, height: height * 0.4 }, colors.textSecondary);
318
+ appendTextLines(document, group, wrapText(node.name || definition.label, 150, 12), { x: x + width / 2, y: y + height + 20, lineHeight: 15, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
319
+ return;
320
+ }
321
+ if (definition.kind === 'gateway') {
322
+ const points = `${x + width / 2},${y + 2} ${x + width - 2},${y + height / 2} ${x + width / 2},${y + height - 2} ${x + 2},${y + height / 2}`;
323
+ group.appendChild(svgElement(document, 'polygon', { points, fill: colors.surface, stroke: presentation?.status && presentation.status !== 'idle' ? statusTone.strong : colors.borderStrong, 'stroke-width': 2, filter: 'url(#nova-export-shadow)' }));
324
+ appendIcon(document, group, icon, { x: x + width * 0.28, y: y + height * 0.28, width: width * 0.44, height: height * 0.44 }, colors.textSecondary);
325
+ appendTextLines(document, group, wrapText(node.name || definition.label, 160, 12), { x: x + width / 2, y: y + height + 20, lineHeight: 15, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
326
+ return;
327
+ }
328
+ if (definition.kind === 'participant' || definition.kind === 'lane') {
329
+ group.appendChild(svgElement(document, 'rect', { x, y, width, height, rx: 4, fill: colors.surfaceSubtle, stroke: colors.borderStrong, 'stroke-width': 1.3 }));
330
+ const rail = Math.min(42, Math.max(30, width * 0.08));
331
+ group.appendChild(svgElement(document, 'line', { x1: x + rail, y1: y, x2: x + rail, y2: y + height, stroke: colors.borderStrong, 'stroke-width': 1.2 }));
332
+ const title = svgElement(document, 'text', { x: x + rail / 2, y: y + height / 2, transform: `rotate(-90 ${x + rail / 2} ${y + height / 2})`, 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-weight': 600, 'font-family': theme.fontFamily }, node.name || definition.label);
333
+ group.appendChild(title);
334
+ return;
335
+ }
336
+ if (definition.kind === 'group') {
337
+ group.appendChild(svgElement(document, 'rect', { x, y, width, height, rx: 10, fill: 'none', stroke: colors.borderStrong, 'stroke-width': 1.2, 'stroke-dasharray': '7 5' }));
338
+ group.appendChild(svgElement(document, 'text', { x: x + 12, y: y + 22, fill: colors.textSecondary, 'font-size': 12, 'font-weight': 600, 'font-family': theme.fontFamily }, node.properties?.categoryValue || node.name || '分组'));
339
+ return;
340
+ }
341
+ if (definition.kind === 'annotation') {
342
+ group.appendChild(svgElement(document, 'path', { d: `M ${x + 10} ${y} H ${x} V ${y + height} H ${x + 10}`, fill: 'none', stroke: colors.borderStrong, 'stroke-width': 1.5 }));
343
+ appendTextLines(document, group, wrapText(node.properties?.text || node.name || '说明', width - 24, 12), { x: x + 16, y: y + 20, lineHeight: 17, attributes: { fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
344
+ return;
345
+ }
346
+ if (definition.kind === 'data' || definition.kind === 'dataStore') {
347
+ if (definition.kind === 'data') group.appendChild(svgElement(document, 'path', { d: `M ${x + 8} ${y + 2} H ${x + width - 18} L ${x + width - 2} ${y + 18} V ${y + height - 2} H ${x + 8} Z`, fill: colors.surface, stroke: colors.borderStrong, 'stroke-width': 1.5 }));
348
+ else {
349
+ group.appendChild(svgElement(document, 'rect', { x: x + 4, y: y + 10, width: width - 8, height: height - 20, fill: colors.surface, stroke: colors.borderStrong, 'stroke-width': 1.5 }));
350
+ group.appendChild(svgElement(document, 'ellipse', { cx: x + width / 2, cy: y + 10, rx: width / 2 - 4, ry: 9, fill: colors.surface, stroke: colors.borderStrong, 'stroke-width': 1.5 }));
351
+ group.appendChild(svgElement(document, 'ellipse', { cx: x + width / 2, cy: y + height - 10, rx: width / 2 - 4, ry: 9, fill: 'none', stroke: colors.borderStrong, 'stroke-width': 1.5 }));
352
+ }
353
+ appendIcon(document, group, icon, { x: x + width * 0.33, y: y + height * 0.31, width: width * 0.34, height: height * 0.34 }, colors.textSecondary);
354
+ appendTextLines(document, group, wrapText(node.name || definition.label, 150, 12), { x: x + width / 2, y: y + height + 20, lineHeight: 15, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
355
+ return;
356
+ }
357
+
358
+ const iconSize = Math.min(36, height - 24);
359
+ const iconX = x + 14;
360
+ const iconY = y + (height - iconSize) / 2;
361
+ const copyX = iconX + iconSize + 12;
362
+ const title = node.name || definition.label;
363
+ const subtitle = presentation?.actionSummary || presentation?.summary || (node.properties?.assignee || node.properties?.candidateGroups || node.properties?.candidateUsers || '');
364
+ const hasStatus = Boolean(presentation?.status && presentation.status !== 'idle');
365
+ const label = hasStatus ? String(presentation.statusLabel || presentation.status) : '';
366
+ const statusTypography = { fontSize: 10.5, fontWeight: 650, fontFamily: theme.fontFamily };
367
+ const labelWidth = hasStatus ? Math.max(38, Math.min(88, Math.ceil(measureText(document, label, statusTypography) + 16))) : 0;
368
+ const statusX = x + width - labelWidth - 10;
369
+ const titleMaxWidth = Math.max(0, (hasStatus ? statusX - 8 : x + width - 12) - copyX);
370
+ const subtitleMaxWidth = Math.max(0, x + width - 12 - copyX);
371
+ const titleTypography = { fontSize: 14, fontWeight: 650, fontFamily: theme.fontFamily };
372
+ const subtitleTypography = { fontSize: 11.5, fontWeight: 400, fontFamily: theme.fontFamily };
373
+ const fittedTitle = fitText(document, title, titleMaxWidth, titleTypography);
374
+ const fittedSubtitle = fitText(document, subtitle, subtitleMaxWidth, subtitleTypography);
375
+ const tooltip = [title, subtitle].filter(Boolean).join('\n');
376
+ if (tooltip && (fittedTitle !== title || fittedSubtitle !== String(subtitle || ''))) group.appendChild(svgElement(document, 'title', {}, tooltip));
377
+
378
+ const clipId = safeSvgId(node.id);
379
+ const titleClip = appendClipPath(document, group, `nova-node-${clipId}-title-clip`, { x: copyX, y: y + 9, width: titleMaxWidth, height: 23 });
380
+ const subtitleClip = appendClipPath(document, group, `nova-node-${clipId}-subtitle-clip`, { x: copyX, y: y + 33, width: subtitleMaxWidth, height: 21 });
381
+ group.appendChild(svgElement(document, 'rect', { x, y, width, height, rx: 12, fill: colors.surface, stroke: hasStatus ? statusTone.border : colors.border, 'stroke-width': presentation?.status === 'active' ? 2 : 1.2, filter: 'url(#nova-export-shadow)' }));
382
+ group.appendChild(svgElement(document, 'rect', { x, y: y + 12, width: 3, height: Math.max(12, height - 24), rx: 1.5, fill: typeTone.strong }));
383
+ group.appendChild(svgElement(document, 'rect', { x: iconX, y: iconY, width: iconSize, height: iconSize, rx: 9, fill: typeTone.background }));
384
+ appendIcon(document, group, icon, { x: iconX + 8, y: iconY + 8, width: iconSize - 16, height: iconSize - 16 }, typeTone.foreground);
385
+ if (fittedTitle) group.appendChild(svgElement(document, 'text', { x: copyX, y: y + 27, fill: colors.text, 'font-size': titleTypography.fontSize, 'font-weight': titleTypography.fontWeight, 'font-family': titleTypography.fontFamily, 'clip-path': titleClip }, fittedTitle));
386
+ if (fittedSubtitle) group.appendChild(svgElement(document, 'text', { x: copyX, y: y + 49, fill: colors.textSecondary, 'font-size': subtitleTypography.fontSize, 'font-family': subtitleTypography.fontFamily, 'clip-path': subtitleClip }, fittedSubtitle));
387
+ if (hasStatus) {
388
+ const fittedLabel = fitText(document, label, labelWidth - 16, statusTypography);
389
+ group.appendChild(svgElement(document, 'rect', { x: x + width - labelWidth - 10, y: y + 9, width: labelWidth, height: 22, rx: 11, fill: statusTone.background }));
390
+ group.appendChild(svgElement(document, 'text', { x: x + width - labelWidth / 2 - 10, y: y + 24, 'text-anchor': 'middle', fill: statusTone.foreground, 'font-size': statusTypography.fontSize, 'font-weight': statusTypography.fontWeight, 'font-family': statusTypography.fontFamily }, fittedLabel));
391
+ }
392
+ }
393
+
394
+ export async function exportDiagramSvg(context = {}, options = {}) {
395
+ options.signal?.throwIfAborted?.();
396
+ const document = resolvedDocument(context);
397
+ const model = context.model || { id: 'Process', name: 'BPMN Nova', nodes: [], edges: [], settings: {} };
398
+ const theme = themeSnapshot(context, options);
399
+ const appearance = context.runtimeAppearance?.resolveStatus ? context.runtimeAppearance : createRuntimeAppearance(context.runtimeAppearance || {});
400
+ const presentation = context.runtimePresentation || createRuntimePresentation({ model, runtime: context.runtime || null, appearance });
401
+ const iconRegistry = context.iconRegistry || createDefaultIconRegistry();
402
+ const warnings = [];
403
+ const bounds = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity };
404
+ for (const node of model.nodes || []) includeBounds(bounds, { x: node.x - 4, y: node.y - 4, width: node.width + 8, height: node.height + 42 });
405
+ for (const edge of model.edges || []) for (const point of edgeWaypoints(model, edge)) includeBounds(bounds, { x: point.x - 4, y: point.y - 4, width: 8, height: 8 });
406
+ if (!Number.isFinite(bounds.left)) Object.assign(bounds, { left: 0, top: 0, right: DEFAULT_SIZE.width, bottom: DEFAULT_SIZE.height });
407
+
408
+ const runtimeRoutes = [];
409
+ if (context.mode === 'instance' || context.runtime) {
410
+ for (const transition of presentation.getTransitions?.() || []) {
411
+ if (!['reject', 'return'].includes(transition.type) || !transition.visible) continue;
412
+ const route = routeRuntimeTransition(model, transition, { occupiedRoutes: runtimeRoutes });
413
+ if (!route) continue;
414
+ runtimeRoutes.push(route.points);
415
+ route.points.forEach((point) => includeBounds(bounds, { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }));
416
+ includeBounds(bounds, { x: route.labelPoint.x - 100, y: route.labelPoint.y - 20, width: 200, height: 40 });
417
+ }
418
+ }
419
+ const padding = Math.max(0, finite(options.padding, DEFAULT_PADDING));
420
+ const minX = Math.floor(bounds.left - padding);
421
+ const minY = Math.floor(bounds.top - padding);
422
+ const width = Math.max(1, Math.ceil(bounds.right - bounds.left + padding * 2));
423
+ const height = Math.max(1, Math.ceil(bounds.bottom - bounds.top + padding * 2));
424
+ const title = options.title || model.name || 'BPMN Nova';
425
+ const svg = createRoot(document, { minX, minY, width, height, title, theme, transparentBackground: options.transparentBackground === true });
426
+ addDefinitions(document, svg, theme);
427
+
428
+ const edgeLayer = svgElement(document, 'g', { 'data-layer': 'edges' });
429
+ for (const edge of model.edges || []) {
430
+ const points = edgeWaypoints(model, edge);
431
+ const edgeState = presentation.getEdge?.(edge.id) || { status: 'idle' };
432
+ const completed = edgeState.status === 'completed';
433
+ const stroke = completed ? tone(theme, 'success').strong : theme.colors.edge;
434
+ const type = edge.type || 'sequenceFlow';
435
+ const path = svgElement(document, 'path', {
436
+ d: routePath(points, edge.routeStyle || model.settings?.edgeStyle || 'rounded', edge.cornerRadius ?? model.settings?.cornerRadius ?? 14),
437
+ fill: 'none', stroke, 'stroke-width': completed ? 2.3 : 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
438
+ 'stroke-dasharray': type === 'messageFlow' ? '8 7' : type === 'association' ? '2 6' : null,
439
+ 'marker-end': type === 'association' ? null : `url(#nova-arrow-${completed ? 'success' : 'edge'})`,
440
+ });
441
+ edgeLayer.appendChild(path);
442
+ if (edge.name) {
443
+ const position = longestSegmentCenter(points);
444
+ const labelTypography = { fontSize: 11, fontWeight: 600, fontFamily: theme.fontFamily };
445
+ const labelWidth = Math.max(44, Math.min(180, Math.ceil(measureText(document, edge.name, labelTypography) + 20)));
446
+ const fittedLabel = fitText(document, edge.name, labelWidth - 16, labelTypography);
447
+ const labelGroup = svgElement(document, 'g', { 'data-edge-label-id': edge.id || '' });
448
+ if (fittedLabel !== String(edge.name)) labelGroup.appendChild(svgElement(document, 'title', {}, edge.name));
449
+ labelGroup.appendChild(svgElement(document, 'rect', { x: position.x - labelWidth / 2, y: position.y - 13, width: labelWidth, height: 26, rx: 7, fill: theme.colors.surfaceRaised, stroke: theme.colors.border, 'stroke-width': 1 }));
450
+ labelGroup.appendChild(svgElement(document, 'text', { x: position.x, y: position.y + 4, 'text-anchor': 'middle', fill: theme.colors.textSecondary, 'font-size': labelTypography.fontSize, 'font-weight': labelTypography.fontWeight, 'font-family': labelTypography.fontFamily }, fittedLabel));
451
+ edgeLayer.appendChild(labelGroup);
452
+ }
453
+ }
454
+ svg.appendChild(edgeLayer);
455
+
456
+ if (context.mode === 'instance' || context.runtime) {
457
+ const transitionLayer = svgElement(document, 'g', { 'data-layer': 'runtime-transitions' });
458
+ const occupied = [];
459
+ for (const transition of presentation.getTransitions?.() || []) {
460
+ if (!['reject', 'return'].includes(transition.type) || !transition.visible) continue;
461
+ const route = routeRuntimeTransition(model, transition, { occupiedRoutes: occupied });
462
+ if (!route) continue;
463
+ occupied.push(route.points);
464
+ const toneName = appearance.resolveTransition?.(transition)?.tone || (transition.type === 'reject' ? 'danger' : 'warning');
465
+ const transitionTone = tone(theme, toneName);
466
+ const markerTone = Object.hasOwn(theme.tones || {}, toneName) && /^[a-z][a-z0-9-]{0,47}$/.test(toneName) ? toneName : 'neutral';
467
+ transitionLayer.appendChild(svgElement(document, 'path', { d: routePath(route.points, model.settings?.edgeStyle || 'rounded', model.settings?.cornerRadius || 14), fill: 'none', stroke: transitionTone.strong, 'stroke-width': 2.2, 'stroke-dasharray': '8 6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'marker-end': `url(#nova-arrow-${markerTone})`, opacity: transition.latest ? 1 : 0.42 }));
468
+ const label = transition.label || (transition.type === 'reject' ? '驳回' : '退回');
469
+ const labelTypography = { fontSize: 11, fontWeight: 650, fontFamily: theme.fontFamily };
470
+ const labelWidth = Math.max(68, Math.min(210, Math.ceil(measureText(document, label, labelTypography) + 20)));
471
+ const fittedLabel = fitText(document, label, labelWidth - 16, labelTypography);
472
+ const labelGroup = svgElement(document, 'g', { 'data-transition-label-id': transition.id || '' });
473
+ if (fittedLabel !== String(label)) labelGroup.appendChild(svgElement(document, 'title', {}, label));
474
+ labelGroup.appendChild(svgElement(document, 'rect', { x: route.labelPoint.x - labelWidth / 2, y: route.labelPoint.y - 14, width: labelWidth, height: 28, rx: 7, fill: theme.colors.surfaceRaised, stroke: transitionTone.border, 'stroke-width': 1 }));
475
+ labelGroup.appendChild(svgElement(document, 'text', { x: route.labelPoint.x, y: route.labelPoint.y + 4, 'text-anchor': 'middle', fill: transitionTone.foreground, 'font-size': labelTypography.fontSize, 'font-weight': labelTypography.fontWeight, 'font-family': labelTypography.fontFamily }, fittedLabel));
476
+ transitionLayer.appendChild(labelGroup);
477
+ }
478
+ svg.appendChild(transitionLayer);
479
+ }
480
+
481
+ const nodeLayer = svgElement(document, 'g', { 'data-layer': 'nodes' });
482
+ const order = { participant: 0, lane: 1, group: 2, container: 3, data: 4, dataStore: 4, annotation: 4, task: 5, gateway: 6, event: 7, boundary: 8 };
483
+ const nodes = [...(model.nodes || [])].sort((a, b) => (order[NODE_DEFINITIONS[a.type]?.kind] ?? 5) - (order[NODE_DEFINITIONS[b.type]?.kind] ?? 5));
484
+ for (const node of nodes) {
485
+ options.signal?.throwIfAborted?.();
486
+ const definition = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
487
+ const nodePresentation = presentation.getNode?.(node.id) || null;
488
+ const visual = resolveNodeVisual(node, { surface: context.mode === 'instance' ? 'viewer' : 'canvas', model: context.visualModel || model });
489
+ const group = svgElement(document, 'g', { 'data-node-id': node.id });
490
+ const customRenderer = context.nodeRenderers?.[node.type] || context.nodeRenderers?.[definition.kind];
491
+ let rendered = false;
492
+ if (typeof customRenderer === 'function') {
493
+ try {
494
+ rendered = customRenderer({ container: group, node, definition, runtimePresentation: nodePresentation, visual, model, themeSnapshot: theme, iconRegistry }) !== false;
495
+ if (!rendered) group.replaceChildren();
496
+ } catch (error) {
497
+ group.replaceChildren();
498
+ warnings.push({ code: 'custom-node-renderer-failed', message: `节点“${node.name || node.id}”的 SVG Renderer 执行失败,已使用标准视觉。`, elementId: node.id });
499
+ }
500
+ } else if (context.htmlNodeRenderers?.[node.type] || context.htmlNodeRenderers?.[definition.kind] || context.htmlNodeRenderer) {
501
+ warnings.push({ code: 'custom-node-renderer-fallback', message: `节点“${node.name || node.id}”没有 SVG Renderer,已使用标准视觉。`, elementId: node.id });
502
+ }
503
+ if (!rendered) renderDefaultNode({ document, group, node, definition, visual, presentation: nodePresentation, theme, iconRegistry });
504
+ nodeLayer.appendChild(group);
505
+ }
506
+ svg.appendChild(nodeLayer);
507
+ const filename = sanitizeSvgFilename(options.filename || `${model.name || model.id || 'process'}-${context.label || (context.mode === 'instance' ? '审批轨迹' : context.mode === 'viewer' ? '流程展示' : '流程设计')}`);
508
+ return serializeArtifact(document, svg, { filename, width, height, viewBox: { x: minX, y: minY, width, height }, warnings });
509
+ }
510
+
511
+ async function resolvedImageData(asset, action, context, warnings, signal) {
512
+ const cache = context.assetCache || new Map();
513
+ const key = `${asset.id}:export`;
514
+ const cached = cache.get(key);
515
+ if (typeof cached === 'string') return cached;
516
+ const promise = Promise.resolve(context.resolveAsset?.(asset, 'export', action)).then(async (url) => {
517
+ signal?.throwIfAborted?.();
518
+ if (!url) throw new Error('Resource resolver returned no URL.');
519
+ if (String(url).startsWith('data:')) {
520
+ if (!String(url).startsWith('data:image/')) throw new Error('Unexpected Data URL MIME type.');
521
+ return String(url);
522
+ }
523
+ const ownerWindow = context.document?.defaultView || globalThis;
524
+ const response = await ownerWindow.fetch(url, { signal });
525
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
526
+ const blob = await response.blob();
527
+ if (!blob.type.startsWith('image/')) throw new Error(`Unexpected MIME type: ${blob.type || 'unknown'}`);
528
+ return await new Promise((resolve, reject) => {
529
+ const reader = new ownerWindow.FileReader();
530
+ reader.addEventListener('load', () => resolve(String(reader.result)), { once: true });
531
+ reader.addEventListener('error', () => reject(reader.error || new Error('Unable to read image.')), { once: true });
532
+ reader.readAsDataURL(blob);
533
+ });
534
+ });
535
+ try {
536
+ const data = await promise;
537
+ cache.set(key, data);
538
+ return data;
539
+ } catch (error) {
540
+ cache.delete(key);
541
+ if (signal?.aborted) throw error;
542
+ warnings.push({ code: 'asset-unavailable', message: `图片“${asset.name}”无法嵌入 SVG,已显示占位内容。`, assetId: asset.id, elementId: action.elementId });
543
+ return null;
544
+ }
545
+ }
546
+
547
+ function timelineSequence(projection) {
548
+ const groupedIds = new Set((projection.groups || []).flatMap((group) => group.itemIds || []));
549
+ return [
550
+ ...(projection.items || []).filter((item) => !groupedIds.has(item.id)),
551
+ ...(projection.groups || []).map((group) => ({ ...group, kind: 'parallel-group' })),
552
+ ].sort((a, b) => (a.order - b.order) || String(a.id).localeCompare(String(b.id)));
553
+ }
554
+
555
+ function actionHeight(action) {
556
+ const paragraphLines = wrapText(action.plainText || action.content?.plainText || '', 700, 12).length;
557
+ const blockParagraphs = (action.content?.blocks || []).filter((block) => block.type === 'paragraph').reduce((sum, block) => sum + wrapText(block.text, 700, 12).length, 0);
558
+ const images = action.images?.length || (action.content?.blocks || []).filter((block) => block.type === 'image').length;
559
+ const files = action.files?.length || (action.content?.blocks || []).filter((block) => block.type === 'file').length;
560
+ const targets = action.targets?.length ? 18 : 0;
561
+ return 52 + targets + Math.max(1, blockParagraphs || paragraphLines) * 17 + (images ? 68 : 0) + files * 40;
562
+ }
563
+
564
+ async function renderTimelineAction({ document, parent, action, x, y, width, theme, appearance, context, warnings, signal }) {
565
+ const actionAppearance = appearance.resolveAction(action);
566
+ const actionTone = tone(theme, actionAppearance.tone);
567
+ const height = actionHeight(action);
568
+ parent.appendChild(svgElement(document, 'rect', { x, y, width, height, rx: 9, fill: theme.colors.surfaceSubtle }));
569
+ const labelWidth = Math.max(42, Math.min(86, Array.from(actionAppearance.label).length * 12 + 16));
570
+ parent.appendChild(svgElement(document, 'rect', { x: x + 12, y: y + 10, width: labelWidth, height: 22, rx: 11, fill: actionTone.background }));
571
+ parent.appendChild(svgElement(document, 'text', { x: x + 12 + labelWidth / 2, y: y + 25, 'text-anchor': 'middle', fill: actionTone.foreground, 'font-size': 11, 'font-weight': 650, 'font-family': theme.fontFamily }, actionAppearance.label));
572
+ parent.appendChild(svgElement(document, 'text', { x: x + 22 + labelWidth, y: y + 25, fill: theme.colors.textMuted, 'font-size': 11, 'font-family': theme.fontFamily }, [action.actor?.name, formatTime(action.occurredAt)].filter(Boolean).join(' · ')));
573
+ let cursorY = y + 48;
574
+ if (action.targets?.length) {
575
+ parent.appendChild(svgElement(document, 'text', { x: x + 12, y: cursorY, fill: theme.colors.textSecondary, 'font-size': 11, 'font-family': theme.fontFamily }, `目标:${action.targets.map((item) => item.name).join('、')}`));
576
+ cursorY += 18;
577
+ }
578
+ const paragraphBlocks = (action.content?.blocks || []).filter((block) => block.type === 'paragraph' && block.text);
579
+ const paragraphs = paragraphBlocks.length ? paragraphBlocks.map((block) => block.text) : [action.plainText || action.content?.plainText || ''].filter(Boolean);
580
+ for (const paragraph of paragraphs) {
581
+ const lines = wrapText(paragraph, width - 24, 12);
582
+ appendTextLines(document, parent, lines, { x: x + 12, y: cursorY, lineHeight: 17, attributes: { fill: theme.colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
583
+ cursorY += lines.length * 17 + 4;
584
+ }
585
+ const assets = new Map((action.content?.assets || []).map((asset) => [asset.id, asset]));
586
+ const imageBlocks = (action.content?.blocks || []).filter((block) => block.type === 'image');
587
+ const fileBlocks = (action.content?.blocks || []).filter((block) => block.type === 'file');
588
+ if (imageBlocks.length || fileBlocks.length) cursorY += 4;
589
+ let mediaX = x + 12;
590
+ for (const block of imageBlocks) {
591
+ const asset = assets.get(block.assetId);
592
+ if (!asset || !asset.mediaType?.startsWith('image/')) {
593
+ warnings.push({ code: 'asset-invalid', message: '审批图片引用无效,已显示占位内容。', assetId: block.assetId, elementId: action.elementId });
594
+ parent.appendChild(svgElement(document, 'rect', { x: mediaX, y: cursorY, width: 88, height: 58, rx: 7, fill: theme.colors.surfaceMuted, stroke: theme.colors.border }));
595
+ } else {
596
+ const data = await resolvedImageData(asset, action, context, warnings, signal);
597
+ if (data) parent.appendChild(svgElement(document, 'image', { x: mediaX, y: cursorY, width: 88, height: 58, preserveAspectRatio: 'xMidYMid slice', href: data }));
598
+ else parent.appendChild(svgElement(document, 'rect', { x: mediaX, y: cursorY, width: 88, height: 58, rx: 7, fill: theme.colors.surfaceMuted, stroke: theme.colors.border }));
599
+ }
600
+ mediaX += 98;
601
+ }
602
+ if (imageBlocks.length) cursorY += 68;
603
+ for (const block of fileBlocks) {
604
+ const asset = assets.get(block.assetId);
605
+ const fileY = cursorY;
606
+ parent.appendChild(svgElement(document, 'rect', { x: x + 12, y: fileY, width: width - 24, height: 34, rx: 7, fill: theme.colors.surfaceRaised, stroke: theme.colors.border }));
607
+ parent.appendChild(svgElement(document, 'text', { x: x + 22, y: fileY + 15, fill: theme.colors.textSecondary, 'font-size': 11, 'font-weight': 600, 'font-family': theme.fontFamily }, asset?.name || '附件不可用'));
608
+ parent.appendChild(svgElement(document, 'text', { x: x + 22, y: fileY + 28, fill: theme.colors.textMuted, 'font-size': 9.5, 'font-family': theme.fontFamily }, asset ? [asset.mediaType, formatSize(asset.size)].filter(Boolean).join(' · ') : ''));
609
+ cursorY += 40;
610
+ }
611
+ return height;
612
+ }
613
+
614
+ function resultLabel(item) {
615
+ return ({ approved: '已同意', rejected: '已驳回', returned: '已退回', submitted: '已提交', pending: '待处理' })[item.outcome] || item.statusLabel || '';
616
+ }
617
+
618
+ async function renderTimelineItem({ document, parent, item, x, y, width, theme, appearance, context, warnings, signal }) {
619
+ const itemTone = tone(theme, appearance.resolveStatus(item.status));
620
+ const actions = item.actions || [];
621
+ const actionTotal = actions.reduce((sum, action) => sum + actionHeight(action) + 8, 0);
622
+ const height = 76 + actionTotal;
623
+ const railX = x + 18;
624
+ parent.appendChild(svgElement(document, 'line', { x1: railX, y1: y, x2: railX, y2: y + height + 16, stroke: theme.colors.divider, 'stroke-width': 2 }));
625
+ parent.appendChild(svgElement(document, 'circle', { cx: railX, cy: y + 22, r: 14, fill: itemTone.background, stroke: itemTone.border, 'stroke-width': 1 }));
626
+ parent.appendChild(svgElement(document, 'circle', { cx: railX, cy: y + 22, r: 4.5, fill: itemTone.strong }));
627
+ const contentX = x + 46;
628
+ const contentWidth = width - 46;
629
+ parent.appendChild(svgElement(document, 'text', { x: contentX, y: y + 20, fill: theme.colors.text, 'font-size': 15, 'font-weight': 650, 'font-family': theme.fontFamily }, `${item.name}${item.round > 1 ? ` 第 ${item.round} 次` : ''}`));
630
+ parent.appendChild(svgElement(document, 'text', { x: contentX + contentWidth, y: y + 20, 'text-anchor': 'end', fill: itemTone.foreground, 'font-size': 12, 'font-weight': 600, 'font-family': theme.fontFamily }, resultLabel(item)));
631
+ parent.appendChild(svgElement(document, 'text', { x: contentX, y: y + 42, fill: theme.colors.textSecondary, 'font-size': 11.5, 'font-family': theme.fontFamily }, item.summary || ''));
632
+ parent.appendChild(svgElement(document, 'text', { x: contentX + contentWidth, y: y + 42, 'text-anchor': 'end', fill: theme.colors.textMuted, 'font-size': 11, 'font-family': theme.fontFamily }, formatTime(item.time)));
633
+ let actionY = y + 56;
634
+ for (const action of actions) {
635
+ const actionHeightValue = await renderTimelineAction({ document, parent, action, x: contentX, y: actionY, width: contentWidth, theme, appearance, context, warnings, signal });
636
+ actionY += actionHeightValue + 8;
637
+ }
638
+ return height;
639
+ }
640
+
641
+ export async function exportRuntimeTimelineSvg(context = {}, options = {}) {
642
+ options.signal?.throwIfAborted?.();
643
+ const document = resolvedDocument(context);
644
+ const projection = context.projection || { items: [], groups: [], links: [] };
645
+ const theme = themeSnapshot(context, options);
646
+ const appearance = context.runtimeAppearance?.resolveStatus ? context.runtimeAppearance : createRuntimeAppearance(context.runtimeAppearance || {});
647
+ const warnings = [];
648
+ const width = Math.max(640, finite(options.width, 960));
649
+ const padding = Math.max(0, finite(options.padding, DEFAULT_PADDING));
650
+ const title = options.title ?? context.title ?? context.model?.name ?? '审批轨迹';
651
+ const description = options.description ?? context.description ?? '按实际处理顺序展示';
652
+ const sequence = timelineSequence(projection);
653
+ const contentHeight = sequence.reduce((sum, entry) => {
654
+ if (entry.kind === 'parallel-group') {
655
+ const children = (entry.itemIds || []).map((id) => projection.items.find((item) => item.id === id)).filter(Boolean);
656
+ return sum + 50 + children.reduce((childSum, item) => childSum + 76 + (item.actions || []).reduce((actionSum, action) => actionSum + actionHeight(action) + 8, 0) + 16, 0);
657
+ }
658
+ return sum + 76 + (entry.actions || []).reduce((actionSum, action) => actionSum + actionHeight(action) + 8, 0) + 16;
659
+ }, 0);
660
+ const headerHeight = title || description ? 74 : 0;
661
+ const height = Math.max(240, padding * 2 + headerHeight + contentHeight);
662
+ const svg = createRoot(document, { minX: 0, minY: 0, width, height, title: title || '审批轨迹', theme, transparentBackground: options.transparentBackground === true });
663
+ addDefinitions(document, svg, theme);
664
+ const root = svgElement(document, 'g', { 'data-layer': 'runtime-timeline' });
665
+ if (title) root.appendChild(svgElement(document, 'text', { x: padding, y: padding + 20, fill: theme.colors.text, 'font-size': 20, 'font-weight': 700, 'font-family': theme.fontFamily }, title));
666
+ if (description) root.appendChild(svgElement(document, 'text', { x: padding, y: padding + 43, fill: theme.colors.textMuted, 'font-size': 12, 'font-family': theme.fontFamily }, description));
667
+ let y = padding + headerHeight;
668
+ const customRenderer = context.timelineRenderer;
669
+ let customHeight = 0;
670
+ if (typeof customRenderer === 'function') {
671
+ const customLayer = svgElement(document, 'g', { 'data-layer': 'custom-runtime-timeline' });
672
+ try {
673
+ const result = await customRenderer({ container: customLayer, document, projection, model: context.model, runtime: context.runtime, themeSnapshot: theme, appearance, resolveAsset: context.resolveAsset, signal: options.signal });
674
+ if (result?.height) customHeight = Math.max(0, finite(result.height));
675
+ root.appendChild(customLayer);
676
+ } catch (error) {
677
+ warnings.push({ code: 'custom-timeline-renderer-failed', message: '自定义时间线 SVG Renderer 执行失败,已使用默认时间线。' });
678
+ }
679
+ }
680
+ if (!customRenderer || warnings.some((warning) => warning.code === 'custom-timeline-renderer-failed')) {
681
+ for (const entry of sequence) {
682
+ options.signal?.throwIfAborted?.();
683
+ if (entry.kind === 'parallel-group') {
684
+ const groupTone = tone(theme, appearance.resolveStatus(entry.status));
685
+ root.appendChild(svgElement(document, 'rect', { x: padding, y, width: width - padding * 2, height: 36, rx: 8, fill: theme.colors.surfaceSubtle }));
686
+ root.appendChild(svgElement(document, 'text', { x: padding + 14, y: y + 23, fill: groupTone.foreground, 'font-size': 13, 'font-weight': 650, 'font-family': theme.fontFamily }, `${entry.label} · ${entry.completed}/${entry.total} 已完成`));
687
+ y += 50;
688
+ const children = (entry.itemIds || []).map((id) => projection.items.find((item) => item.id === id)).filter(Boolean).sort((a, b) => a.order - b.order);
689
+ for (const child of children) y += await renderTimelineItem({ document, parent: root, item: child, x: padding + 18, y, width: width - padding * 2 - 18, theme, appearance, context, warnings, signal: options.signal }) + 16;
690
+ } else {
691
+ y += await renderTimelineItem({ document, parent: root, item: entry, x: padding, y, width: width - padding * 2, theme, appearance, context, warnings, signal: options.signal }) + 16;
692
+ }
693
+ }
694
+ }
695
+ svg.appendChild(root);
696
+ const actualHeight = Math.max(height, customHeight, Math.ceil(y + padding));
697
+ svg.setAttribute('height', String(actualHeight));
698
+ svg.setAttribute('viewBox', `0 0 ${width} ${actualHeight}`);
699
+ const background = svg.querySelector(':scope > rect');
700
+ if (background) background.setAttribute('height', String(actualHeight));
701
+ const filename = sanitizeSvgFilename(options.filename || `${context.model?.name || 'process'}-${context.label || '移动时间线'}`);
702
+ return serializeArtifact(document, svg, { filename, width, height: actualHeight, viewBox: { x: 0, y: 0, width, height: actualHeight }, warnings });
703
+ }
704
+
705
+ export function downloadSvg(artifact, { document = globalThis.document } = {}) {
706
+ if (!artifact?.blob || !document?.createElement) throw new Error('A valid SVG export Artifact is required.');
707
+ const ownerWindow = document.defaultView || globalThis;
708
+ const url = ownerWindow.URL.createObjectURL(artifact.blob);
709
+ const anchor = document.createElement('a');
710
+ anchor.href = url;
711
+ anchor.download = sanitizeSvgFilename(artifact.filename);
712
+ anchor.hidden = true;
713
+ document.body.appendChild(anchor);
714
+ anchor.click();
715
+ anchor.remove();
716
+ ownerWindow.setTimeout(() => ownerWindow.URL.revokeObjectURL(url), 0);
717
+ return artifact.filename;
718
+ }