@markdstage/markdstage 3.4.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,18 +15,28 @@ export const MAX_STRING_LENGTH = 8192;
15
15
 
16
16
  const SCENE_VERSION = 1;
17
17
  const METRIC_PRECISION = 10;
18
+ const DRAWINGML_ANGLE_UNITS_PER_DEGREE = 60000;
19
+ const DRAWINGML_HALF_TURN = 180 * DRAWINGML_ANGLE_UNITS_PER_DEGREE;
20
+ const DRAWINGML_FULL_TURN = 360 * DRAWINGML_ANGLE_UNITS_PER_DEGREE;
18
21
  const NODE_KINDS = new Set(["group", "shape", "text", "image", "connector", "fallback"]);
19
22
  const SOURCE_KINDS = new Set(["architecture", "mermaid"]);
20
23
  const SHAPE_PRESETS = new Set([
21
24
  "rect",
22
25
  "roundedRect",
26
+ "topRoundedRect",
23
27
  "ellipse",
24
28
  "diamond",
25
29
  "triangle",
26
30
  "hexagon",
31
+ "quarterHeightHexagon",
27
32
  "parallelogram",
33
+ "reverseParallelogram",
34
+ "trapezoid",
35
+ "invertedTrapezoid",
36
+ "sequenceTab",
28
37
  ]);
29
38
  const DASH_STYLES = new Set(["", "solid", "dash", "dashDot", "dot"]);
39
+ const LINE_CAPS = new Set(["butt", "round", "square"]);
30
40
  const IMAGE_FITS = new Set(["contain", "cover", "fill", "none"]);
31
41
  const ARROWS = new Set([null, "none", "triangle", "arrow", "stealth", "diamond", "oval"]);
32
42
  const ALIGNMENTS = new Set(["left", "center", "right", "justify"]);
@@ -44,8 +54,8 @@ const COMMON_NODE_KEYS = new Set([
44
54
  ]);
