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.

Potentially problematic release.


This version of sketchmark might be problematic. Click here for more details.

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