sketchmark 0.1.2 → 0.1.4

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/dist/index.js CHANGED
@@ -22,6 +22,7 @@ const KEYWORDS = new Set([
22
22
  "step",
23
23
  "config",
24
24
  "theme",
25
+ "bare",
25
26
  "bar-chart",
26
27
  "line-chart",
27
28
  "pie-chart",
@@ -61,6 +62,7 @@ const KEYWORDS = new Set([
61
62
  "dag",
62
63
  "tree",
63
64
  "force",
65
+ "markdown",
64
66
  ]);
65
67
  const ARROW_PATTERNS = ["<-->", "<->", "-->", "<--", "->", "<-", "---", "--"];
66
68
  // Characters that can start an arrow pattern — used to decide whether a '-'
@@ -98,6 +100,23 @@ function tokenize(src) {
98
100
  i++;
99
101
  continue;
100
102
  }
103
+ if (ch === '"' && peek(1) === '"' && peek(2) === '"') {
104
+ i += 3; // skip opening """
105
+ let raw = "";
106
+ while (i < src.length) {
107
+ if (src[i] === '"' && src[i + 1] === '"' && src[i + 2] === '"') {
108
+ i += 3; // skip closing """
109
+ break;
110
+ }
111
+ if (src[i] === "\n") {
112
+ line++;
113
+ lineStart = i + 1;
114
+ }
115
+ raw += src[i++];
116
+ }
117
+ add("STRING_BLOCK", raw);
118
+ continue;
119
+ }
101
120
  // Strings
102
121
  if (ch === '"' || ch === "'") {
103
122
  const q = ch;
@@ -248,14 +267,16 @@ function propsToStyle(p) {
248
267
  s.fontSize = parseFloat(p["font-size"]);
249
268
  if (p["font-weight"])
250
269
  s.fontWeight = p["font-weight"];
251
- if (p['text-align'])
252
- s.textAlign = p['text-align'];
253
- if (p['vertical-align'])
254
- s.verticalAlign = p['vertical-align'];
255
- if (p['line-height'])
256
- s.lineHeight = parseFloat(p['line-height']);
257
- if (p['letter-spacing'])
258
- s.letterSpacing = parseFloat(p['letter-spacing']);
270
+ if (p["text-align"])
271
+ s.textAlign = p["text-align"];
272
+ if (p.padding)
273
+ s.padding = parseFloat(p.padding);
274
+ if (p["vertical-align"])
275
+ s.verticalAlign = p["vertical-align"];
276
+ if (p["line-height"])
277
+ s.lineHeight = parseFloat(p["line-height"]);
278
+ if (p["letter-spacing"])
279
+ s.letterSpacing = parseFloat(p["letter-spacing"]);
259
280
  if (p.font)
260
281
  s.font = p.font;
261
282
  if (p["dash"]) {
@@ -295,6 +316,7 @@ function parse(src) {
295
316
  notes: [],
296
317
  charts: [],
297
318
  tables: [],
319
+ markdowns: [],
298
320
  styles: {},
299
321
  themes: {},
300
322
  config: {},
@@ -305,6 +327,7 @@ function parse(src) {
305
327
  const noteIds = new Set();
306
328
  const chartIds = new Set();
307
329
  const groupIds = new Set();
330
+ const markdownIds = new Set();
308
331
  let i = 0;
309
332
  const cur = () => flat[i] ?? flat[flat.length - 1];
310
333
  const peek1 = () => flat[i + 1] ?? flat[flat.length - 1];
@@ -518,7 +541,7 @@ function parse(src) {
518
541
  const group = {
519
542
  kind: "group",
520
543
  id,
521
- label: props.label ?? id,
544
+ label: props.label ?? "",
522
545
  children: [],
523
546
  layout: props.layout,
524
547
  columns: props.columns !== undefined ? parseInt(props.columns, 10) : undefined,
@@ -544,8 +567,18 @@ function parse(src) {
544
567
  break;
545
568
  const v = cur().value;
546
569
  // ── Nested group ──────────────────────────────────
547
- if (v === "group") {
570
+ if (v === "group" || v === "bare") {
571
+ const isBare = v === "bare";
548
572
  const nested = parseGroup();
573
+ if (isBare) {
574
+ nested.label = "";
575
+ nested.style = {
576
+ ...nested.style,
577
+ fill: nested.style?.fill ?? "none",
578
+ stroke: nested.style?.stroke ?? "none",
579
+ strokeWidth: nested.style?.strokeWidth ?? 0,
580
+ };
581
+ }
549
582
  ast.groups.push(nested);
550
583
  groupIds.add(nested.id);
551
584
  group.children.push({ kind: "group", id: nested.id });
@@ -567,6 +600,21 @@ function parse(src) {
567
600
  group.children.push({ kind: "note", id: note.id });
568
601
  continue;
569
602
  }
603
+ // ── Markdown ───────────────────────────────────────
604
+ if (v === "markdown") {
605
+ const md = parseMarkdown(id);
606
+ ast.markdowns.push(md);
607
+ markdownIds.add(md.id);
608
+ group.children.push({ kind: "markdown", id: md.id });
609
+ continue;
610
+ }
611
+ if (v === "bare") {
612
+ // treat exactly like 'group' but inject defaults
613
+ const grp = parseGroup(); // reuse parseGroup
614
+ grp.label = "";
615
+ grp.style = { ...grp.style, stroke: "none", fill: "none" };
616
+ // rest is identical to group handling
617
+ }
570
618
  // ── Chart ──────────────────────────────────────────
571
619
  if (CHART_TYPES.includes(v)) {
572
620
  const chart = parseChart(v);
@@ -610,71 +658,71 @@ function parse(src) {
610
658
  function parseStep() {
611
659
  skip();
612
660
  const toks = lineTokens();
613
- const action = (toks[0]?.value ?? 'highlight');
614
- let target = toks[1]?.value ?? '';
615
- if (toks[2]?.type === 'ARROW' && toks[3]) {
661
+ const action = (toks[0]?.value ?? "highlight");
662
+ let target = toks[1]?.value ?? "";
663
+ if (toks[2]?.type === "ARROW" && toks[3]) {
616
664
  target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
617
665
  }
618
- const step = { kind: 'step', action, target };
666
+ const step = { kind: "step", action, target };
619
667
  for (let j = 2; j < toks.length; j++) {
620
668
  const k = toks[j]?.value;
621
669
  const eq = toks[j + 1];
622
670
  const vt = toks[j + 2];
623
671
  // key=value form
624
- if (eq?.type === 'EQUALS' && vt) {
625
- if (k === 'dx') {
672
+ if (eq?.type === "EQUALS" && vt) {
673
+ if (k === "dx") {
626
674
  step.dx = parseFloat(vt.value);
627
675
  j += 2;
628
676
  continue;
629
677
  }
630
- if (k === 'dy') {
678
+ if (k === "dy") {
631
679
  step.dy = parseFloat(vt.value);
632
680
  j += 2;
633
681
  continue;
634
682
  }
635
- if (k === 'duration') {
683
+ if (k === "duration") {
636
684
  step.duration = parseFloat(vt.value);
637
685
  j += 2;
638
686
  continue;
639
687
  }
640
- if (k === 'delay') {
688
+ if (k === "delay") {
641
689
  step.delay = parseFloat(vt.value);
642
690
  j += 2;
643
691
  continue;
644
692
  }
645
- if (k === 'factor') {
693
+ if (k === "factor") {
646
694
  step.factor = parseFloat(vt.value);
647
695
  j += 2;
648
696
  continue;
649
697
  }
650
- if (k === 'deg') {
698
+ if (k === "deg") {
651
699
  step.deg = parseFloat(vt.value);
652
700
  j += 2;
653
701
  continue;
654
702
  }
655
- if (k === 'fill') {
703
+ if (k === "fill") {
656
704
  step.value = vt.value;
657
705
  j += 2;
658
706
  continue;
659
707
  }
660
- if (k === 'color') {
708
+ if (k === "color") {
661
709
  step.value = vt.value;
662
710
  j += 2;
663
711
  continue;
664
712
  }
665
713
  }
666
714
  // bare key value (legacy)
667
- if (k === 'delay' && eq?.type === 'NUMBER') {
715
+ if (k === "delay" && eq?.type === "NUMBER") {
668
716
  step.delay = parseFloat(eq.value);
669
717
  j++;
670
718
  continue;
671
719
  }
672
- if (k === 'duration' && eq?.type === 'NUMBER') {
720
+ if (k === "duration" && eq?.type === "NUMBER") {
673
721
  step.duration = parseFloat(eq.value);
674
722
  j++;
675
723
  continue;
676
724
  }
677
- if (k === 'trigger') {
725
+ if (k === "trigger") {
678
726
  step.trigger = eq?.value;
679
727
  j++;
680
728
  continue;
@@ -874,6 +922,40 @@ function parse(src) {
874
922
  skip();
875
923
  return table;
876
924
  }
925
+ // ── parseMarkdown ─────────────────────────────────────────
926
+ function parseMarkdown(groupId) {
927
+ skip(); // 'markdown'
928
+ const toks = lineTokens();
929
+ let id = groupId ? groupId + "_" + uid("md") : uid("md");
930
+ if (toks[0])
931
+ id = toks[0].value;
932
+ const props = {};
933
+ let j = 1;
934
+ while (j < toks.length - 1) {
935
+ const k = toks[j], eq = toks[j + 1];
936
+ if (eq?.type === "EQUALS" && j + 2 < toks.length) {
937
+ props[k.value] = toks[j + 2].value;
938
+ j += 3;
939
+ }
940
+ else
941
+ j++;
942
+ }
943
+ skipNL();
944
+ let content = "";
945
+ if (cur().type === "STRING_BLOCK") {
946
+ content = cur().value;
947
+ skip();
948
+ }
949
+ return {
950
+ kind: "markdown",
951
+ id,
952
+ content: content.trim(),
953
+ width: props.width ? parseFloat(props.width) : undefined,
954
+ height: props.height ? parseFloat(props.height) : undefined,
955
+ theme: props.theme,
956
+ style: propsToStyle(props),
957
+ };
958
+ }
877
959
  // ── Main parse loop ─────────────────────────────────────
878
960
  skipNL();
879
961
  if (cur().value === "diagram")
@@ -987,8 +1069,18 @@ function parse(src) {
987
1069
  continue;
988
1070
  }
989
1071
  // group
990
- if (v === "group") {
1072
+ if (v === "group" || v === "bare") {
1073
+ const isBare = v === "bare";
991
1074
  const grp = parseGroup();
1075
+ if (isBare) {
1076
+ grp.label = "";
1077
+ grp.style = {
1078
+ ...grp.style,
1079
+ fill: grp.style?.fill ?? "none",
1080
+ stroke: grp.style?.stroke ?? "none",
1081
+ strokeWidth: grp.style?.strokeWidth ?? 0,
1082
+ };
1083
+ }
992
1084
  ast.groups.push(grp);
993
1085
  groupIds.add(grp.id);
994
1086
  ast.rootOrder.push({ kind: "group", id: grp.id });
@@ -1023,6 +1115,13 @@ function parse(src) {
1023
1115
  ast.rootOrder.push({ kind: "chart", id: chart.id }); // ← ADD
1024
1116
  continue;
1025
1117
  }
1118
+ if (v === "markdown") {
1119
+ const md = parseMarkdown();
1120
+ ast.markdowns.push(md);
1121
+ markdownIds.add(md.id);
1122
+ ast.rootOrder.push({ kind: "markdown", id: md.id });
1123
+ continue;
1124
+ }
1026
1125
  // edge: A -> B (MUST come before shape check)
1027
1126
  if (t.type === "IDENT" || t.type === "STRING" || t.type === "KEYWORD") {
1028
1127
  const nextTok = flat[i + 1];
@@ -1075,6 +1174,91 @@ function parse(src) {
1075
1174
  return ast;
1076
1175
  }
1077
1176
 
1177
+ // ============================================================
1178
+ // sketchmark — Markdown inline parser
1179
+ // Supports: # h1 ## h2 ### h3 **bold** *italic* blank lines
1180
+ // ============================================================
1181
+ // ── Font sizes per line kind ──────────────────────────────
1182
+ const LINE_FONT_SIZE = {
1183
+ h1: 40,
1184
+ h2: 28,
1185
+ h3: 20,
1186
+ p: 15,
1187
+ blank: 0,
1188
+ };
1189
+ const LINE_FONT_WEIGHT = {
1190
+ h1: 700,
1191
+ h2: 600,
1192
+ h3: 600,
1193
+ p: 400,
1194
+ blank: 400,
1195
+ };
1196
+ // Spacing below each line kind (px)
1197
+ const LINE_SPACING = {
1198
+ h1: 52,
1199
+ h2: 38,
1200
+ h3: 28,
1201
+ p: 22,
1202
+ blank: 10,
1203
+ };
1204
+ // ── Parse a full markdown string into lines ───────────────
1205
+ function parseMarkdownContent(content) {
1206
+ const raw = content.split('\n');
1207
+ const lines = [];
1208
+ for (const line of raw) {
1209
+ const t = line.trim();
1210
+ if (!t) {
1211
+ lines.push({ kind: 'blank', runs: [] });
1212
+ continue;
1213
+ }
1214
+ if (t.startsWith('### ')) {
1215
+ lines.push({ kind: 'h3', runs: parseInline(t.slice(4)) });
1216
+ }
1217
+ else if (t.startsWith('## ')) {
1218
+ lines.push({ kind: 'h2', runs: parseInline(t.slice(3)) });
1219
+ }
1220
+ else if (t.startsWith('# ')) {
1221
+ lines.push({ kind: 'h1', runs: parseInline(t.slice(2)) });
1222
+ }
1223
+ else {
1224
+ lines.push({ kind: 'p', runs: parseInline(t) });
1225
+ }
1226
+ }
1227
+ // strip leading/trailing blank lines
1228
+ while (lines.length && lines[0].kind === 'blank')
1229
+ lines.shift();
1230
+ while (lines.length && lines[lines.length - 1].kind === 'blank')
1231
+ lines.pop();
1232
+ return lines;
1233
+ }
1234
+ // ── Parse inline bold/italic spans ───────────────────────
1235
+ function parseInline(text) {
1236
+ const runs = [];
1237
+ // Order matters: check ** before *
1238
+ const re = /(\*\*(.+?)\*\*|\*(.+?)\*|[^*]+)/g;
1239
+ let m;
1240
+ while ((m = re.exec(text)) !== null) {
1241
+ if (m[0].startsWith('**')) {
1242
+ runs.push({ text: m[2], bold: true });
1243
+ }
1244
+ else if (m[0].startsWith('*')) {
1245
+ runs.push({ text: m[3], italic: true });
1246
+ }
1247
+ else {
1248
+ if (m[0])
1249
+ runs.push({ text: m[0] });
1250
+ }
1251
+ }
1252
+ return runs;
1253
+ }
1254
+ // ── Calculate natural height of a parsed block ────────────
1255
+ function calcMarkdownHeight(lines, pad = 16) {
1256
+ let h = pad * 2; // top + bottom
1257
+ for (const line of lines)
1258
+ h += LINE_SPACING[line.kind];
1259
+ return h;
1260
+ }
1261
+
1078
1262
  // ============================================================
1079
1263
  // sketchmark — Scene Graph
1080
1264
  // ============================================================
@@ -1165,6 +1349,18 @@ function buildSceneGraph(ast) {
1165
1349
  h: c.height ?? 240,
1166
1350
  };
1167
1351
  });
1352
+ const markdowns = (ast.markdowns ?? []).map(m => {
1353
+ const themeStyle = m.theme ? (ast.themes[m.theme] ?? {}) : {};
1354
+ return {
1355
+ id: m.id,
1356
+ content: m.content,
1357
+ lines: parseMarkdownContent(m.content),
1358
+ style: { ...themeStyle, ...m.style },
1359
+ width: m.width,
1360
+ height: m.height,
1361
+ x: 0, y: 0, w: 0, h: 0,
1362
+ };
1363
+ });
1168
1364
  // Set parentId for nested groups
1169
1365
  for (const g of groups) {
1170
1366
  for (const child of g.children) {
@@ -1195,6 +1391,7 @@ function buildSceneGraph(ast) {
1195
1391
  tables,
1196
1392
  notes,
1197
1393
  charts,
1394
+ markdowns,
1198
1395
  animation: { steps: ast.steps, currentStep: -1 },
1199
1396
  styles: ast.styles,
1200
1397
  config: ast.config,
@@ -1219,6 +1416,9 @@ function noteMap(sg) {
1219
1416
  function chartMap(sg) {
1220
1417
  return new Map(sg.charts.map((c) => [c.id, c]));
1221
1418
  }
1419
+ function markdownMap(sg) {
1420
+ return new Map((sg.markdowns ?? []).map(m => [m.id, m]));
1421
+ }
1222
1422
 
1223
1423
  // ============================================================
1224
1424
  // sketchmark — Layout Engine (Flexbox-style, recursive)
@@ -1259,30 +1459,40 @@ function sizeNode(n) {
1259
1459
  n.h = n.height;
1260
1460
  const labelW = Math.round(n.label.length * FONT_PX_PER_CHAR + BASE_PAD);
1261
1461
  switch (n.shape) {
1262
- case 'circle':
1462
+ case "circle":
1263
1463
  n.w = n.w || Math.max(84, Math.min(MAX_W, labelW));
1264
1464
  n.h = n.h || n.w;
1265
1465
  break;
1266
- case 'diamond':
1466
+ case "diamond":
1267
1467
  n.w = n.w || Math.max(130, Math.min(MAX_W, labelW + 30));
1268
1468
  n.h = n.h || Math.max(62, n.w * 0.46);
1269
1469
  break;
1270
- case 'hexagon':
1470
+ case "hexagon":
1271
1471
  n.w = n.w || Math.max(126, Math.min(MAX_W, labelW + 20));
1272
1472
  n.h = n.h || Math.max(54, n.w * 0.44);
1273
1473
  break;
1274
- case 'triangle':
1474
+ case "triangle":
1275
1475
  n.w = n.w || Math.max(108, Math.min(MAX_W, labelW + 10));
1276
- n.h = n.h || Math.max(64, n.w * 0.60);
1476
+ n.h = n.h || Math.max(64, n.w * 0.6);
1277
1477
  break;
1278
- case 'cylinder':
1478
+ case "cylinder":
1279
1479
  n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1280
1480
  n.h = n.h || 66;
1281
1481
  break;
1282
- case 'parallelogram':
1482
+ case "parallelogram":
1283
1483
  n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW + 28));
1284
1484
  n.h = n.h || 50;
1285
1485
  break;
1486
+ case "text": {
1487
+ // read fontSize from style if set, otherwise use default
1488
+ const fontSize = Number(n.style?.fontSize ?? 13);
1489
+ const charWidth = fontSize * 0.55;
1490
+ const maxW = n.width ?? 400;
1491
+ const approxLines = Math.ceil((n.label.length * charWidth) / (maxW - 16));
1492
+ n.w = maxW;
1493
+ n.h = n.height ?? Math.max(24, approxLines * fontSize * 1.5 + 8);
1494
+ break;
1495
+ }
1286
1496
  default:
1287
1497
  n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1288
1498
  n.h = n.h || 52;
@@ -1290,7 +1500,7 @@ function sizeNode(n) {
1290
1500
  }
1291
1501
  }
1292
1502
  function sizeNote(n) {
1293
- const maxChars = Math.max(...n.lines.map(l => l.length));
1503
+ const maxChars = Math.max(...n.lines.map((l) => l.length));
1294
1504
  n.w = Math.max(120, Math.ceil(maxChars * NOTE_FONT) + NOTE_PAD_X * 2);
1295
1505
  n.h = n.lines.length * NOTE_LINE_H + NOTE_PAD_Y * 2;
1296
1506
  if (n.width && n.w < n.width)
@@ -1306,7 +1516,7 @@ function sizeTable(t) {
1306
1516
  t.h = labelH + rowH;
1307
1517
  return;
1308
1518
  }
1309
- const numCols = Math.max(...rows.map(r => r.cells.length));
1519
+ const numCols = Math.max(...rows.map((r) => r.cells.length));
1310
1520
  const colW = Array(numCols).fill(MIN_COL_W);
1311
1521
  for (const row of rows) {
1312
1522
  row.cells.forEach((cell, i) => {
@@ -1315,110 +1525,128 @@ function sizeTable(t) {
1315
1525
  }
1316
1526
  t.colWidths = colW;
1317
1527
  t.w = colW.reduce((s, w) => s + w, 0);
1318
- const nHeader = rows.filter(r => r.kind === 'header').length;
1319
- const nData = rows.filter(r => r.kind === 'data').length;
1528
+ const nHeader = rows.filter((r) => r.kind === "header").length;
1529
+ const nData = rows.filter((r) => r.kind === "data").length;
1320
1530
  t.h = labelH + nHeader * headerH + nData * rowH;
1321
1531
  }
1322
1532
  function sizeChart(c) {
1323
1533
  c.w = c.w || 320;
1324
1534
  c.h = c.h || 240;
1325
1535
  }
1536
+ function sizeMarkdown(m) {
1537
+ const pad = Number(m.style?.padding ?? 16);
1538
+ m.w = m.width ?? 400;
1539
+ m.h = m.height ?? calcMarkdownHeight(m.lines, pad);
1540
+ }
1326
1541
  // ── Item size helpers ─────────────────────────────────────
1327
- function iW(r, nm, gm, tm, ntm, cm) {
1328
- if (r.kind === 'node')
1542
+ function iW(r, nm, gm, tm, ntm, cm, mdm) {
1543
+ if (r.kind === "node")
1329
1544
  return nm.get(r.id).w;
1330
- if (r.kind === 'table')
1545
+ if (r.kind === "table")
1331
1546
  return tm.get(r.id).w;
1332
- if (r.kind === 'note')
1547
+ if (r.kind === "note")
1333
1548
  return ntm.get(r.id).w;
1334
- if (r.kind === 'chart')
1549
+ if (r.kind === "chart")
1335
1550
  return cm.get(r.id).w;
1551
+ if (r.kind === "markdown")
1552
+ return mdm.get(r.id).w;
1336
1553
  return gm.get(r.id).w;
1337
1554
  }
1338
- function iH(r, nm, gm, tm, ntm, cm) {
1339
- if (r.kind === 'node')
1555
+ function iH(r, nm, gm, tm, ntm, cm, mdm) {
1556
+ if (r.kind === "node")
1340
1557
  return nm.get(r.id).h;
1341
- if (r.kind === 'table')
1558
+ if (r.kind === "table")
1342
1559
  return tm.get(r.id).h;
1343
- if (r.kind === 'note')
1560
+ if (r.kind === "note")
1344
1561
  return ntm.get(r.id).h;
1345
- if (r.kind === 'chart')
1562
+ if (r.kind === "chart")
1346
1563
  return cm.get(r.id).h;
1564
+ if (r.kind === "markdown")
1565
+ return mdm.get(r.id).h;
1347
1566
  return gm.get(r.id).h;
1348
1567
  }
1349
- function setPos(r, x, y, nm, gm, tm, ntm, cm) {
1350
- if (r.kind === 'node') {
1568
+ function setPos(r, x, y, nm, gm, tm, ntm, cm, mdm) {
1569
+ if (r.kind === "node") {
1351
1570
  const n = nm.get(r.id);
1352
1571
  n.x = Math.round(x);
1353
1572
  n.y = Math.round(y);
1354
1573
  return;
1355
1574
  }
1356
- if (r.kind === 'table') {
1575
+ if (r.kind === "table") {
1357
1576
  const t = tm.get(r.id);
1358
1577
  t.x = Math.round(x);
1359
1578
  t.y = Math.round(y);
1360
1579
  return;
1361
1580
  }
1362
- if (r.kind === 'note') {
1581
+ if (r.kind === "note") {
1363
1582
  const nt = ntm.get(r.id);
1364
1583
  nt.x = Math.round(x);
1365
1584
  nt.y = Math.round(y);
1366
1585
  return;
1367
1586
  }
1368
- if (r.kind === 'chart') {
1587
+ if (r.kind === "chart") {
1369
1588
  const c = cm.get(r.id);
1370
1589
  c.x = Math.round(x);
1371
1590
  c.y = Math.round(y);
1372
1591
  return;
1373
1592
  }
1593
+ if (r.kind === "markdown") {
1594
+ const md = mdm.get(r.id);
1595
+ md.x = Math.round(x);
1596
+ md.y = Math.round(y);
1597
+ return;
1598
+ }
1374
1599
  const g = gm.get(r.id);
1375
1600
  g.x = Math.round(x);
1376
1601
  g.y = Math.round(y);
1377
1602
  }
1378
1603
  // ── Pass 1: Measure (bottom-up) ───────────────────────────
1379
1604
  // Recursively computes w, h for a group from its children's sizes.
1380
- function measure(g, nm, gm, tm, ntm, cm) {
1605
+ function measure(g, nm, gm, tm, ntm, cm, mdm) {
1381
1606
  // Recurse into nested groups first; size tables before reading their dims
1382
1607
  for (const r of g.children) {
1383
- if (r.kind === 'group')
1384
- measure(gm.get(r.id), nm, gm, tm, ntm, cm);
1385
- if (r.kind === 'table')
1608
+ if (r.kind === "group")
1609
+ measure(gm.get(r.id), nm, gm, tm, ntm, cm, mdm);
1610
+ if (r.kind === "table")
1386
1611
  sizeTable(tm.get(r.id));
1387
- if (r.kind === 'note')
1612
+ if (r.kind === "note")
1388
1613
  sizeNote(ntm.get(r.id));
1389
- if (r.kind === 'chart')
1614
+ if (r.kind === "chart")
1390
1615
  sizeChart(cm.get(r.id));
1616
+ if (r.kind === "markdown")
1617
+ sizeMarkdown(mdm.get(r.id));
1391
1618
  }
1392
1619
  const { padding: pad, gap, columns, layout } = g;
1393
1620
  const kids = g.children;
1621
+ const labelH = g.label ? GROUP_LABEL_H : 0;
1394
1622
  if (!kids.length) {
1395
1623
  g.w = pad * 2;
1396
- g.h = pad * 2 + GROUP_LABEL_H;
1624
+ g.h = pad * 2 + labelH;
1397
1625
  if (g.width && g.w < g.width)
1398
1626
  g.w = g.width;
1399
1627
  if (g.height && g.h < g.height)
1400
1628
  g.h = g.height;
1401
1629
  return;
1402
1630
  }
1403
- const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1404
- const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
1631
+ const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
1632
+ const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
1405
1633
  const n = kids.length;
1406
- if (layout === 'row') {
1634
+ if (layout === "row") {
1407
1635
  g.w = ws.reduce((s, w) => s + w, 0) + gap * (n - 1) + pad * 2;
1408
- g.h = Math.max(...hs) + pad * 2 + GROUP_LABEL_H;
1636
+ g.h = Math.max(...hs) + pad * 2 + labelH;
1409
1637
  }
1410
- else if (layout === 'grid') {
1638
+ else if (layout === "grid") {
1411
1639
  const cols = Math.max(1, columns);
1412
1640
  const rows = Math.ceil(n / cols);
1413
1641
  const cellW = Math.max(...ws);
1414
1642
  const cellH = Math.max(...hs);
1415
1643
  g.w = cols * cellW + (cols - 1) * gap + pad * 2;
1416
- g.h = rows * cellH + (rows - 1) * gap + pad * 2 + GROUP_LABEL_H;
1644
+ g.h = rows * cellH + (rows - 1) * gap + pad * 2 + labelH;
1417
1645
  }
1418
1646
  else {
1419
1647
  // column (default)
1420
1648
  g.w = Math.max(...ws) + pad * 2;
1421
- g.h = hs.reduce((s, h) => s + h, 0) + gap * (n - 1) + pad * 2 + GROUP_LABEL_H;
1649
+ g.h = hs.reduce((s, h) => s + h, 0) + gap * (n - 1) + pad * 2 + labelH;
1422
1650
  }
1423
1651
  // Clamp to minWidth / minHeight — this is what gives distribute() free
1424
1652
  // space to work with for justify=center/end/space-between/space-around
@@ -1433,21 +1661,32 @@ function distribute(sizes, contentSize, gap, justify) {
1433
1661
  const totalSize = sizes.reduce((s, v) => s + v, 0);
1434
1662
  const gapCount = n - 1;
1435
1663
  switch (justify) {
1436
- case 'center': {
1664
+ case "center": {
1437
1665
  const total = totalSize + gap * gapCount;
1438
- return { start: Math.max(0, (contentSize - total) / 2), gaps: Array(gapCount).fill(gap) };
1666
+ return {
1667
+ start: Math.max(0, (contentSize - total) / 2),
1668
+ gaps: Array(gapCount).fill(gap),
1669
+ };
1439
1670
  }
1440
- case 'end': {
1671
+ case "end": {
1441
1672
  const total = totalSize + gap * gapCount;
1442
- return { start: Math.max(0, contentSize - total), gaps: Array(gapCount).fill(gap) };
1673
+ return {
1674
+ start: Math.max(0, contentSize - total),
1675
+ gaps: Array(gapCount).fill(gap),
1676
+ };
1443
1677
  }
1444
- case 'space-between': {
1445
- const g2 = gapCount > 0 ? Math.max(gap, (contentSize - totalSize) / gapCount) : gap;
1678
+ case "space-between": {
1679
+ const g2 = gapCount > 0
1680
+ ? Math.max(gap, (contentSize - totalSize) / gapCount)
1681
+ : gap;
1446
1682
  return { start: 0, gaps: Array(gapCount).fill(g2) };
1447
1683
  }
1448
- case 'space-around': {
1684
+ case "space-around": {
1449
1685
  const space = n > 0 ? (contentSize - totalSize) / n : gap;
1450
- return { start: Math.max(0, space / 2), gaps: Array(gapCount).fill(Math.max(gap, space)) };
1686
+ return {
1687
+ start: Math.max(0, space / 2),
1688
+ gaps: Array(gapCount).fill(Math.max(gap, space)),
1689
+ };
1451
1690
  }
1452
1691
  default: // start
1453
1692
  return { start: 0, gaps: Array(gapCount).fill(gap) };
@@ -1455,70 +1694,73 @@ function distribute(sizes, contentSize, gap, justify) {
1455
1694
  }
1456
1695
  // ── Pass 2: Place (top-down) ──────────────────────────────
1457
1696
  // Assigns x, y to each child. Assumes g.x / g.y already set by parent.
1458
- function place(g, nm, gm, tm, ntm, cm) {
1697
+ function place(g, nm, gm, tm, ntm, cm, mdm) {
1459
1698
  const { padding: pad, gap, columns, layout, align, justify } = g;
1699
+ const labelH = g.label ? GROUP_LABEL_H : 0;
1460
1700
  const contentX = g.x + pad;
1461
- const contentY = g.y + GROUP_LABEL_H + pad;
1701
+ const contentY = g.y + labelH + pad;
1462
1702
  const contentW = g.w - pad * 2;
1463
- const contentH = g.h - pad * 2 - GROUP_LABEL_H;
1703
+ const contentH = g.h - pad * 2 - labelH;
1464
1704
  const kids = g.children;
1465
1705
  if (!kids.length)
1466
1706
  return;
1467
- if (layout === 'row') {
1468
- const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1469
- const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
1707
+ if (layout === "row") {
1708
+ const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
1709
+ const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
1470
1710
  const maxH = Math.max(...hs);
1471
1711
  const { start, gaps } = distribute(ws, contentW, gap, justify);
1472
1712
  let x = contentX + start;
1473
1713
  for (let i = 0; i < kids.length; i++) {
1474
1714
  let y;
1475
1715
  switch (align) {
1476
- case 'center':
1716
+ case "center":
1477
1717
  y = contentY + (maxH - hs[i]) / 2;
1478
1718
  break;
1479
- case 'end':
1719
+ case "end":
1480
1720
  y = contentY + maxH - hs[i];
1481
1721
  break;
1482
- default: y = contentY;
1722
+ default:
1723
+ y = contentY;
1483
1724
  }
1484
- setPos(kids[i], x, y, nm, gm, tm, ntm, cm);
1725
+ setPos(kids[i], x, y, nm, gm, tm, ntm, cm, mdm);
1485
1726
  x += ws[i] + (i < gaps.length ? gaps[i] : 0);
1486
1727
  }
1487
1728
  }
1488
- else if (layout === 'grid') {
1729
+ else if (layout === "grid") {
1489
1730
  const cols = Math.max(1, columns);
1490
- const cellW = Math.max(...kids.map(r => iW(r, nm, gm, tm, ntm, cm)));
1491
- const cellH = Math.max(...kids.map(r => iH(r, nm, gm, tm, ntm, cm)));
1731
+ const cellW = Math.max(...kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm)));
1732
+ const cellH = Math.max(...kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm)));
1492
1733
  kids.forEach((ref, i) => {
1493
- setPos(ref, contentX + (i % cols) * (cellW + gap), contentY + Math.floor(i / cols) * (cellH + gap), nm, gm, tm, ntm, cm);
1734
+ setPos(ref, contentX + (i % cols) * (cellW + gap), contentY + Math.floor(i / cols) * (cellH + gap), nm, gm, tm, ntm, cm, mdm);
1494
1735
  });
1495
1736
  }
1496
1737
  else {
1497
1738
  // column (default)
1498
- const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1499
- const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
1739
+ const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
1740
+ const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
1500
1741
  const maxW = Math.max(...ws);
1501
1742
  const { start, gaps } = distribute(hs, contentH, gap, justify);
1502
1743
  let y = contentY + start;
1503
1744
  for (let i = 0; i < kids.length; i++) {
1504
1745
  let x;
1505
1746
  switch (align) {
1506
- case 'center':
1747
+ case "center":
1507
1748
  x = contentX + (maxW - ws[i]) / 2;
1508
1749
  break;
1509
- case 'end':
1750
+ case "end":
1510
1751
  x = contentX + maxW - ws[i];
1511
1752
  break;
1512
- default: x = contentX;
1753
+ default:
1754
+ x = contentX;
1513
1755
  }
1514
- setPos(kids[i], x, y, nm, gm, tm, ntm, cm);
1756
+ setPos(kids[i], x, y, nm, gm, tm, ntm, cm, mdm);
1515
1757
  y += hs[i] + (i < gaps.length ? gaps[i] : 0);
1516
1758
  }
1517
1759
  }
1518
1760
  // Recurse into nested groups
1519
1761
  for (const r of kids) {
1520
- if (r.kind === 'group')
1521
- place(gm.get(r.id), nm, gm, tm, ntm, cm);
1762
+ if (r.kind === "group")
1763
+ place(gm.get(r.id), nm, gm, tm, ntm, cm, mdm);
1522
1764
  }
1523
1765
  }
1524
1766
  // ── Edge routing ──────────────────────────────────────────
@@ -1528,9 +1770,9 @@ function connPoint(n, other) {
1528
1770
  const dx = ox - cx, dy = oy - cy;
1529
1771
  if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
1530
1772
  return [cx, cy];
1531
- if (n.shape === 'circle') {
1773
+ if (n.shape === "circle") {
1532
1774
  const r = n.w * 0.44, len = Math.sqrt(dx * dx + dy * dy);
1533
- return [cx + dx / len * r, cy + dy / len * r];
1775
+ return [cx + (dx / len) * r, cy + (dy / len) * r];
1534
1776
  }
1535
1777
  const hw = n.w / 2 - 2, hh = n.h / 2 - 2;
1536
1778
  const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
@@ -1575,9 +1817,12 @@ function routeEdges(sg) {
1575
1817
  }
1576
1818
  function connPt(src, dstCX, dstCY) {
1577
1819
  // SceneNode has a .shape field; use the existing connPoint for it
1578
- if ('shape' in src && src.shape) {
1820
+ if ("shape" in src && src.shape) {
1579
1821
  return connPoint(src, {
1580
- x: dstCX - 1, y: dstCY - 1, w: 2, h: 2});
1822
+ x: dstCX - 1,
1823
+ y: dstCY - 1,
1824
+ w: 2,
1825
+ h: 2});
1581
1826
  }
1582
1827
  return rectConnPoint$2(src.x, src.y, src.w, src.h, dstCX, dstCY);
1583
1828
  }
@@ -1590,84 +1835,84 @@ function routeEdges(sg) {
1590
1835
  }
1591
1836
  const dstCX = dst.x + dst.w / 2, dstCY = dst.y + dst.h / 2;
1592
1837
  const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
1593
- e.points = [
1594
- connPt(src, dstCX, dstCY),
1595
- connPt(dst, srcCX, srcCY),
1596
- ];
1838
+ e.points = [connPt(src, dstCX, dstCY), connPt(dst, srcCX, srcCY)];
1597
1839
  }
1598
1840
  }
1599
1841
  function computeBounds(sg, margin) {
1600
1842
  const allX = [
1601
- ...sg.nodes.map(n => n.x + n.w),
1602
- ...sg.groups.filter(g => g.w).map(g => g.x + g.w),
1603
- ...sg.tables.map(t => t.x + t.w),
1604
- ...sg.notes.map(n => n.x + n.w),
1605
- ...sg.charts.map(c => c.x + c.w)
1843
+ ...sg.nodes.map((n) => n.x + n.w),
1844
+ ...sg.groups.filter((g) => g.w).map((g) => g.x + g.w),
1845
+ ...sg.tables.map((t) => t.x + t.w),
1846
+ ...sg.notes.map((n) => n.x + n.w),
1847
+ ...sg.charts.map((c) => c.x + c.w),
1848
+ ...sg.markdowns.map((m) => m.x + m.w),
1606
1849
  ];
1607
1850
  const allY = [
1608
- ...sg.nodes.map(n => n.y + n.h),
1609
- ...sg.groups.filter(g => g.h).map(g => g.y + g.h),
1610
- ...sg.tables.map(t => t.y + t.h),
1611
- ...sg.notes.map(n => n.y + n.h),
1612
- ...sg.charts.map(c => c.y + c.h)
1851
+ ...sg.nodes.map((n) => n.y + n.h),
1852
+ ...sg.groups.filter((g) => g.h).map((g) => g.y + g.h),
1853
+ ...sg.tables.map((t) => t.y + t.h),
1854
+ ...sg.notes.map((n) => n.y + n.h),
1855
+ ...sg.charts.map((c) => c.y + c.h),
1856
+ ...sg.markdowns.map((m) => m.y + m.h),
1613
1857
  ];
1614
1858
  sg.width = (allX.length ? Math.max(...allX) : 400) + margin;
1615
1859
  sg.height = (allY.length ? Math.max(...allY) : 300) + margin;
1616
1860
  }
1617
1861
  // ── Public entry point ────────────────────────────────────
1618
1862
  function layout(sg) {
1619
- const GAP_MAIN = Number(sg.config['gap'] ?? DEFAULT_GAP_MAIN);
1620
- const MARGIN = Number(sg.config['margin'] ?? DEFAULT_MARGIN);
1863
+ const GAP_MAIN = Number(sg.config["gap"] ?? DEFAULT_GAP_MAIN);
1864
+ const MARGIN = Number(sg.config["margin"] ?? DEFAULT_MARGIN);
1621
1865
  const nm = nodeMap(sg);
1622
1866
  const gm = groupMap(sg);
1623
1867
  const tm = tableMap(sg);
1624
1868
  const ntm = noteMap(sg);
1625
1869
  const cm = chartMap(sg);
1626
- console.log('[layout] sg.charts:', sg.charts.map(c => c.id));
1627
- console.log('[layout] sg.rootOrder:', sg.rootOrder.map(r => r.kind + ':' + r.id));
1870
+ const mdm = markdownMap(sg);
1628
1871
  // 1. Size all nodes and tables
1629
1872
  sg.nodes.forEach(sizeNode);
1630
1873
  sg.tables.forEach(sizeTable);
1631
1874
  sg.notes.forEach(sizeNote);
1632
1875
  sg.charts.forEach(sizeChart);
1876
+ sg.markdowns.forEach(sizeMarkdown);
1633
1877
  // src/layout/index.ts — after sg.charts.forEach(sizeChart);
1634
1878
  // 2. Identify root vs nested items
1635
- const nestedGroupIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'group').map(c => c.id)));
1636
- const groupedNodeIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'node').map(c => c.id)));
1637
- const groupedTableIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'table').map(c => c.id)));
1638
- const groupedNoteIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'note').map(c => c.id)));
1639
- const groupedChartIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'chart').map(c => c.id)));
1640
- const rootGroups = sg.groups.filter(g => !nestedGroupIds.has(g.id));
1641
- const rootNodes = sg.nodes.filter(n => !groupedNodeIds.has(n.id));
1642
- const rootTables = sg.tables.filter(t => !groupedTableIds.has(t.id));
1643
- const rootNotes = sg.notes.filter(n => !groupedNoteIds.has(n.id));
1644
- const rootCharts = sg.charts.filter(c => !groupedChartIds.has(c.id));
1879
+ const nestedGroupIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "group").map((c) => c.id)));
1880
+ const groupedNodeIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "node").map((c) => c.id)));
1881
+ const groupedTableIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "table").map((c) => c.id)));
1882
+ const groupedNoteIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "note").map((c) => c.id)));
1883
+ const groupedChartIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "chart").map((c) => c.id)));
1884
+ const groupedMarkdownIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "markdown").map((c) => c.id)));
1885
+ const rootGroups = sg.groups.filter((g) => !nestedGroupIds.has(g.id));
1886
+ const rootNodes = sg.nodes.filter((n) => !groupedNodeIds.has(n.id));
1887
+ const rootTables = sg.tables.filter((t) => !groupedTableIds.has(t.id));
1888
+ const rootNotes = sg.notes.filter((n) => !groupedNoteIds.has(n.id));
1889
+ const rootCharts = sg.charts.filter((c) => !groupedChartIds.has(c.id));
1890
+ const rootMarkdowns = sg.markdowns.filter((m) => !groupedMarkdownIds.has(m.id));
1645
1891
  // 3. Measure root groups bottom-up
1646
1892
  for (const g of rootGroups)
1647
- measure(g, nm, gm, tm, ntm, cm);
1893
+ measure(g, nm, gm, tm, ntm, cm, mdm);
1648
1894
  // 4. Build root order
1649
1895
  // sg.rootOrder preserves DSL declaration order.
1650
1896
  // Fall back: groups, then nodes, then tables.
1651
1897
  const rootOrder = sg.rootOrder?.length
1652
1898
  ? sg.rootOrder
1653
1899
  : [
1654
- ...rootGroups.map(g => ({ kind: 'group', id: g.id })),
1655
- ...rootNodes.map(n => ({ kind: 'node', id: n.id })),
1656
- ...rootTables.map(t => ({ kind: 'table', id: t.id })),
1657
- ...rootNotes.map(n => ({ kind: 'note', id: n.id })),
1658
- ...rootCharts.map(c => ({ kind: 'chart', id: c.id }))
1900
+ ...rootGroups.map((g) => ({ kind: "group", id: g.id })),
1901
+ ...rootNodes.map((n) => ({ kind: "node", id: n.id })),
1902
+ ...rootTables.map((t) => ({ kind: "table", id: t.id })),
1903
+ ...rootNotes.map((n) => ({ kind: "note", id: n.id })),
1904
+ ...rootCharts.map((c) => ({ kind: "chart", id: c.id })),
1905
+ ...rootMarkdowns.map((m) => ({ kind: "markdown", id: m.id })),
1659
1906
  ];
1660
1907
  // 5. Root-level layout
1661
1908
  // sg.layout:
1662
1909
  // 'row' → items flow left to right (default)
1663
1910
  // 'column' → items flow top to bottom
1664
1911
  // 'grid' → config columns=N grid
1665
- const rootLayout = (sg.layout ?? 'row');
1666
- const rootCols = Number(sg.config['columns'] ?? 1);
1667
- const useGrid = rootLayout === 'grid' && rootCols > 0;
1668
- const useColumn = rootLayout === 'column';
1669
- console.log('[layout] sized charts:', sg.charts.map(c => `${c.id} w=${c.w} h=${c.h}`));
1670
- console.log('[layout] rootOrder chart refs:', rootOrder.filter(r => r.kind === 'chart'));
1912
+ const rootLayout = (sg.layout ?? "row");
1913
+ const rootCols = Number(sg.config["columns"] ?? 1);
1914
+ const useGrid = rootLayout === "grid" && rootCols > 0;
1915
+ const useColumn = rootLayout === "column";
1671
1916
  if (useGrid) {
1672
1917
  // ── Grid: per-row heights, per-column widths (no wasted space) ──
1673
1918
  const cols = rootCols;
@@ -1678,22 +1923,26 @@ function layout(sg) {
1678
1923
  const col = idx % cols;
1679
1924
  const row = Math.floor(idx / cols);
1680
1925
  let w = 0, h = 0;
1681
- if (ref.kind === 'group') {
1926
+ if (ref.kind === "group") {
1682
1927
  w = gm.get(ref.id).w;
1683
1928
  h = gm.get(ref.id).h;
1684
1929
  }
1685
- else if (ref.kind === 'table') {
1930
+ else if (ref.kind === "table") {
1686
1931
  w = tm.get(ref.id).w;
1687
1932
  h = tm.get(ref.id).h;
1688
1933
  }
1689
- else if (ref.kind === 'note') {
1934
+ else if (ref.kind === "note") {
1690
1935
  w = ntm.get(ref.id).w;
1691
1936
  h = ntm.get(ref.id).h;
1692
1937
  }
1693
- else if (ref.kind === 'chart') {
1938
+ else if (ref.kind === "chart") {
1694
1939
  w = cm.get(ref.id).w;
1695
1940
  h = cm.get(ref.id).h;
1696
1941
  }
1942
+ else if (ref.kind === "markdown") {
1943
+ w = mdm.get(ref.id).w;
1944
+ h = mdm.get(ref.id).h;
1945
+ }
1697
1946
  else {
1698
1947
  w = nm.get(ref.id).w;
1699
1948
  h = nm.get(ref.id).h;
@@ -1716,22 +1965,26 @@ function layout(sg) {
1716
1965
  rootOrder.forEach((ref, idx) => {
1717
1966
  const x = colX[idx % cols];
1718
1967
  const y = rowY[Math.floor(idx / cols)];
1719
- if (ref.kind === 'group') {
1968
+ if (ref.kind === "group") {
1720
1969
  gm.get(ref.id).x = x;
1721
1970
  gm.get(ref.id).y = y;
1722
1971
  }
1723
- else if (ref.kind === 'table') {
1972
+ else if (ref.kind === "table") {
1724
1973
  tm.get(ref.id).x = x;
1725
1974
  tm.get(ref.id).y = y;
1726
1975
  }
1727
- else if (ref.kind === 'note') {
1976
+ else if (ref.kind === "note") {
1728
1977
  ntm.get(ref.id).x = x;
1729
1978
  ntm.get(ref.id).y = y;
1730
1979
  }
1731
- else if (ref.kind === 'chart') {
1980
+ else if (ref.kind === "chart") {
1732
1981
  cm.get(ref.id).x = x;
1733
1982
  cm.get(ref.id).y = y;
1734
1983
  }
1984
+ else if (ref.kind === "markdown") {
1985
+ mdm.get(ref.id).x = x;
1986
+ mdm.get(ref.id).y = y;
1987
+ }
1735
1988
  else {
1736
1989
  nm.get(ref.id).x = x;
1737
1990
  nm.get(ref.id).y = y;
@@ -1743,44 +1996,52 @@ function layout(sg) {
1743
1996
  let pos = MARGIN;
1744
1997
  for (const ref of rootOrder) {
1745
1998
  let w = 0, h = 0;
1746
- if (ref.kind === 'group') {
1999
+ if (ref.kind === "group") {
1747
2000
  w = gm.get(ref.id).w;
1748
2001
  h = gm.get(ref.id).h;
1749
2002
  }
1750
- else if (ref.kind === 'table') {
2003
+ else if (ref.kind === "table") {
1751
2004
  w = tm.get(ref.id).w;
1752
2005
  h = tm.get(ref.id).h;
1753
2006
  }
1754
- else if (ref.kind === 'note') {
2007
+ else if (ref.kind === "note") {
1755
2008
  w = ntm.get(ref.id).w;
1756
2009
  h = ntm.get(ref.id).h;
1757
2010
  }
1758
- else if (ref.kind === 'chart') {
2011
+ else if (ref.kind === "chart") {
1759
2012
  w = cm.get(ref.id).w;
1760
2013
  h = cm.get(ref.id).h;
1761
2014
  }
2015
+ else if (ref.kind === "markdown") {
2016
+ w = mdm.get(ref.id).w;
2017
+ h = mdm.get(ref.id).h;
2018
+ }
1762
2019
  else {
1763
2020
  w = nm.get(ref.id).w;
1764
2021
  h = nm.get(ref.id).h;
1765
2022
  }
1766
2023
  const x = useColumn ? MARGIN : pos;
1767
2024
  const y = useColumn ? pos : MARGIN;
1768
- if (ref.kind === 'group') {
2025
+ if (ref.kind === "group") {
1769
2026
  gm.get(ref.id).x = x;
1770
2027
  gm.get(ref.id).y = y;
1771
2028
  }
1772
- else if (ref.kind === 'table') {
2029
+ else if (ref.kind === "table") {
1773
2030
  tm.get(ref.id).x = x;
1774
2031
  tm.get(ref.id).y = y;
1775
2032
  }
1776
- else if (ref.kind === 'note') {
2033
+ else if (ref.kind === "note") {
1777
2034
  ntm.get(ref.id).x = x;
1778
2035
  ntm.get(ref.id).y = y;
1779
2036
  }
1780
- else if (ref.kind === 'chart') {
2037
+ else if (ref.kind === "chart") {
1781
2038
  cm.get(ref.id).x = x;
1782
2039
  cm.get(ref.id).y = y;
1783
2040
  }
2041
+ else if (ref.kind === "markdown") {
2042
+ mdm.get(ref.id).x = x;
2043
+ mdm.get(ref.id).y = y;
2044
+ }
1784
2045
  else {
1785
2046
  nm.get(ref.id).x = x;
1786
2047
  nm.get(ref.id).y = y;
@@ -1790,10 +2051,9 @@ function layout(sg) {
1790
2051
  }
1791
2052
  // 6. Place children within each root group (top-down, recursive)
1792
2053
  for (const g of rootGroups)
1793
- place(g, nm, gm, tm, ntm, cm);
2054
+ place(g, nm, gm, tm, ntm, cm, mdm);
1794
2055
  // 7. Route edges and compute canvas size
1795
2056
  routeEdges(sg);
1796
- console.log('[layout] chart positions:', sg.charts.map(c => `${c.id} x=${c.x} y=${c.y}`));
1797
2057
  computeBounds(sg, MARGIN);
1798
2058
  return sg;
1799
2059
  }
@@ -2522,6 +2782,26 @@ function resolveStyleFont$1(style, fallback) {
2522
2782
  loadFont(raw);
2523
2783
  return resolveFont(raw);
2524
2784
  }
2785
+ function wrapText$1(text, maxWidth, fontSize) {
2786
+ const words = text.split(' ');
2787
+ const charsPerPx = fontSize * 0.55; // approximate
2788
+ const maxChars = Math.floor(maxWidth / charsPerPx);
2789
+ const lines = [];
2790
+ let current = '';
2791
+ for (const word of words) {
2792
+ const test = current ? `${current} ${word}` : word;
2793
+ if (test.length > maxChars && current) {
2794
+ lines.push(current);
2795
+ current = word;
2796
+ }
2797
+ else {
2798
+ current = test;
2799
+ }
2800
+ }
2801
+ if (current)
2802
+ lines.push(current);
2803
+ return lines;
2804
+ }
2525
2805
  // ── SVG text helpers ──────────────────────────────────────────────────────
2526
2806
  /**
2527
2807
  * Single-line SVG text element.
@@ -2864,7 +3144,9 @@ function renderToSVG(sg, container, options = {}) {
2864
3144
  const gFontSize = Number(gs.fontSize ?? 12);
2865
3145
  const gFont = resolveStyleFont$1(gs, diagramFont);
2866
3146
  const gLetterSpacing = gs.letterSpacing;
2867
- gg.appendChild(mkText(g.label, g.x + 14, g.y + 14, gFontSize, 500, gLabelColor, "start", gFont, gLetterSpacing));
3147
+ if (g.label) {
3148
+ gg.appendChild(mkText(g.label, g.x + 14, g.y + 14, gFontSize, 500, gLabelColor, "start", gFont, gLetterSpacing));
3149
+ }
2868
3150
  GL.appendChild(gg);
2869
3151
  }
2870
3152
  svg.appendChild(GL);
@@ -2957,7 +3239,9 @@ function renderToSVG(sg, container, options = {}) {
2957
3239
  : textAlign === "right"
2958
3240
  ? n.x + n.w - 8
2959
3241
  : n.x + n.w / 2;
2960
- const lines = n.label.split("\n");
3242
+ const lines = n.shape === 'text' && !n.label.includes('\n')
3243
+ ? wrapText$1(n.label, n.w - 16, fontSize)
3244
+ : n.label.split('\n');
2961
3245
  const verticalAlign = String(n.style?.verticalAlign ?? "middle");
2962
3246
  const nodeBodyTop = n.y + 6;
2963
3247
  const nodeBodyBottom = n.y + n.h - 6;
@@ -3154,6 +3438,54 @@ function renderToSVG(sg, container, options = {}) {
3154
3438
  NoteL.appendChild(ng);
3155
3439
  }
3156
3440
  svg.appendChild(NoteL);
3441
+ markdownMap(sg);
3442
+ const MDL = mkGroup('markdown-layer');
3443
+ for (const m of sg.markdowns) {
3444
+ const mg = mkGroup(`markdown-${m.id}`, 'mdg');
3445
+ const mFont = resolveStyleFont$1(m.style, diagramFont);
3446
+ const baseColor = String(m.style?.color ?? palette.nodeText);
3447
+ const textAlign = String(m.style?.textAlign ?? 'left');
3448
+ const anchor = textAlign === 'right' ? 'end'
3449
+ : textAlign === 'center' ? 'middle'
3450
+ : 'start';
3451
+ const PAD = Number(m.style?.padding ?? 16);
3452
+ const textX = textAlign === 'right' ? m.x + m.w - PAD
3453
+ : textAlign === 'center' ? m.x + m.w / 2
3454
+ : m.x + PAD;
3455
+ let y = m.y + PAD;
3456
+ for (const line of m.lines) {
3457
+ if (line.kind === 'blank') {
3458
+ y += LINE_SPACING.blank;
3459
+ continue;
3460
+ }
3461
+ const fontSize = LINE_FONT_SIZE[line.kind];
3462
+ const fontWeight = LINE_FONT_WEIGHT[line.kind];
3463
+ const t = se('text');
3464
+ t.setAttribute('x', String(textX));
3465
+ t.setAttribute('y', String(y + fontSize / 2));
3466
+ t.setAttribute('text-anchor', anchor);
3467
+ t.setAttribute('dominant-baseline', 'middle');
3468
+ t.setAttribute('font-family', mFont);
3469
+ t.setAttribute('font-size', String(fontSize));
3470
+ t.setAttribute('font-weight', String(fontWeight));
3471
+ t.setAttribute('fill', baseColor);
3472
+ t.setAttribute('pointer-events', 'none');
3473
+ t.setAttribute('user-select', 'none');
3474
+ for (const run of line.runs) {
3475
+ const span = se('tspan');
3476
+ span.textContent = run.text;
3477
+ if (run.bold)
3478
+ span.setAttribute('font-weight', '700');
3479
+ if (run.italic)
3480
+ span.setAttribute('font-style', 'italic');
3481
+ t.appendChild(span);
3482
+ }
3483
+ mg.appendChild(t);
3484
+ y += LINE_SPACING[line.kind];
3485
+ }
3486
+ MDL.appendChild(mg);
3487
+ }
3488
+ svg.appendChild(MDL);
3157
3489
  // ── Charts ────────────────────────────────────────────────
3158
3490
  const CL = mkGroup("chart-layer");
3159
3491
  for (const c of sg.charts) {
@@ -3436,10 +3768,6 @@ function resolveStyleFont(style, fallback) {
3436
3768
  return resolveFont(raw);
3437
3769
  }
3438
3770
  // ── Canvas text helpers ────────────────────────────────────────────────────
3439
- /**
3440
- * Draw a single line of text.
3441
- * align: 'left' | 'center' | 'right' (maps to ctx.textAlign)
3442
- */
3443
3771
  function drawText(ctx, txt, x, y, sz = 14, wt = 500, col = '#1a1208', align = 'center', font = 'system-ui, sans-serif', letterSpacing) {
3444
3772
  ctx.save();
3445
3773
  ctx.font = `${wt} ${sz}px ${font}`;
@@ -3464,9 +3792,6 @@ function drawText(ctx, txt, x, y, sz = 14, wt = 500, col = '#1a1208', align = 'c
3464
3792
  }
3465
3793
  ctx.restore();
3466
3794
  }
3467
- /**
3468
- * Draw multiple lines of text, vertically centred around cy.
3469
- */
3470
3795
  function drawMultilineText(ctx, lines, x, cy, sz = 14, wt = 500, col = '#1a1208', align = 'center', lineH = 18, font = 'system-ui, sans-serif', letterSpacing) {
3471
3796
  const totalH = (lines.length - 1) * lineH;
3472
3797
  const startY = cy - totalH / 2;
@@ -3474,6 +3799,27 @@ function drawMultilineText(ctx, lines, x, cy, sz = 14, wt = 500, col = '#1a1208'
3474
3799
  drawText(ctx, line, x, startY + i * lineH, sz, wt, col, align, font, letterSpacing);
3475
3800
  });
3476
3801
  }
3802
+ // Soft word-wrap for `text` shape nodes
3803
+ function wrapText(text, maxWidth, fontSize) {
3804
+ const charWidth = fontSize * 0.55;
3805
+ const maxChars = Math.floor(maxWidth / charWidth);
3806
+ const words = text.split(' ');
3807
+ const lines = [];
3808
+ let current = '';
3809
+ for (const word of words) {
3810
+ const test = current ? `${current} ${word}` : word;
3811
+ if (test.length > maxChars && current) {
3812
+ lines.push(current);
3813
+ current = word;
3814
+ }
3815
+ else {
3816
+ current = test;
3817
+ }
3818
+ }
3819
+ if (current)
3820
+ lines.push(current);
3821
+ return lines.length ? lines : [text];
3822
+ }
3477
3823
  // ── Arrow direction ────────────────────────────────────────────────────────
3478
3824
  function connMeta(connector) {
3479
3825
  if (connector === '--')
@@ -3565,7 +3911,7 @@ function renderShape(rc, ctx, n, palette, R) {
3565
3911
  ], opts);
3566
3912
  break;
3567
3913
  case 'text':
3568
- break;
3914
+ break; // no shape drawn
3569
3915
  case 'image': {
3570
3916
  if (n.imageUrl) {
3571
3917
  const img = new Image();
@@ -3655,7 +4001,9 @@ function renderToCanvas(sg, canvas, options = {}) {
3655
4001
  // ── Title ────────────────────────────────────────────────
3656
4002
  if (sg.title) {
3657
4003
  const titleSize = Number(sg.config['title-size'] ?? 18);
3658
- drawText(ctx, sg.title, sg.width / 2, 28, titleSize, 600, palette.titleText, 'center', diagramFont);
4004
+ const titleWeight = Number(sg.config['title-weight'] ?? 600);
4005
+ const titleColor = String(sg.config['title-color'] ?? palette.titleText);
4006
+ drawText(ctx, sg.title, sg.width / 2, 28, titleSize, titleWeight, titleColor, 'center', diagramFont);
3659
4007
  }
3660
4008
  // ── Groups (outermost first) ─────────────────────────────
3661
4009
  const sortedGroups = [...sg.groups].sort((a, b) => groupDepth(a, gm) - groupDepth(b, gm));
@@ -3671,13 +4019,16 @@ function renderToCanvas(sg, canvas, options = {}) {
3671
4019
  strokeWidth: Number(gs.strokeWidth ?? 1.2),
3672
4020
  strokeLineDash: gs.strokeDash ?? palette.groupDash,
3673
4021
  });
3674
- // ── Group label: font, font-size, letter-spacing ─────
3675
- // always left-anchored (single line)
3676
- const gFontSize = Number(gs.fontSize ?? 12);
3677
- const gFont = resolveStyleFont(gs, diagramFont);
3678
- const gLetterSpacing = gs.letterSpacing;
3679
- const gLabelColor = gs.color ? String(gs.color) : palette.groupLabel;
3680
- drawText(ctx, g.label, g.x + 14, g.y + 16, gFontSize, 500, gLabelColor, 'left', gFont, gLetterSpacing);
4022
+ // ── Group label ──────────────────────────────────────
4023
+ // Only render when label has content — empty label = no reserved space
4024
+ // supports: font, font-size, letter-spacing (always left-anchored)
4025
+ if (g.label) {
4026
+ const gFontSize = Number(gs.fontSize ?? 12);
4027
+ const gFont = resolveStyleFont(gs, diagramFont);
4028
+ const gLetterSpacing = gs.letterSpacing;
4029
+ const gLabelColor = gs.color ? String(gs.color) : palette.groupLabel;
4030
+ drawText(ctx, g.label, g.x + 14, g.y + 16, gFontSize, 500, gLabelColor, 'left', gFont, gLetterSpacing);
4031
+ }
3681
4032
  }
3682
4033
  // ── Edges ─────────────────────────────────────────────────
3683
4034
  for (const e of sg.edges) {
@@ -3730,7 +4081,8 @@ function renderToCanvas(sg, canvas, options = {}) {
3730
4081
  for (const n of sg.nodes) {
3731
4082
  renderShape(rc, ctx, n, palette, R);
3732
4083
  // ── Node / text typography ─────────────────────────
3733
- // supports: font, font-size, letter-spacing, text-align, line-height
4084
+ // supports: font, font-size, letter-spacing, text-align,
4085
+ // vertical-align, line-height, word-wrap (text shape)
3734
4086
  const fontSize = Number(n.style?.fontSize ?? (n.shape === 'text' ? 13 : 14));
3735
4087
  const fontWeight = n.style?.fontWeight ?? (n.shape === 'text' ? 400 : 500);
3736
4088
  const textColor = String(n.style?.color ??
@@ -3739,15 +4091,28 @@ function renderToCanvas(sg, canvas, options = {}) {
3739
4091
  const textAlign = String(n.style?.textAlign ?? 'center');
3740
4092
  const lineHeight = Number(n.style?.lineHeight ?? 1.3) * fontSize;
3741
4093
  const letterSpacing = n.style?.letterSpacing;
4094
+ const vertAlign = String(n.style?.verticalAlign ?? 'middle');
4095
+ // x shifts for left/right alignment
3742
4096
  const textX = textAlign === 'left' ? n.x + 8
3743
4097
  : textAlign === 'right' ? n.x + n.w - 8
3744
4098
  : n.x + n.w / 2;
3745
- const lines = n.label.split('\n');
4099
+ // word-wrap for text shape; explicit \n for all others
4100
+ const rawLines = n.label.split('\n');
4101
+ const lines = n.shape === 'text' && rawLines.length === 1
4102
+ ? wrapText(n.label, n.w - 16, fontSize)
4103
+ : rawLines;
4104
+ // vertical-align: compute textCY from top/middle/bottom
4105
+ const nodeBodyTop = n.y + 6;
4106
+ const nodeBodyBottom = n.y + n.h - 6;
4107
+ const blockH = (lines.length - 1) * lineHeight;
4108
+ const textCY = vertAlign === 'top' ? nodeBodyTop + blockH / 2
4109
+ : vertAlign === 'bottom' ? nodeBodyBottom - blockH / 2
4110
+ : n.y + n.h / 2; // middle (default)
3746
4111
  if (lines.length > 1) {
3747
- drawMultilineText(ctx, lines, textX, n.y + n.h / 2, fontSize, fontWeight, textColor, textAlign, lineHeight, nodeFont, letterSpacing);
4112
+ drawMultilineText(ctx, lines, textX, textCY, fontSize, fontWeight, textColor, textAlign, lineHeight, nodeFont, letterSpacing);
3748
4113
  }
3749
4114
  else {
3750
- drawText(ctx, n.label, textX, n.y + n.h / 2, fontSize, fontWeight, textColor, textAlign, nodeFont, letterSpacing);
4115
+ drawText(ctx, lines[0] ?? '', textX, textCY, fontSize, fontWeight, textColor, textAlign, nodeFont, letterSpacing);
3751
4116
  }
3752
4117
  }
3753
4118
  // ── Tables ────────────────────────────────────────────────
@@ -3758,7 +4123,8 @@ function renderToCanvas(sg, canvas, options = {}) {
3758
4123
  const textCol = String(gs.color ?? palette.tableText);
3759
4124
  const pad = t.labelH;
3760
4125
  // ── Table-level font ────────────────────────────────
3761
- // supports: font, font-size, letter-spacing (cells also support text-align)
4126
+ // supports: font, font-size, letter-spacing
4127
+ // cells also support text-align
3762
4128
  const tFontSize = Number(gs.fontSize ?? 12);
3763
4129
  const tFont = resolveStyleFont(gs, diagramFont);
3764
4130
  const tLetterSpacing = gs.letterSpacing;
@@ -3769,8 +4135,7 @@ function renderToCanvas(sg, canvas, options = {}) {
3769
4135
  rc.line(t.x, t.y + pad, t.x + t.w, t.y + pad, {
3770
4136
  roughness: 0.6, seed: hashStr$1(t.id + 'l'), stroke: strk, strokeWidth: 1,
3771
4137
  });
3772
- // ── Table label: font, font-size, letter-spacing ────
3773
- // always left-anchored
4138
+ // ── Table label: always left-anchored ───────────────
3774
4139
  drawText(ctx, t.label, t.x + 10, t.y + pad / 2, tFontSize, 500, textCol, 'left', tFont, tLetterSpacing);
3775
4140
  let rowY = t.y + pad;
3776
4141
  for (const row of t.rows) {
@@ -3784,7 +4149,7 @@ function renderToCanvas(sg, canvas, options = {}) {
3784
4149
  stroke: row.kind === 'header' ? strk : palette.tableDivider,
3785
4150
  strokeWidth: row.kind === 'header' ? 1.2 : 0.6,
3786
4151
  });
3787
- // ── Cell text: font, font-size, letter-spacing, text-align ──
4152
+ // ── Cell text ───────────────────────────────────
3788
4153
  // header always centered; data rows respect gs.textAlign
3789
4154
  const cellAlignProp = (row.kind === 'header'
3790
4155
  ? 'center'
@@ -3832,22 +4197,91 @@ function renderToCanvas(sg, canvas, options = {}) {
3832
4197
  ], { roughness: 0.4, seed: hashStr$1(n.id + 'f'),
3833
4198
  fill: palette.noteFold, fillStyle: 'solid', stroke: strk, strokeWidth: 0.8 });
3834
4199
  // ── Note typography ─────────────────────────────────
3835
- // supports: font, font-size, letter-spacing, text-align, line-height
4200
+ // supports: font, font-size, letter-spacing, text-align,
4201
+ // vertical-align, line-height
3836
4202
  const nFontSize = Number(gs.fontSize ?? 12);
3837
4203
  const nFont = resolveStyleFont(gs, diagramFont);
3838
4204
  const nLetterSpacing = gs.letterSpacing;
3839
4205
  const nLineHeight = Number(gs.lineHeight ?? 1.4) * nFontSize;
3840
4206
  const nTextAlign = String(gs.textAlign ?? 'left');
4207
+ const nVertAlign = String(gs.verticalAlign ?? 'top');
3841
4208
  const nColor = String(gs.color ?? palette.noteText);
3842
4209
  const nTextX = nTextAlign === 'right' ? x + w - fold - 6
3843
4210
  : nTextAlign === 'center' ? x + (w - fold) / 2
3844
4211
  : x + 12;
4212
+ // vertical-align inside note body (below fold)
4213
+ const bodyTop = y + fold + 8;
4214
+ const bodyBottom = y + h - 8;
4215
+ const blockH = (n.lines.length - 1) * nLineHeight;
4216
+ const blockCY = nVertAlign === 'bottom' ? bodyBottom - blockH / 2
4217
+ : nVertAlign === 'middle' ? (bodyTop + bodyBottom) / 2
4218
+ : bodyTop + blockH / 2; // top (default)
3845
4219
  if (n.lines.length > 1) {
3846
- const blockCY = y + fold / 2 + (h - fold) / 2;
3847
4220
  drawMultilineText(ctx, n.lines, nTextX, blockCY, nFontSize, 400, nColor, nTextAlign, nLineHeight, nFont, nLetterSpacing);
3848
4221
  }
3849
4222
  else {
3850
- drawText(ctx, n.lines[0] ?? '', nTextX, y + h / 2, nFontSize, 400, nColor, nTextAlign, nFont, nLetterSpacing);
4223
+ drawText(ctx, n.lines[0] ?? '', nTextX, blockCY, nFontSize, 400, nColor, nTextAlign, nFont, nLetterSpacing);
4224
+ }
4225
+ }
4226
+ // ── Markdown blocks ────────────────────────────────────────
4227
+ // Renders prose with Markdown headings and bold/italic inline spans.
4228
+ // Canvas has no native bold-within-a-run, so each run is drawn
4229
+ // individually with its own ctx.font setting.
4230
+ for (const m of (sg.markdowns ?? [])) {
4231
+ const mFont = resolveStyleFont(m.style, diagramFont);
4232
+ const baseColor = String(m.style?.color ?? palette.nodeText);
4233
+ const textAlign = String(m.style?.textAlign ?? 'left');
4234
+ const PAD = Number(m.style?.padding ?? 16);
4235
+ const anchorX = textAlign === 'right' ? m.x + m.w - PAD
4236
+ : textAlign === 'center' ? m.x + m.w / 2
4237
+ : m.x + PAD;
4238
+ let y = m.y + PAD;
4239
+ for (const line of m.lines) {
4240
+ if (line.kind === 'blank') {
4241
+ y += LINE_SPACING.blank;
4242
+ continue;
4243
+ }
4244
+ const fontSize = LINE_FONT_SIZE[line.kind];
4245
+ const fontWeight = LINE_FONT_WEIGHT[line.kind];
4246
+ const lineY = y + fontSize / 2;
4247
+ // Measure total run width for left-offset when runs mix bold/italic
4248
+ // Simple: draw each run consecutively from a computed start x
4249
+ ctx.save();
4250
+ ctx.textBaseline = 'middle';
4251
+ ctx.fillStyle = baseColor;
4252
+ if (textAlign === 'center' || textAlign === 'right') {
4253
+ // Measure full line width first
4254
+ let totalW = 0;
4255
+ for (const run of line.runs) {
4256
+ const runStyle = run.italic ? 'italic ' : '';
4257
+ const runWeight = run.bold ? 700 : fontWeight;
4258
+ ctx.font = `${runStyle}${runWeight} ${fontSize}px ${mFont}`;
4259
+ totalW += ctx.measureText(run.text).width;
4260
+ }
4261
+ let runX = textAlign === 'center' ? anchorX - totalW / 2 : anchorX - totalW;
4262
+ ctx.textAlign = 'left';
4263
+ for (const run of line.runs) {
4264
+ const runStyle = run.italic ? 'italic ' : '';
4265
+ const runWeight = run.bold ? 700 : fontWeight;
4266
+ ctx.font = `${runStyle}${runWeight} ${fontSize}px ${mFont}`;
4267
+ ctx.fillText(run.text, runX, lineY);
4268
+ runX += ctx.measureText(run.text).width;
4269
+ }
4270
+ }
4271
+ else {
4272
+ // left-aligned — draw runs left to right from anchorX
4273
+ let runX = anchorX;
4274
+ ctx.textAlign = 'left';
4275
+ for (const run of line.runs) {
4276
+ const runStyle = run.italic ? 'italic ' : '';
4277
+ const runWeight = run.bold ? 700 : fontWeight;
4278
+ ctx.font = `${runStyle}${runWeight} ${fontSize}px ${mFont}`;
4279
+ ctx.fillText(run.text, runX, lineY);
4280
+ runX += ctx.measureText(run.text).width;
4281
+ }
4282
+ }
4283
+ ctx.restore();
4284
+ y += LINE_SPACING[line.kind];
3851
4285
  }
3852
4286
  }
3853
4287
  // ── Charts ────────────────────────────────────────────────
@@ -4807,6 +5241,89 @@ class EventEmitter {
4807
5241
  }
4808
5242
  }
4809
5243
 
5244
+ // ============================================================
5245
+ // sketchmark — Encrypted sharing
5246
+ // Diagram DSL is encrypted in the browser.
5247
+ // The server stores an opaque blob it cannot read.
5248
+ // The decryption key lives only in the URL fragment (#key=...).
5249
+ // ============================================================
5250
+ const WORKER_URL = 'https://sketchmark.anmism.workers.dev';
5251
+ // ── Crypto helpers ────────────────────────────────────────
5252
+ async function generateKey() {
5253
+ return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, // extractable so we can export to URL
5254
+ ['encrypt', 'decrypt']);
5255
+ }
5256
+ async function keyToBase64(key) {
5257
+ const raw = await crypto.subtle.exportKey('raw', key);
5258
+ return btoa(String.fromCharCode(...new Uint8Array(raw)));
5259
+ }
5260
+ async function base64ToKey(b64) {
5261
+ const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
5262
+ return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, // not extractable on the receiving end
5263
+ ['decrypt']);
5264
+ }
5265
+ // ── Encrypt ───────────────────────────────────────────────
5266
+ async function encryptDSL(dsl, key) {
5267
+ const iv = crypto.getRandomValues(new Uint8Array(12));
5268
+ const encoded = new TextEncoder().encode(dsl);
5269
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
5270
+ // prepend iv to the blob: [ iv (12 bytes) | ciphertext ]
5271
+ const result = new Uint8Array(12 + encrypted.byteLength);
5272
+ result.set(iv, 0);
5273
+ result.set(new Uint8Array(encrypted), 12);
5274
+ return result;
5275
+ }
5276
+ // ── Decrypt ───────────────────────────────────────────────
5277
+ async function decryptBlob(blob, key) {
5278
+ const iv = blob.slice(0, 12);
5279
+ const ciphertext = blob.slice(12);
5280
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
5281
+ return new TextDecoder().decode(decrypted);
5282
+ }
5283
+ // ── Public API ────────────────────────────────────────────
5284
+ /**
5285
+ * Encrypt DSL, upload to worker, return shareable URL.
5286
+ * The URL fragment (#key=...) never reaches the server.
5287
+ */
5288
+ async function shareDiagram(dsl) {
5289
+ const key = await generateKey();
5290
+ const blob = await encryptDSL(dsl, key);
5291
+ const keyB64 = await keyToBase64(key);
5292
+ const res = await fetch(`${WORKER_URL}/api/blob`, {
5293
+ method: 'POST',
5294
+ headers: { 'Content-Type': 'application/octet-stream' },
5295
+ body: blob.buffer,
5296
+ });
5297
+ if (!res.ok)
5298
+ throw new Error(`Upload failed: ${res.status}`);
5299
+ const { id } = await res.json();
5300
+ // key goes into the fragment — browser never sends this to any server
5301
+ return `${window.location.origin}/playground.html?s=${id}#key=${keyB64}`;
5302
+ }
5303
+ /**
5304
+ * Read ?s= and #key= from the current URL, fetch + decrypt the diagram.
5305
+ * Returns null if no share params found.
5306
+ */
5307
+ async function loadSharedDiagram() {
5308
+ const params = new URLSearchParams(window.location.search);
5309
+ const id = params.get('s');
5310
+ if (!id)
5311
+ return null;
5312
+ // key is in the fragment — parse manually, not via URLSearchParams
5313
+ // (URLSearchParams on hash strips the #)
5314
+ const fragment = window.location.hash.slice(1);
5315
+ const keyMatch = fragment.match(/key=([^&]+)/);
5316
+ if (!keyMatch)
5317
+ return null;
5318
+ const keyB64 = keyMatch[1];
5319
+ const res = await fetch(`${WORKER_URL}/api/blob/${id}`);
5320
+ if (!res.ok)
5321
+ throw new Error('Diagram not found or expired');
5322
+ const blob = await res.arrayBuffer();
5323
+ const key = await base64ToKey(keyB64);
5324
+ return decryptBlob(blob, key);
5325
+ }
5326
+
4810
5327
  // ============================================================
4811
5328
  // sketchmark — Public API
4812
5329
  // ============================================================
@@ -4871,5 +5388,5 @@ function render(options) {
4871
5388
  return instance;
4872
5389
  }
4873
5390
 
4874
- export { ANIMATION_CSS, AnimationController, BUILTIN_FONTS, EventEmitter, PALETTES, ParseError, THEME_CONFIG_KEY, THEME_NAMES, buildSceneGraph, canvasToPNGBlob, canvasToPNGDataURL, clamp, connPoint, debounce, exportCanvasPNG, exportGIF, exportHTML, exportMP4, exportPNG, exportSVG, getSVGBlob, groupMap, hashStr, layout, lerp, listThemes, loadFont, nodeMap, parse, parseHex, registerFont, render, renderToCanvas, renderToSVG, resolveFont, resolvePalette, sleep, svgToPNGDataURL, svgToString, throttle };
5391
+ export { ANIMATION_CSS, AnimationController, BUILTIN_FONTS, EventEmitter, PALETTES, ParseError, THEME_CONFIG_KEY, THEME_NAMES, buildSceneGraph, canvasToPNGBlob, canvasToPNGDataURL, clamp, connPoint, debounce, exportCanvasPNG, exportGIF, exportHTML, exportMP4, exportPNG, exportSVG, getSVGBlob, groupMap, hashStr, layout, lerp, listThemes, loadFont, loadSharedDiagram, markdownMap, nodeMap, parse, parseHex, registerFont, render, renderToCanvas, renderToSVG, resolveFont, resolvePalette, shareDiagram, sleep, svgToPNGDataURL, svgToString, throttle };
4875
5392
  //# sourceMappingURL=index.js.map