45
55
  const NODE_KEYS = {
46
56
  group: new Set(["children", "style", "text", "textLayout"]),
47
- shape: new Set(["preset", "style", "text", "textLayout"]),
48
- text: new Set(["text", "textLayout"]),
57
+ shape: new Set(["preset", "style", "text", "textLayout", "rotation"]),
58
+ text: new Set(["text", "textLayout", "rotation"]),
49
59
  image: new Set(["src", "alt", "fit", "opacity"]),
50
60
  connector: new Set(["points", "style", "arrowStart", "arrowEnd", "label"]),
51
61
  fallback: new Set(["reason"]),
@@ -63,11 +73,22 @@ function roundedMetric(value) {
63
73
  return Math.round(Math.max(0, Number(value) || 0) * METRIC_PRECISION) / METRIC_PRECISION;
64
74
  }
65
75
 
76
+ function roundedCoordinate(value) {
77
+ return Math.round((Number(value) || 0) * METRIC_PRECISION) / METRIC_PRECISION;
78
+ }
79
+
66
80
  function roundedExtent(value) {
67
81
  const number = Number(value);
68
82
  return Number.isFinite(number) && number >= 0 ? roundedMetric(number) : value;
69
83
  }
70
84
 
85
+ export function normalizeRotationAngle(value) {
86
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
87
+ let units = Math.round((((value % 360) + 360) % 360) * DRAWINGML_ANGLE_UNITS_PER_DEGREE);
88
+ if (units >= DRAWINGML_HALF_TURN) units -= DRAWINGML_FULL_TURN;
89
+ return units === 0 ? 0 : units / DRAWINGML_ANGLE_UNITS_PER_DEGREE;
90
+ }
91
+
71
92
  function finiteNumber(value, path) {
72
93
  if (typeof value !== "number" || !Number.isFinite(value)) {
73
94
  fail(`${path} must be a finite number`);
@@ -108,6 +129,14 @@ function exactKeys(value, allowed, path) {
108
129
  }
109
130
  }
110
131
 
132
+ function rejectInheritedKeys(value, allowed, path) {
133
+ for (const key of allowed) {
134
+ if (!Object.hasOwn(value, key) && key in value) {
135
+ fail(`${path}.${key} must be an own property`);
136
+ }
137
+ }
138
+ }
139
+
111
140
  function requiredObject(value, path) {
112
141
  if (!isPlainObject(value)) fail(`${path} must be an object`);
113
142
  return value;
@@ -178,8 +207,8 @@ function normalizeBounds(value, offsetX, offsetY) {
178
207
  return null;
179
208
  }
180
209
  return {
181
- x: roundedMetric(offsetX + x),
182
- y: roundedMetric(offsetY + y),
210
+ x: roundedCoordinate(offsetX + x),
211
+ y: roundedCoordinate(offsetY + y),
183
212
  width: roundedMetric(width),
184
213
  height: roundedMetric(height),
185
214
  };
@@ -191,8 +220,8 @@ function normalizePoint(value, offsetX, offsetY) {
191
220
  const y = Number(value.y);
192
221
  if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
193
222
  return {
194
- x: roundedMetric(offsetX + x),
195
- y: roundedMetric(offsetY + y),
223
+ x: roundedCoordinate(offsetX + x),
224
+ y: roundedCoordinate(offsetY + y),
196
225
  };
197
226
  }
198
227
 
@@ -202,8 +231,8 @@ function boundsFromPoints(points) {
202
231
  const left = Math.min(...xs);
203
232
  const top = Math.min(...ys);
204
233
  return {
205
- x: roundedMetric(left),
206
- y: roundedMetric(top),
234
+ x: roundedCoordinate(left),
235
+ y: roundedCoordinate(top),
207
236
  width: roundedMetric(Math.max(...xs) - left),
208
237
  height: roundedMetric(Math.max(...ys) - top),
209
238
  };
@@ -272,18 +301,34 @@ function validateCapability(value, path) {
272
301
  function validateStyle(value, path) {
273
302
  if (value === undefined) return;
274
303
  requiredObject(value, path);
275
- exactKeys(value, new Set(["fill", "stroke", "strokeWidth", "dash", "opacity", "cornerRadius"]), path);
304
+ const keys = new Set([
305
+ "fill",
306
+ "stroke",
307
+ "strokeWidth",
308
+ "dash",
309
+ "lineCap",
310
+ "opacity",
311
+ "fillOpacity",
312
+ "strokeOpacity",
313
+ "cornerRadius",
314
+ ]);
315
+ exactKeys(value, keys, path);
316
+ rejectInheritedKeys(value, keys, path);
276
317
  if (value.fill !== undefined) validateColor(value.fill, `${path}.fill`);
277
318
  if (value.stroke !== undefined) validateColor(value.stroke, `${path}.stroke`);
278
319
  if (value.strokeWidth !== undefined) nonNegativeNumber(value.strokeWidth, `${path}.strokeWidth`);
279
320
  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`);
321
+ for (const key of ["opacity", "fillOpacity", "strokeOpacity"]) {
322
+ if (value[key] === undefined) continue;
323
+ const opacity = finiteNumber(value[key], `${path}.${key}`);
324
+ if (opacity < 0 || opacity > 1) fail(`${path}.${key} must be between 0 and 1`);
283
325
  }
284
326
  if (value.dash !== undefined && !DASH_STYLES.has(value.dash)) {
285
327
  fail(`${path}.dash must be "", "solid", "dash", "dashDot", or "dot"`);
286
328
  }
329
+ if (value.lineCap !== undefined && !LINE_CAPS.has(value.lineCap)) {
330
+ fail(`${path}.lineCap must be "butt", "round", or "square"`);
331
+ }
287
332
  }
288
333
 
289
334
  function validateText(value, path) {
@@ -386,6 +431,16 @@ function validateNode(node, path, state, depth) {
386
431
  validateCapability(node.capability, `${path}.capability`);
387
432
  validateAccessibility(node.accessibility, `${path}.accessibility`);
388
433
  validateMeta(node.meta, `${path}.meta`);
434
+ if (node.kind === "shape" || node.kind === "text") {
435
+ rejectInheritedKeys(node, new Set(["rotation"]), path);
436
+ if (node.rotation !== undefined) {
437
+ const rotation = finiteNumber(node.rotation, `${path}.rotation`);
438
+ const normalized = normalizeRotationAngle(rotation);
439
+ if (normalized !== rotation || Object.is(rotation, -0)) {
440
+ fail(`${path}.rotation must be normalized to [-180, 180) degrees`);
441
+ }
442
+ }
443
+ }
389
444
  if (node.kind === "group") {
390
445
  if (!Array.isArray(node.children)) fail(`${path}.children must be an array`);
391
446
  validateStyle(node.style, `${path}.style`);
@@ -453,7 +508,9 @@ function normalizeStyle(value) {
453
508
  return Object.fromEntries(
454
509
  Object.entries(value).map(([key, entry]) => {
455
510
  if (key === "strokeWidth" || key === "cornerRadius") return [key, roundedMetric(entry)];
456
- if (key === "opacity") return [key, clampOpacity(entry)];
511
+ if (key === "opacity" || key === "fillOpacity" || key === "strokeOpacity") {
512
+ return [key, clampOpacity(entry)];
513
+ }
457
514
  return [key, entry];
458
515
  }),
459
516
  );
@@ -538,6 +595,7 @@ function normalizeKnownNode(node, path, bounds, offsetX, offsetY, diagnostics, d
538
595
  };
539
596
  }
540
597
  if (node.kind === "shape") {
598
+ const rotation = node.rotation === undefined ? 0 : normalizeRotationAngle(node.rotation);
541
599
  if (!SHAPE_PRESETS.has(node.preset)) {
542
600
  const reason = `unsupported shape preset: ${String(node.preset)}`;
543
601
  unsupported(path, "fallback", reason, diagnostics);
@@ -546,16 +604,19 @@ function normalizeKnownNode(node, path, bounds, offsetX, offsetY, diagnostics, d
546
604
  return {
547
605
  ...base,
548
606
  preset: node.preset,
607
+ ...(rotation ? { rotation } : {}),
549
608
  ...(node.style !== undefined ? { style: normalizeStyle(node.style) } : {}),
550
609
  ...(node.text !== undefined ? { text: normalizeText(node.text) } : {}),
551
610
  ...(node.textLayout !== undefined ? { textLayout: normalizeTextLayout(node.textLayout) } : {}),
552
611
  };
553
612
  }
554
613
  if (node.kind === "text") {
614
+ const rotation = node.rotation === undefined ? 0 : normalizeRotationAngle(node.rotation);
555
615
  return {
556
616
  ...base,
557
617
  text: normalizeText(node.text),
558
618
  ...(node.textLayout !== undefined ? { textLayout: normalizeTextLayout(node.textLayout) } : {}),
619
+ ...(rotation ? { rotation } : {}),
559
620
  };
560
621
  }
561
622
  if (node.kind === "image") {
@@ -645,6 +706,15 @@ function normalizeNode(node, path, offsetX, offsetY, depth, output, diagnostics)
645
706
  output.push(normalized);
646
707
  return;
647
708
  }
709
+ if (["text", "shape"].includes(node.kind) && "rotation" in node &&
710
+ (!Object.hasOwn(node, "rotation") || normalizeRotationAngle(node.rotation) === null)) {
711
+ const reason = `${node.kind} rotation is not a finite number`;
712
+ unsupported(path, "fallback", reason, diagnostics);
713
+ const normalized = fallbackNode(node, path, reason, bounds);
714
+ normalized.__sortZ = Number.isFinite(Number(node.z)) ? Number(node.z) : output.length;
715
+ output.push(normalized);
716
+ return;
717
+ }
648
718
  const normalized = normalizeKnownNode(node, path, bounds, offsetX, offsetY, diagnostics, depth);
649
719
  normalized.__sortZ = Number.isFinite(Number(node.z)) ? Number(node.z) : output.length;
650
720
  output.push(normalized);
@@ -61,7 +61,15 @@ function copyTextLayout(target, textLayout = {}, keys) {
61
61
 
62
62
  function styleFields(style = {}) {
63
63
  const mapped = {};
64
- copyDefined(mapped, style, ["fill", "stroke", "opacity", "cornerRadius"]);
64
+ copyDefined(mapped, style, [
65
+ "fill",
66
+ "stroke",
67
+ "lineCap",
68
+ "opacity",
69
+ "fillOpacity",
70
+ "strokeOpacity",
71
+ "cornerRadius",
72
+ ]);
65
73
  if (style.strokeWidth !== undefined && style.strokeWidth > 0) {
66
74
  mapped.strokeWidth = style.strokeWidth;
67
75
  } else if (style.strokeWidth === 0) {
@@ -153,12 +161,141 @@ function shapeElement(node, index, options, shape) {
153
161
  shape,
154
162
  ...geometryFields(node),
155
163
  ...styleFields(node.style),
164
+ ...(node.rotation !== undefined ? { rotation: node.rotation } : {}),
156
165
  };
157
166
  if (node.text !== undefined) element.text = cloneText(node.text);
158
167
  copyTextLayout(element, node.textLayout, ["verticalAlignment", "textWrap", "textInsets"]);
159
168
  return element;
160
169
  }
161
170
 
171
+ function sameValue(left, right) {
172
+ return JSON.stringify(left) === JSON.stringify(right);
173
+ }
174
+
175
+ function sameMetric(left, right) {
176
+ return Math.abs(left - right) <= 0.1;
177
+ }
178
+
179
+ function samePoints(actual, expected) {
180
+ return Array.isArray(actual) &&
181
+ actual.length === expected.length &&
182
+ actual.every((point, index) =>
183
+ sameMetric(point.x, expected[index].x) &&
184
+ sameMetric(point.y, expected[index].y));
185
+ }
186
+
187
+ function stadiumMapping(scene, group, index, options) {
188
+ if (
189
+ group.kind !== "group" ||
190
+ group.meta?.mermaid?.shape !== "stadium" ||
191
+ typeof group.sourcePath !== "string"
192
+ ) {
193
+ return null;
194
+ }
195
+ const prefix = `${group.sourcePath}.`;
196
+ const descendants = scene.nodes.filter((node) =>
197
+ node.sourcePath?.startsWith(prefix));
198
+ const part = (partIndex) => descendants.find((node) =>
199
+ node.sourcePath === `${group.sourcePath}.parts[${partIndex}]`);
200
+ const left = part(0);
201
+ const right = part(1);
202
+ const center = part(2);
203
+ const top = part(3);
204
+ const bottom = part(4);
205
+ const label = descendants.find((node) =>
206
+ node.sourcePath === `${group.sourcePath}.label`);
207
+ if (
208
+ !left ||
209
+ !right ||
210
+ !center ||
211
+ !top ||
212
+ !bottom ||
213
+ descendants.length !== (label ? 6 : 5)
214
+ ) {
215
+ return null;
216
+ }
217
+ const unsupported = [group, ...descendants].find((node) =>
218
+ node.kind === "fallback" ||
219
+ node.capability?.pptx === "fallback");
220
+ if (unsupported) {
221
+ return {
222
+ prefix,
223
+ fallback: fallbackFor(
224
+ group,
225
+ index,
226
+ options,
227
+ fallbackReason(unsupported, "stadium-rendered-as-artwork"),
228
+ ),
229
+ };
230
+ }
231
+ if (
232
+ left.kind !== "shape" ||
233
+ left.preset !== "ellipse" ||
234
+ right.kind !== "shape" ||
235
+ right.preset !== "ellipse" ||
236
+ center.kind !== "shape" ||
237
+ center.preset !== "rect" ||
238
+ top.kind !== "connector" ||
239
+ bottom.kind !== "connector" ||
240
+ (label && (label.kind !== "text" || label.rotation !== undefined))
241
+ ) {
242
+ return null;
243
+ }
244
+ const { x, y, width, height } = group.bounds;
245
+ const radius = height / 2;
246
+ const expectedCenterStyle = { ...left.style, stroke: null, strokeWidth: 0 };
247
+ const expectedOutlineStyle = { ...left.style, fill: null };
248
+ const expectedGeometry = (
249
+ sameMetric(left.bounds.x, x) &&
250
+ sameMetric(left.bounds.y, y) &&
251
+ sameMetric(left.bounds.width, height) &&
252
+ sameMetric(left.bounds.height, height) &&
253
+ sameMetric(right.bounds.x, x + width - height) &&
254
+ sameMetric(right.bounds.y, y) &&
255
+ sameMetric(right.bounds.width, height) &&
256
+ sameMetric(right.bounds.height, height) &&
257
+ sameMetric(center.bounds.x, x + radius) &&
258
+ sameMetric(center.bounds.y, y) &&
259
+ sameMetric(center.bounds.width, width - height) &&
260
+ sameMetric(center.bounds.height, height) &&
261
+ samePoints(top.points, [
262
+ { x: x + radius, y },
263
+ { x: x + width - radius, y },
264
+ ]) &&
265
+ samePoints(bottom.points, [
266
+ { x: x + radius, y: y + height },
267
+ { x: x + width - radius, y: y + height },
268
+ ])
269
+ );
270
+ if (
271
+ !expectedGeometry ||
272
+ !sameValue(left.style, right.style) ||
273
+ !sameValue(center.style, expectedCenterStyle) ||
274
+ !sameValue(top.style, expectedOutlineStyle) ||
275
+ !sameValue(bottom.style, expectedOutlineStyle) ||
276
+ top.arrowStart !== "none" ||
277
+ top.arrowEnd !== "none" ||
278
+ bottom.arrowStart !== "none" ||
279
+ bottom.arrowEnd !== "none"
280
+ ) {
281
+ return null;
282
+ }
283
+ return {
284
+ prefix,
285
+ element: shapeElement({
286
+ ...group,
287
+ kind: "shape",
288
+ style: left.style,
289
+ ...(label
290
+ ? {
291
+ text: label.text,
292
+ textLayout: label.textLayout,
293
+ }
294
+ : {}),
295
+ }, index, options, "stadium"),
296
+ };
297
+ }
298
+
162
299
  function groupElement(node, index, options) {
163
300
  if (!hasVisibleStyle(node.style) && node.text === undefined) return null;
164
301
  return shapeElement(node, index, options, options.groupPreset);
@@ -173,6 +310,10 @@ function textElement(node, index, options) {
173
310
  paragraphs: text.paragraphs,
174
311
  };
175
312
  copyDefined(element, node.textLayout || {}, ["textInsets", "textWrap"]);
313
+ if (node.rotation !== undefined) {
314
+ element.rotation = node.rotation;
315
+ copyDefined(element, node.textLayout || {}, ["verticalAlignment"]);
316
+ }
176
317
  return element;
177
318
  }
178
319
 
@@ -249,7 +390,19 @@ export function sceneToPptxElements(scene, options = {}) {
249
390
  const normalizedOptions = normalizeOptions(scene, options);
250
391
  const elements = [];
251
392
  const fallbacks = [];
393
+ const collapsedPrefixes = [];
252
394
  scene.nodes.forEach((node, index) => {
395
+ if (collapsedPrefixes.some((prefix) =>
396
+ node.sourcePath?.startsWith(prefix))) {
397
+ return;
398
+ }
399
+ const stadium = stadiumMapping(scene, node, index, normalizedOptions);
400
+ if (stadium) {
401
+ if (stadium.fallback) fallbacks.push(stadium.fallback);
402
+ else elements.push(stadium.element);
403
+ collapsedPrefixes.push(stadium.prefix);
404
+ return;
405
+ }
253
406
  const mapped = mappedNode(node, index, normalizedOptions);
254
407
  if (!mapped) return;
255
408
  if (mapped.reason !== undefined && !["shape", "text", "image", "connector"].includes(mapped.type)) {