@markdstage/markdstage 3.2.0 → 3.3.0
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/package.json +1 -1
- package/shared/README.md +11 -1
- package/shared/renderer/architecture-scene.mjs +312 -0
- package/shared/renderer/architecture.mjs +41 -18
- package/shared/renderer/index.html +9 -1
- package/shared/renderer/mermaid-scene.mjs +1248 -0
- package/shared/renderer/renderer.js +429 -232
- package/shared/renderer/scene-graph.mjs +739 -0
- package/shared/renderer/scene-pptx.mjs +265 -0
- package/shared/renderer/scene-svg.mjs +321 -0
- package/shared/renderer/slides.css +23 -0
- package/shared/renderer/theme.mjs +54 -0
- package/shared/runtime/architecture-editor-server.mjs +3 -0
- package/shared/runtime/pptx-package.mjs +4 -3
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
export class SceneGraphError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "SceneGraphError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// These ceilings keep accidental full-DOM captures bounded while staying well above authored diagrams.
|
|
9
|
+
export const MAX_SCENE_NODES = 4000;
|
|
10
|
+
export const MAX_GROUP_DEPTH = 16;
|
|
11
|
+
export const MAX_CONNECTOR_POINTS = 64;
|
|
12
|
+
export const MAX_TEXT_PARAGRAPHS = 200;
|
|
13
|
+
export const MAX_TEXT_RUNS = 1000;
|
|
14
|
+
export const MAX_STRING_LENGTH = 8192;
|
|
15
|
+
|
|
16
|
+
const SCENE_VERSION = 1;
|
|
17
|
+
const METRIC_PRECISION = 10;
|
|
18
|
+
const NODE_KINDS = new Set(["group", "shape", "text", "image", "connector", "fallback"]);
|
|
19
|
+
const SOURCE_KINDS = new Set(["architecture", "mermaid"]);
|
|
20
|
+
const SHAPE_PRESETS = new Set([
|
|
21
|
+
"rect",
|
|
22
|
+
"roundedRect",
|
|
23
|
+
"ellipse",
|
|
24
|
+
"diamond",
|
|
25
|
+
"triangle",
|
|
26
|
+
"hexagon",
|
|
27
|
+
"parallelogram",
|
|
28
|
+
]);
|
|
29
|
+
const DASH_STYLES = new Set(["", "solid", "dash", "dashDot", "dot"]);
|
|
30
|
+
const IMAGE_FITS = new Set(["contain", "cover", "fill", "none"]);
|
|
31
|
+
const ARROWS = new Set([null, "none", "triangle", "arrow", "stealth", "diamond", "oval"]);
|
|
32
|
+
const ALIGNMENTS = new Set(["left", "center", "right", "justify"]);
|
|
33
|
+
const VERTICAL_ALIGNMENTS = new Set(["top", "middle", "bottom"]);
|
|
34
|
+
const TEXT_WRAPS = new Set(["none", "square"]);
|
|
35
|
+
const COMMON_NODE_KEYS = new Set([
|
|
36
|
+
"kind",
|
|
37
|
+
"id",
|
|
38
|
+
"sourcePath",
|
|
39
|
+
"z",
|
|
40
|
+
"bounds",
|
|
41
|
+
"capability",
|
|
42
|
+
"accessibility",
|
|
43
|
+
"meta",
|
|
44
|
+
]);
|
|
45
|
+
const NODE_KEYS = {
|
|
46
|
+
group: new Set(["children", "style", "text", "textLayout"]),
|
|
47
|
+
shape: new Set(["preset", "style", "text", "textLayout"]),
|
|
48
|
+
text: new Set(["text", "textLayout"]),
|
|
49
|
+
image: new Set(["src", "alt", "fit", "opacity"]),
|
|
50
|
+
connector: new Set(["points", "style", "arrowStart", "arrowEnd", "label"]),
|
|
51
|
+
fallback: new Set(["reason"]),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function fail(message) {
|
|
55
|
+
throw new SceneGraphError(message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPlainObject(value) {
|
|
59
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function roundedMetric(value) {
|
|
63
|
+
return Math.round(Math.max(0, Number(value) || 0) * METRIC_PRECISION) / METRIC_PRECISION;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function roundedExtent(value) {
|
|
67
|
+
const number = Number(value);
|
|
68
|
+
return Number.isFinite(number) && number >= 0 ? roundedMetric(number) : value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function finiteNumber(value, path) {
|
|
72
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
73
|
+
fail(`${path} must be a finite number`);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function nonNegativeNumber(value, path) {
|
|
79
|
+
const number = finiteNumber(value, path);
|
|
80
|
+
if (number < 0) fail(`${path} must be a finite non-negative number`);
|
|
81
|
+
return number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function optionalString(value, path) {
|
|
85
|
+
if (value !== undefined && typeof value !== "string") {
|
|
86
|
+
fail(`${path} must be a string`);
|
|
87
|
+
}
|
|
88
|
+
if (typeof value === "string" && value.length > MAX_STRING_LENGTH) {
|
|
89
|
+
fail(`${path} exceeds ${MAX_STRING_LENGTH} characters`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requiredString(value, path) {
|
|
94
|
+
optionalString(value, path);
|
|
95
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
96
|
+
fail(`${path} must be a non-empty string`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stringValue(value, path) {
|
|
101
|
+
optionalString(value, path);
|
|
102
|
+
if (typeof value !== "string") fail(`${path} must be a string`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function exactKeys(value, allowed, path) {
|
|
106
|
+
for (const key of Object.keys(value)) {
|
|
107
|
+
if (!allowed.has(key)) fail(`${path}.${key} is not supported`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function requiredObject(value, path) {
|
|
112
|
+
if (!isPlainObject(value)) fail(`${path} must be an object`);
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isPlainSerializableObject(value) {
|
|
117
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
118
|
+
const prototype = Object.getPrototypeOf(value);
|
|
119
|
+
return prototype === Object.prototype || prototype === null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateMetaValue(value, path, seen) {
|
|
123
|
+
if (value === null || typeof value === "boolean" || typeof value === "number") {
|
|
124
|
+
if (typeof value === "number" && !Number.isFinite(value)) fail(`${path} must be JSON-serializable`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (typeof value === "string") {
|
|
128
|
+
optionalString(value, path);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
if (seen.has(value)) fail(`${path} must be JSON-serializable`);
|
|
133
|
+
seen.add(value);
|
|
134
|
+
value.forEach((entry, index) => validateMetaValue(entry, `${path}[${index}]`, seen));
|
|
135
|
+
seen.delete(value);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (isPlainSerializableObject(value)) {
|
|
139
|
+
if (seen.has(value)) fail(`${path} must be JSON-serializable`);
|
|
140
|
+
seen.add(value);
|
|
141
|
+
Object.entries(value).forEach(([key, entry]) => {
|
|
142
|
+
optionalString(key, `${path} key`);
|
|
143
|
+
validateMetaValue(entry, `${path}.${key}`, seen);
|
|
144
|
+
});
|
|
145
|
+
seen.delete(value);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
fail(`${path} must be a JSON-serializable plain object`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validateMeta(value, path) {
|
|
152
|
+
if (value === undefined) return;
|
|
153
|
+
if (!isPlainSerializableObject(value)) fail(`${path} must be a plain object`);
|
|
154
|
+
validateMetaValue(value, path, new Set());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function boundsOf(value, path) {
|
|
158
|
+
requiredObject(value, path);
|
|
159
|
+
exactKeys(value, new Set(["x", "y", "width", "height"]), path);
|
|
160
|
+
finiteNumber(value.x, `${path}.x`);
|
|
161
|
+
finiteNumber(value.y, `${path}.y`);
|
|
162
|
+
nonNegativeNumber(value.width, `${path}.width`);
|
|
163
|
+
nonNegativeNumber(value.height, `${path}.height`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeBounds(value, offsetX, offsetY) {
|
|
167
|
+
if (!isPlainObject(value)) return null;
|
|
168
|
+
const x = Number(value.x);
|
|
169
|
+
const y = Number(value.y);
|
|
170
|
+
const width = Number(value.width);
|
|
171
|
+
const height = Number(value.height);
|
|
172
|
+
if (
|
|
173
|
+
!Number.isFinite(x) ||
|
|
174
|
+
!Number.isFinite(y) ||
|
|
175
|
+
!Number.isFinite(width) ||
|
|
176
|
+
!Number.isFinite(height)
|
|
177
|
+
) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
x: roundedMetric(offsetX + x),
|
|
182
|
+
y: roundedMetric(offsetY + y),
|
|
183
|
+
width: roundedMetric(width),
|
|
184
|
+
height: roundedMetric(height),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizePoint(value, offsetX, offsetY) {
|
|
189
|
+
if (!isPlainObject(value)) return null;
|
|
190
|
+
const x = Number(value.x);
|
|
191
|
+
const y = Number(value.y);
|
|
192
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
193
|
+
return {
|
|
194
|
+
x: roundedMetric(offsetX + x),
|
|
195
|
+
y: roundedMetric(offsetY + y),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function boundsFromPoints(points) {
|
|
200
|
+
const xs = points.map((point) => point.x);
|
|
201
|
+
const ys = points.map((point) => point.y);
|
|
202
|
+
const left = Math.min(...xs);
|
|
203
|
+
const top = Math.min(...ys);
|
|
204
|
+
return {
|
|
205
|
+
x: roundedMetric(left),
|
|
206
|
+
y: roundedMetric(top),
|
|
207
|
+
width: roundedMetric(Math.max(...xs) - left),
|
|
208
|
+
height: roundedMetric(Math.max(...ys) - top),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function validateColor(value, path) {
|
|
213
|
+
if (value === null) return;
|
|
214
|
+
if (typeof value !== "string") fail(`${path} must be a color string or null`);
|
|
215
|
+
const text = value.trim();
|
|
216
|
+
if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(text)) return;
|
|
217
|
+
const rgb = /^rgba?\((.*)\)$/i.exec(text);
|
|
218
|
+
if (!rgb) fail(`${path} must be #RGB, #RRGGBB, #RRGGBBAA, rgb(), or rgba()`);
|
|
219
|
+
const inner = rgb[1].trim();
|
|
220
|
+
let channels;
|
|
221
|
+
let alpha;
|
|
222
|
+
if (inner.includes(",")) {
|
|
223
|
+
const parts = inner.split(",").map((part) => part.trim());
|
|
224
|
+
if (parts.length !== 3 && parts.length !== 4) fail(`${path} is not a valid rgb() or rgba() color`);
|
|
225
|
+
channels = parts.slice(0, 3);
|
|
226
|
+
alpha = parts[3];
|
|
227
|
+
} else {
|
|
228
|
+
const parts = inner.split("/").map((part) => part.trim());
|
|
229
|
+
if (parts.length > 2) fail(`${path} is not a valid rgb() or rgba() color`);
|
|
230
|
+
channels = parts[0].split(/\s+/).filter(Boolean);
|
|
231
|
+
alpha = parts[1];
|
|
232
|
+
}
|
|
233
|
+
if (channels.length !== 3) fail(`${path} is not a valid rgb() or rgba() color`);
|
|
234
|
+
for (const [index, channel] of channels.entries()) {
|
|
235
|
+
const percent = /^(\d+(?:\.\d+)?)%$/.exec(channel);
|
|
236
|
+
const number = percent ? Number(percent[1]) : Number(channel);
|
|
237
|
+
const maximum = percent ? 100 : 255;
|
|
238
|
+
if (!Number.isFinite(number) || number < 0 || number > maximum) {
|
|
239
|
+
fail(`${path}.channel[${index}] must be between 0 and ${maximum}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (alpha === undefined) return;
|
|
243
|
+
const percent = /^(\d+(?:\.\d+)?)%$/.exec(alpha);
|
|
244
|
+
const number = percent ? Number(percent[1]) : Number(alpha);
|
|
245
|
+
const maximum = percent ? 100 : 1;
|
|
246
|
+
if (!Number.isFinite(number) || number < 0 || number > maximum) {
|
|
247
|
+
fail(`${path}.alpha must be between 0 and ${maximum}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function validateAccessibility(value, path) {
|
|
252
|
+
if (value === undefined) return;
|
|
253
|
+
requiredObject(value, path);
|
|
254
|
+
exactKeys(value, new Set(["title", "description"]), path);
|
|
255
|
+
optionalString(value.title, `${path}.title`);
|
|
256
|
+
optionalString(value.description, `${path}.description`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateCapability(value, path) {
|
|
260
|
+
if (value === undefined) return;
|
|
261
|
+
requiredObject(value, path);
|
|
262
|
+
exactKeys(value, new Set(["pptx", "reason"]), path);
|
|
263
|
+
if (value.pptx !== "native" && value.pptx !== "fallback") {
|
|
264
|
+
fail(`${path}.pptx must be "native" or "fallback"`);
|
|
265
|
+
}
|
|
266
|
+
optionalString(value.reason, `${path}.reason`);
|
|
267
|
+
if (value.reason !== undefined && value.reason.length === 0) {
|
|
268
|
+
fail(`${path}.reason must be a non-empty string`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function validateStyle(value, path) {
|
|
273
|
+
if (value === undefined) return;
|
|
274
|
+
requiredObject(value, path);
|
|
275
|
+
exactKeys(value, new Set(["fill", "stroke", "strokeWidth", "dash", "opacity", "cornerRadius"]), path);
|
|
276
|
+
if (value.fill !== undefined) validateColor(value.fill, `${path}.fill`);
|
|
277
|
+
if (value.stroke !== undefined) validateColor(value.stroke, `${path}.stroke`);
|
|
278
|
+
if (value.strokeWidth !== undefined) nonNegativeNumber(value.strokeWidth, `${path}.strokeWidth`);
|
|
279
|
+
if (value.cornerRadius !== undefined) nonNegativeNumber(value.cornerRadius, `${path}.cornerRadius`);
|
|
280
|
+
if (value.opacity !== undefined) {
|
|
281
|
+
const opacity = finiteNumber(value.opacity, `${path}.opacity`);
|
|
282
|
+
if (opacity < 0 || opacity > 1) fail(`${path}.opacity must be between 0 and 1`);
|
|
283
|
+
}
|
|
284
|
+
if (value.dash !== undefined && !DASH_STYLES.has(value.dash)) {
|
|
285
|
+
fail(`${path}.dash must be "", "solid", "dash", "dashDot", or "dot"`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function validateText(value, path) {
|
|
290
|
+
requiredObject(value, path);
|
|
291
|
+
exactKeys(value, new Set(["paragraphs"]), path);
|
|
292
|
+
if (!Array.isArray(value.paragraphs) || value.paragraphs.length === 0) {
|
|
293
|
+
fail(`${path}.paragraphs must be a non-empty array`);
|
|
294
|
+
}
|
|
295
|
+
if (value.paragraphs.length > MAX_TEXT_PARAGRAPHS) {
|
|
296
|
+
fail(`${path}.paragraphs exceeds ${MAX_TEXT_PARAGRAPHS} entries`);
|
|
297
|
+
}
|
|
298
|
+
let runCount = 0;
|
|
299
|
+
value.paragraphs.forEach((paragraph, paragraphIndex) => {
|
|
300
|
+
const paragraphPath = `${path}.paragraphs[${paragraphIndex}]`;
|
|
301
|
+
requiredObject(paragraph, paragraphPath);
|
|
302
|
+
exactKeys(paragraph, new Set(["alignment", "runs"]), paragraphPath);
|
|
303
|
+
if (paragraph.alignment !== undefined && !ALIGNMENTS.has(paragraph.alignment)) {
|
|
304
|
+
fail(`${paragraphPath}.alignment is invalid`);
|
|
305
|
+
}
|
|
306
|
+
if (!Array.isArray(paragraph.runs) || paragraph.runs.length === 0) {
|
|
307
|
+
fail(`${paragraphPath}.runs must be a non-empty array`);
|
|
308
|
+
}
|
|
309
|
+
runCount += paragraph.runs.length;
|
|
310
|
+
if (runCount > MAX_TEXT_RUNS) fail(`${path}.runs exceeds ${MAX_TEXT_RUNS} entries`);
|
|
311
|
+
paragraph.runs.forEach((run, runIndex) => {
|
|
312
|
+
const runPath = `${paragraphPath}.runs[${runIndex}]`;
|
|
313
|
+
requiredObject(run, runPath);
|
|
314
|
+
exactKeys(
|
|
315
|
+
run,
|
|
316
|
+
new Set(["text", "fontSize", "fontFace", "fontWeight", "bold", "italic", "color", "opacity"]),
|
|
317
|
+
runPath,
|
|
318
|
+
);
|
|
319
|
+
stringValue(run.text, `${runPath}.text`);
|
|
320
|
+
if (run.fontSize !== undefined) nonNegativeNumber(run.fontSize, `${runPath}.fontSize`);
|
|
321
|
+
optionalString(run.fontFace, `${runPath}.fontFace`);
|
|
322
|
+
if (run.fontFace !== undefined && run.fontFace.length === 0) {
|
|
323
|
+
fail(`${runPath}.fontFace must be a non-empty string`);
|
|
324
|
+
}
|
|
325
|
+
if (run.fontWeight !== undefined) nonNegativeNumber(run.fontWeight, `${runPath}.fontWeight`);
|
|
326
|
+
if (run.bold !== undefined && typeof run.bold !== "boolean") fail(`${runPath}.bold must be a boolean`);
|
|
327
|
+
if (run.italic !== undefined && typeof run.italic !== "boolean") fail(`${runPath}.italic must be a boolean`);
|
|
328
|
+
if (run.color !== undefined) validateColor(run.color, `${runPath}.color`);
|
|
329
|
+
if (run.opacity !== undefined) {
|
|
330
|
+
const opacity = finiteNumber(run.opacity, `${runPath}.opacity`);
|
|
331
|
+
if (opacity < 0 || opacity > 1) fail(`${runPath}.opacity must be between 0 and 1`);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function validateTextLayout(value, path) {
|
|
338
|
+
if (value === undefined) return;
|
|
339
|
+
requiredObject(value, path);
|
|
340
|
+
exactKeys(value, new Set(["alignment", "verticalAlignment", "textWrap", "textInsets"]), path);
|
|
341
|
+
if (value.alignment !== undefined && !ALIGNMENTS.has(value.alignment)) fail(`${path}.alignment is invalid`);
|
|
342
|
+
if (value.verticalAlignment !== undefined && !VERTICAL_ALIGNMENTS.has(value.verticalAlignment)) {
|
|
343
|
+
fail(`${path}.verticalAlignment must be "top", "middle", or "bottom"`);
|
|
344
|
+
}
|
|
345
|
+
if (value.textWrap !== undefined && !TEXT_WRAPS.has(value.textWrap)) {
|
|
346
|
+
fail(`${path}.textWrap must be "none" or "square"`);
|
|
347
|
+
}
|
|
348
|
+
if (value.textInsets !== undefined) {
|
|
349
|
+
requiredObject(value.textInsets, `${path}.textInsets`);
|
|
350
|
+
exactKeys(value.textInsets, new Set(["left", "top", "right", "bottom"]), `${path}.textInsets`);
|
|
351
|
+
for (const side of ["left", "top", "right", "bottom"]) {
|
|
352
|
+
if (value.textInsets[side] !== undefined) {
|
|
353
|
+
nonNegativeNumber(value.textInsets[side], `${path}.textInsets.${side}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function validatePoint(value, path) {
|
|
360
|
+
requiredObject(value, path);
|
|
361
|
+
exactKeys(value, new Set(["x", "y"]), path);
|
|
362
|
+
finiteNumber(value.x, `${path}.x`);
|
|
363
|
+
finiteNumber(value.y, `${path}.y`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function validateLabel(value, path) {
|
|
367
|
+
if (value === undefined) return;
|
|
368
|
+
requiredObject(value, path);
|
|
369
|
+
exactKeys(value, new Set(["text", "bounds"]), path);
|
|
370
|
+
validateText(value.text, `${path}.text`);
|
|
371
|
+
boundsOf(value.bounds, `${path}.bounds`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function validateNode(node, path, state, depth) {
|
|
375
|
+
requiredObject(node, path);
|
|
376
|
+
state.count += 1;
|
|
377
|
+
if (state.count > MAX_SCENE_NODES) fail(`scene.nodes exceeds ${MAX_SCENE_NODES} entries`);
|
|
378
|
+
if (depth > MAX_GROUP_DEPTH) fail(`${path} exceeds maximum group depth ${MAX_GROUP_DEPTH}`);
|
|
379
|
+
if (!NODE_KINDS.has(node.kind)) fail(`${path}.kind is not supported`);
|
|
380
|
+
exactKeys(node, new Set([...COMMON_NODE_KEYS, ...NODE_KEYS[node.kind]]), path);
|
|
381
|
+
optionalString(node.id, `${path}.id`);
|
|
382
|
+
optionalString(node.sourcePath, `${path}.sourcePath`);
|
|
383
|
+
finiteNumber(node.z, `${path}.z`);
|
|
384
|
+
if (node.kind !== "connector") boundsOf(node.bounds, `${path}.bounds`);
|
|
385
|
+
else if (node.bounds !== undefined) boundsOf(node.bounds, `${path}.bounds`);
|
|
386
|
+
validateCapability(node.capability, `${path}.capability`);
|
|
387
|
+
validateAccessibility(node.accessibility, `${path}.accessibility`);
|
|
388
|
+
validateMeta(node.meta, `${path}.meta`);
|
|
389
|
+
if (node.kind === "group") {
|
|
390
|
+
if (!Array.isArray(node.children)) fail(`${path}.children must be an array`);
|
|
391
|
+
validateStyle(node.style, `${path}.style`);
|
|
392
|
+
if (node.text !== undefined) validateText(node.text, `${path}.text`);
|
|
393
|
+
validateTextLayout(node.textLayout, `${path}.textLayout`);
|
|
394
|
+
node.children.forEach((child, index) => validateNode(child, `${path}.children[${index}]`, state, depth + 1));
|
|
395
|
+
} else if (node.kind === "shape") {
|
|
396
|
+
if (!SHAPE_PRESETS.has(node.preset)) fail(`${path}.preset is not supported`);
|
|
397
|
+
validateStyle(node.style, `${path}.style`);
|
|
398
|
+
if (node.text !== undefined) validateText(node.text, `${path}.text`);
|
|
399
|
+
validateTextLayout(node.textLayout, `${path}.textLayout`);
|
|
400
|
+
} else if (node.kind === "text") {
|
|
401
|
+
validateText(node.text, `${path}.text`);
|
|
402
|
+
validateTextLayout(node.textLayout, `${path}.textLayout`);
|
|
403
|
+
} else if (node.kind === "image") {
|
|
404
|
+
requiredString(node.src, `${path}.src`);
|
|
405
|
+
stringValue(node.alt, `${path}.alt`);
|
|
406
|
+
if (!IMAGE_FITS.has(node.fit)) fail(`${path}.fit is not supported`);
|
|
407
|
+
if (node.opacity !== undefined) {
|
|
408
|
+
const opacity = finiteNumber(node.opacity, `${path}.opacity`);
|
|
409
|
+
if (opacity < 0 || opacity > 1) fail(`${path}.opacity must be between 0 and 1`);
|
|
410
|
+
}
|
|
411
|
+
} else if (node.kind === "connector") {
|
|
412
|
+
if (!Array.isArray(node.points) || node.points.length < 2) {
|
|
413
|
+
fail(`${path}.points must contain at least two points`);
|
|
414
|
+
}
|
|
415
|
+
if (node.points.length > MAX_CONNECTOR_POINTS) {
|
|
416
|
+
fail(`${path}.points exceeds ${MAX_CONNECTOR_POINTS} entries`);
|
|
417
|
+
}
|
|
418
|
+
node.points.forEach((point, index) => validatePoint(point, `${path}.points[${index}]`));
|
|
419
|
+
for (let index = 1; index < node.points.length; index += 1) {
|
|
420
|
+
const previous = node.points[index - 1];
|
|
421
|
+
const current = node.points[index];
|
|
422
|
+
if (previous.x === current.x && previous.y === current.y) {
|
|
423
|
+
fail(`${path}.points[${index - 1}] and ${path}.points[${index}] must differ`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
validateStyle(node.style, `${path}.style`);
|
|
427
|
+
if (!ARROWS.has(node.arrowStart)) fail(`${path}.arrowStart is not supported`);
|
|
428
|
+
if (!ARROWS.has(node.arrowEnd)) fail(`${path}.arrowEnd is not supported`);
|
|
429
|
+
validateLabel(node.label, `${path}.label`);
|
|
430
|
+
} else if (node.kind === "fallback") {
|
|
431
|
+
requiredString(node.reason, `${path}.reason`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function textFromString(value) {
|
|
436
|
+
return {
|
|
437
|
+
paragraphs: [
|
|
438
|
+
{
|
|
439
|
+
runs: [{ text: value }],
|
|
440
|
+
},
|
|
441
|
+
],
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function clampOpacity(value) {
|
|
446
|
+
const number = Number(value);
|
|
447
|
+
if (!Number.isFinite(number)) return value;
|
|
448
|
+
return Math.max(0, Math.min(1, number));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function normalizeStyle(value) {
|
|
452
|
+
if (!isPlainObject(value)) return value;
|
|
453
|
+
return Object.fromEntries(
|
|
454
|
+
Object.entries(value).map(([key, entry]) => {
|
|
455
|
+
if (key === "strokeWidth" || key === "cornerRadius") return [key, roundedMetric(entry)];
|
|
456
|
+
if (key === "opacity") return [key, clampOpacity(entry)];
|
|
457
|
+
return [key, entry];
|
|
458
|
+
}),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function normalizeText(value) {
|
|
463
|
+
if (typeof value === "string") return textFromString(value);
|
|
464
|
+
if (!isPlainObject(value) || !Array.isArray(value.paragraphs)) return value;
|
|
465
|
+
return {
|
|
466
|
+
paragraphs: value.paragraphs.map((paragraph) => {
|
|
467
|
+
if (!isPlainObject(paragraph) || !Array.isArray(paragraph.runs)) return paragraph;
|
|
468
|
+
return {
|
|
469
|
+
...paragraph,
|
|
470
|
+
runs: paragraph.runs.map((run) =>
|
|
471
|
+
isPlainObject(run) && Number.isFinite(Number(run.opacity))
|
|
472
|
+
? { ...run, opacity: clampOpacity(run.opacity) }
|
|
473
|
+
: run,
|
|
474
|
+
),
|
|
475
|
+
};
|
|
476
|
+
}),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function normalizeTextLayout(value) {
|
|
481
|
+
if (!isPlainObject(value)) return value;
|
|
482
|
+
const normalized = { ...value };
|
|
483
|
+
if (isPlainObject(value.textInsets)) {
|
|
484
|
+
normalized.textInsets = Object.fromEntries(
|
|
485
|
+
Object.entries(value.textInsets).map(([key, entry]) => [key, roundedMetric(entry)]),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return normalized;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function normalizeCapability(value, fallbackReason) {
|
|
492
|
+
if (fallbackReason) return { pptx: "fallback", reason: fallbackReason };
|
|
493
|
+
if (!isPlainObject(value)) return { pptx: "native" };
|
|
494
|
+
return {
|
|
495
|
+
pptx: value.pptx === "fallback" ? "fallback" : "native",
|
|
496
|
+
...(typeof value.reason === "string" && value.reason ? { reason: value.reason } : {}),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function fallbackNode(node, path, reason, bounds) {
|
|
501
|
+
return {
|
|
502
|
+
kind: "fallback",
|
|
503
|
+
...(typeof node?.id === "string" ? { id: node.id } : {}),
|
|
504
|
+
...(typeof node?.sourcePath === "string" ? { sourcePath: node.sourcePath } : {}),
|
|
505
|
+
z: Number.isFinite(Number(node?.z)) ? Number(node.z) : 0,
|
|
506
|
+
bounds: bounds || { x: 0, y: 0, width: 0, height: 0 },
|
|
507
|
+
capability: { pptx: "fallback", reason },
|
|
508
|
+
reason,
|
|
509
|
+
...(isPlainObject(node?.accessibility) ? { accessibility: node.accessibility } : {}),
|
|
510
|
+
...(node?.meta !== undefined ? { meta: node.meta } : {}),
|
|
511
|
+
__order: 0,
|
|
512
|
+
__path: path,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function unsupported(path, kind, reason, diagnostics) {
|
|
517
|
+
diagnostics.push({ path, kind, reason });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function normalizeKnownNode(node, path, bounds, offsetX, offsetY, diagnostics, depth) {
|
|
521
|
+
const base = {
|
|
522
|
+
kind: node.kind,
|
|
523
|
+
...(typeof node.id === "string" ? { id: node.id } : {}),
|
|
524
|
+
...(typeof node.sourcePath === "string" ? { sourcePath: node.sourcePath } : {}),
|
|
525
|
+
z: Number.isFinite(Number(node.z)) ? Number(node.z) : 0,
|
|
526
|
+
...(node.kind !== "connector" ? { bounds } : {}),
|
|
527
|
+
capability: normalizeCapability(node.capability),
|
|
528
|
+
...(isPlainObject(node.accessibility) ? { accessibility: node.accessibility } : {}),
|
|
529
|
+
...(node.meta !== undefined ? { meta: node.meta } : {}),
|
|
530
|
+
};
|
|
531
|
+
if (node.kind === "group") {
|
|
532
|
+
return {
|
|
533
|
+
...base,
|
|
534
|
+
children: [],
|
|
535
|
+
...(node.style !== undefined ? { style: normalizeStyle(node.style) } : {}),
|
|
536
|
+
...(node.text !== undefined ? { text: normalizeText(node.text) } : {}),
|
|
537
|
+
...(node.textLayout !== undefined ? { textLayout: normalizeTextLayout(node.textLayout) } : {}),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (node.kind === "shape") {
|
|
541
|
+
if (!SHAPE_PRESETS.has(node.preset)) {
|
|
542
|
+
const reason = `unsupported shape preset: ${String(node.preset)}`;
|
|
543
|
+
unsupported(path, "fallback", reason, diagnostics);
|
|
544
|
+
return fallbackNode(node, path, reason, bounds);
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
...base,
|
|
548
|
+
preset: node.preset,
|
|
549
|
+
...(node.style !== undefined ? { style: normalizeStyle(node.style) } : {}),
|
|
550
|
+
...(node.text !== undefined ? { text: normalizeText(node.text) } : {}),
|
|
551
|
+
...(node.textLayout !== undefined ? { textLayout: normalizeTextLayout(node.textLayout) } : {}),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (node.kind === "text") {
|
|
555
|
+
return {
|
|
556
|
+
...base,
|
|
557
|
+
text: normalizeText(node.text),
|
|
558
|
+
...(node.textLayout !== undefined ? { textLayout: normalizeTextLayout(node.textLayout) } : {}),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
if (node.kind === "image") {
|
|
562
|
+
if (!IMAGE_FITS.has(node.fit)) {
|
|
563
|
+
const reason = `unsupported image fit: ${String(node.fit)}`;
|
|
564
|
+
unsupported(path, "fallback", reason, diagnostics);
|
|
565
|
+
return fallbackNode(node, path, reason, bounds);
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
...base,
|
|
569
|
+
src: node.src,
|
|
570
|
+
alt: node.alt,
|
|
571
|
+
fit: node.fit,
|
|
572
|
+
...(node.opacity !== undefined ? { opacity: clampOpacity(node.opacity) } : {}),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
if (node.kind === "connector") {
|
|
576
|
+
if (!Array.isArray(node.points) || node.points.length < 2 || node.points.length > MAX_CONNECTOR_POINTS) {
|
|
577
|
+
const reason = "connector points are not representable";
|
|
578
|
+
unsupported(path, "fallback", reason, diagnostics);
|
|
579
|
+
return fallbackNode(node, path, reason, bounds);
|
|
580
|
+
}
|
|
581
|
+
const points = node.points.map((point) => normalizePoint(point, offsetX, offsetY));
|
|
582
|
+
if (points.some((point) => !point)) {
|
|
583
|
+
const reason = "connector points are not finite";
|
|
584
|
+
unsupported(path, "fallback", reason, diagnostics);
|
|
585
|
+
return fallbackNode(node, path, reason, bounds);
|
|
586
|
+
}
|
|
587
|
+
const connectorBounds = boundsFromPoints(points);
|
|
588
|
+
return {
|
|
589
|
+
...base,
|
|
590
|
+
bounds: connectorBounds,
|
|
591
|
+
points,
|
|
592
|
+
...(node.style !== undefined ? { style: normalizeStyle(node.style) } : {}),
|
|
593
|
+
arrowStart: ARROWS.has(node.arrowStart) ? node.arrowStart : "none",
|
|
594
|
+
arrowEnd: ARROWS.has(node.arrowEnd) ? node.arrowEnd : "none",
|
|
595
|
+
...(node.label !== undefined && isPlainObject(node.label)
|
|
596
|
+
? {
|
|
597
|
+
label: {
|
|
598
|
+
text: normalizeText(node.label.text),
|
|
599
|
+
bounds: normalizeBounds(node.label.bounds, offsetX, offsetY) || connectorBounds,
|
|
600
|
+
},
|
|
601
|
+
}
|
|
602
|
+
: {}),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
...base,
|
|
607
|
+
capability: normalizeCapability(node.capability, node.reason),
|
|
608
|
+
reason: node.reason,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function normalizeNode(node, path, offsetX, offsetY, depth, output, diagnostics) {
|
|
613
|
+
if (!isPlainObject(node)) {
|
|
614
|
+
const reason = "node is not an object";
|
|
615
|
+
unsupported(path, "fallback", reason, diagnostics);
|
|
616
|
+
const normalized = fallbackNode(node, path, reason);
|
|
617
|
+
normalized.__sortZ = output.length;
|
|
618
|
+
output.push(normalized);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (depth > MAX_GROUP_DEPTH) {
|
|
622
|
+
const bounds = normalizeBounds(node.bounds, offsetX, offsetY);
|
|
623
|
+
const reason = `group depth exceeds ${MAX_GROUP_DEPTH}`;
|
|
624
|
+
unsupported(path, "fallback", reason, diagnostics);
|
|
625
|
+
const normalized = fallbackNode(node, path, reason, bounds);
|
|
626
|
+
normalized.__sortZ = Number.isFinite(Number(node.z)) ? Number(node.z) : output.length;
|
|
627
|
+
output.push(normalized);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (!NODE_KINDS.has(node.kind)) {
|
|
631
|
+
const bounds = normalizeBounds(node.bounds, offsetX, offsetY);
|
|
632
|
+
const reason = `unsupported node kind: ${String(node.kind)}`;
|
|
633
|
+
unsupported(path, "fallback", reason, diagnostics);
|
|
634
|
+
const normalized = fallbackNode(node, path, reason, bounds);
|
|
635
|
+
normalized.__sortZ = Number.isFinite(Number(node.z)) ? Number(node.z) : output.length;
|
|
636
|
+
output.push(normalized);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const bounds = normalizeBounds(node.bounds, offsetX, offsetY);
|
|
640
|
+
if (node.kind !== "connector" && !bounds) {
|
|
641
|
+
const reason = "node bounds are not finite";
|
|
642
|
+
unsupported(path, "fallback", reason, diagnostics);
|
|
643
|
+
const normalized = fallbackNode(node, path, reason);
|
|
644
|
+
normalized.__sortZ = Number.isFinite(Number(node.z)) ? Number(node.z) : output.length;
|
|
645
|
+
output.push(normalized);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const normalized = normalizeKnownNode(node, path, bounds, offsetX, offsetY, diagnostics, depth);
|
|
649
|
+
normalized.__sortZ = Number.isFinite(Number(node.z)) ? Number(node.z) : output.length;
|
|
650
|
+
output.push(normalized);
|
|
651
|
+
if (node.kind !== "group") return;
|
|
652
|
+
if (!Array.isArray(node.children)) {
|
|
653
|
+
const reason = "group children are not an array";
|
|
654
|
+
unsupported(`${path}.children`, "fallback", reason, diagnostics);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
node.children.forEach((child, index) => {
|
|
658
|
+
normalizeNode(
|
|
659
|
+
child,
|
|
660
|
+
`${path}.children[${index}]`,
|
|
661
|
+
bounds.x,
|
|
662
|
+
bounds.y,
|
|
663
|
+
depth + 1,
|
|
664
|
+
output,
|
|
665
|
+
diagnostics,
|
|
666
|
+
);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function stripInternal(node) {
|
|
671
|
+
const { __order, __path, __sortZ, ...stripped } = node;
|
|
672
|
+
return stripped;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export function createScene({ width, height, source, nodes, accessibility, meta } = {}) {
|
|
676
|
+
return {
|
|
677
|
+
version: SCENE_VERSION,
|
|
678
|
+
source,
|
|
679
|
+
width,
|
|
680
|
+
height,
|
|
681
|
+
...(accessibility !== undefined ? { accessibility } : {}),
|
|
682
|
+
...(meta !== undefined ? { meta } : {}),
|
|
683
|
+
nodes: Array.isArray(nodes) ? nodes : [],
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export function validateScene(scene) {
|
|
688
|
+
requiredObject(scene, "scene");
|
|
689
|
+
exactKeys(scene, new Set(["version", "source", "width", "height", "accessibility", "meta", "nodes"]), "scene");
|
|
690
|
+
if (scene.version !== SCENE_VERSION) fail("scene.version must be 1");
|
|
691
|
+
nonNegativeNumber(scene.width, "scene.width");
|
|
692
|
+
nonNegativeNumber(scene.height, "scene.height");
|
|
693
|
+
requiredObject(scene.source, "scene.source");
|
|
694
|
+
exactKeys(scene.source, new Set(["kind", "path"]), "scene.source");
|
|
695
|
+
if (!SOURCE_KINDS.has(scene.source.kind)) fail("scene.source.kind is not supported");
|
|
696
|
+
requiredString(scene.source.path, "scene.source.path");
|
|
697
|
+
validateAccessibility(scene.accessibility, "scene.accessibility");
|
|
698
|
+
validateMeta(scene.meta, "scene.meta");
|
|
699
|
+
if (!Array.isArray(scene.nodes)) fail("scene.nodes must be an array");
|
|
700
|
+
const state = { count: 0 };
|
|
701
|
+
scene.nodes.forEach((node, index) => validateNode(node, `scene.nodes[${index}]`, state, 1));
|
|
702
|
+
return scene;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Intended pipeline: producers create a loose graph, normalize unsupported values
|
|
707
|
+
* into explicit fallback nodes, then validate the normalized JSON contract.
|
|
708
|
+
*/
|
|
709
|
+
export function normalizeScene(scene) {
|
|
710
|
+
const diagnostics = [];
|
|
711
|
+
const nodes = [];
|
|
712
|
+
const inputNodes = Array.isArray(scene?.nodes) ? scene.nodes : [];
|
|
713
|
+
inputNodes.forEach((node, index) => normalizeNode(node, `scene.nodes[${index}]`, 0, 0, 1, nodes, diagnostics));
|
|
714
|
+
nodes.forEach((node, index) => {
|
|
715
|
+
node.__order = index;
|
|
716
|
+
});
|
|
717
|
+
const normalizedNodes = nodes
|
|
718
|
+
.toSorted((left, right) => {
|
|
719
|
+
const leftZ = Number.isFinite(Number(left.z)) ? Number(left.z) : left.__order;
|
|
720
|
+
const rightZ = Number.isFinite(Number(right.z)) ? Number(right.z) : right.__order;
|
|
721
|
+
const normalizedLeftZ = Number.isFinite(Number(left.__sortZ)) ? Number(left.__sortZ) : leftZ;
|
|
722
|
+
const normalizedRightZ = Number.isFinite(Number(right.__sortZ)) ? Number(right.__sortZ) : rightZ;
|
|
723
|
+
if (normalizedLeftZ !== normalizedRightZ) return normalizedLeftZ - normalizedRightZ;
|
|
724
|
+
return left.__order - right.__order;
|
|
725
|
+
})
|
|
726
|
+
.map((node, index) => stripInternal({ ...node, z: index }));
|
|
727
|
+
return {
|
|
728
|
+
scene: {
|
|
729
|
+
version: SCENE_VERSION,
|
|
730
|
+
source: isPlainObject(scene?.source) ? { ...scene.source } : scene?.source,
|
|
731
|
+
width: roundedExtent(scene?.width),
|
|
732
|
+
height: roundedExtent(scene?.height),
|
|
733
|
+
...(scene?.accessibility !== undefined ? { accessibility: scene.accessibility } : {}),
|
|
734
|
+
...(scene?.meta !== undefined ? { meta: scene.meta } : {}),
|
|
735
|
+
nodes: normalizedNodes,
|
|
736
|
+
},
|
|
737
|
+
diagnostics,
|
|
738
|
+
};
|
|
739
|
+
}
|