sketchmark 0.1.1 → 0.1.3

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.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,6 +267,18 @@ function propsToStyle(p) {
248
267
  s.fontSize = parseFloat(p["font-size"]);
249
268
  if (p["font-weight"])
250
269
  s.fontWeight = p["font-weight"];
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"]);
280
+ if (p.font)
281
+ s.font = p.font;
251
282
  if (p["dash"]) {
252
283
  const parts = p["dash"]
253
284
  .split(",")
@@ -285,6 +316,7 @@ function parse(src) {
285
316
  notes: [],
286
317
  charts: [],
287
318
  tables: [],
319
+ markdowns: [],
288
320
  styles: {},
289
321
  themes: {},
290
322
  config: {},
@@ -295,6 +327,7 @@ function parse(src) {
295
327
  const noteIds = new Set();
296
328
  const chartIds = new Set();
297
329
  const groupIds = new Set();
330
+ const markdownIds = new Set();
298
331
  let i = 0;
299
332
  const cur = () => flat[i] ?? flat[flat.length - 1];
300
333
  const peek1 = () => flat[i + 1] ?? flat[flat.length - 1];
@@ -474,6 +507,8 @@ function parse(src) {
474
507
  label: rawLabel.replace(/\\n/g, "\n"),
475
508
  theme: props.theme,
476
509
  style: propsToStyle(props),
510
+ ...(props.width ? { width: parseFloat(props.width) } : {}),
511
+ ...(props.height ? { height: parseFloat(props.height) } : {}),
477
512
  };
478
513
  }
479
514
  // ── parseGroup ───────────────────────────────────────────
@@ -506,7 +541,7 @@ function parse(src) {
506
541
  const group = {
507
542
  kind: "group",
508
543
  id,
509
- label: props.label ?? id,
544
+ label: props.label ?? "",
510
545
  children: [],
511
546
  layout: props.layout,
512
547
  columns: props.columns !== undefined ? parseInt(props.columns, 10) : undefined,
@@ -532,8 +567,18 @@ function parse(src) {
532
567
  break;
533
568
  const v = cur().value;
534
569
  // ── Nested group ──────────────────────────────────
535
- if (v === "group") {
570
+ if (v === "group" || v === "bare") {
571
+ const isBare = v === "bare";
536
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
+ }
537
582
  ast.groups.push(nested);
538
583
  groupIds.add(nested.id);
539
584
  group.children.push({ kind: "group", id: nested.id });
@@ -555,6 +600,21 @@ function parse(src) {
555
600
  group.children.push({ kind: "note", id: note.id });
556
601
  continue;
557
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
+ }
558
618
  // ── Chart ──────────────────────────────────────────
559
619
  if (CHART_TYPES.includes(v)) {
560
620
  const chart = parseChart(v);
@@ -598,71 +658,71 @@ function parse(src) {
598
658
  function parseStep() {
599
659
  skip();
600
660
  const toks = lineTokens();
601
- const action = (toks[0]?.value ?? 'highlight');
602
- let target = toks[1]?.value ?? '';
603
- 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]) {
604
664
  target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
605
665
  }
606
- const step = { kind: 'step', action, target };
666
+ const step = { kind: "step", action, target };
607
667
  for (let j = 2; j < toks.length; j++) {
608
668
  const k = toks[j]?.value;
609
669
  const eq = toks[j + 1];
610
670
  const vt = toks[j + 2];
611
671
  // key=value form
612
- if (eq?.type === 'EQUALS' && vt) {
613
- if (k === 'dx') {
672
+ if (eq?.type === "EQUALS" && vt) {
673
+ if (k === "dx") {
614
674
  step.dx = parseFloat(vt.value);
615
675
  j += 2;
616
676
  continue;
617
677
  }
618
- if (k === 'dy') {
678
+ if (k === "dy") {
619
679
  step.dy = parseFloat(vt.value);
620
680
  j += 2;
621
681
  continue;
622
682
  }
623
- if (k === 'duration') {
683
+ if (k === "duration") {
624
684
  step.duration = parseFloat(vt.value);
625
685
  j += 2;
626
686
  continue;
627
687
  }
628
- if (k === 'delay') {
688
+ if (k === "delay") {
629
689
  step.delay = parseFloat(vt.value);
630
690
  j += 2;
631
691
  continue;
632
692
  }
633
- if (k === 'factor') {
693
+ if (k === "factor") {
634
694
  step.factor = parseFloat(vt.value);
635
695
  j += 2;
636
696
  continue;
637
697
  }
638
- if (k === 'deg') {
698
+ if (k === "deg") {
639
699
  step.deg = parseFloat(vt.value);
640
700
  j += 2;
641
701
  continue;
642
702
  }
643
- if (k === 'fill') {
703
+ if (k === "fill") {
644
704
  step.value = vt.value;
645
705
  j += 2;
646
706
  continue;
647
707
  }
648
- if (k === 'color') {
708
+ if (k === "color") {
649
709
  step.value = vt.value;
650
710
  j += 2;
651
711
  continue;
652
712
  }
653
713
  }
654
714
  // bare key value (legacy)
655
- if (k === 'delay' && eq?.type === 'NUMBER') {
715
+ if (k === "delay" && eq?.type === "NUMBER") {
656
716
  step.delay = parseFloat(eq.value);
657
717
  j++;
658
718
  continue;
659
719
  }
660
- if (k === 'duration' && eq?.type === 'NUMBER') {
720
+ if (k === "duration" && eq?.type === "NUMBER") {
661
721
  step.duration = parseFloat(eq.value);
662
722
  j++;
663
723
  continue;
664
724
  }
665
- if (k === 'trigger') {
725
+ if (k === "trigger") {
666
726
  step.trigger = eq?.value;
667
727
  j++;
668
728
  continue;
@@ -862,6 +922,40 @@ function parse(src) {
862
922
  skip();
863
923
  return table;
864
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
+ }
865
959
  // ── Main parse loop ─────────────────────────────────────
866
960
  skipNL();
867
961
  if (cur().value === "diagram")
@@ -975,8 +1069,18 @@ function parse(src) {
975
1069
  continue;
976
1070
  }
977
1071
  // group
978
- if (v === "group") {
1072
+ if (v === "group" || v === "bare") {
1073
+ const isBare = v === "bare";
979
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
+ }
980
1084
  ast.groups.push(grp);
981
1085
  groupIds.add(grp.id);
982
1086
  ast.rootOrder.push({ kind: "group", id: grp.id });
@@ -1011,6 +1115,13 @@ function parse(src) {
1011
1115
  ast.rootOrder.push({ kind: "chart", id: chart.id }); // ← ADD
1012
1116
  continue;
1013
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
+ }
1014
1125
  // edge: A -> B (MUST come before shape check)
1015
1126
  if (t.type === "IDENT" || t.type === "STRING" || t.type === "KEYWORD") {
1016
1127
  const nextTok = flat[i + 1];
@@ -1060,11 +1171,94 @@ function parse(src) {
1060
1171
  node.style = { ...ast.styles[node.id], ...node.style };
1061
1172
  }
1062
1173
  }
1063
- console.log("[parse] charts:", ast.charts.map((c) => c.id));
1064
- console.log("[parse] rootOrder:", ast.rootOrder.map((r) => r.kind + ":" + r.id));
1065
1174
  return ast;
1066
1175
  }
1067
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
+
1068
1262
  // ============================================================
1069
1263
  // sketchmark — Scene Graph
1070
1264
  // ============================================================
@@ -1137,6 +1331,8 @@ function buildSceneGraph(ast) {
1137
1331
  y: 0,
1138
1332
  w: 0,
1139
1333
  h: 0,
1334
+ width: n.width,
1335
+ height: n.height,
1140
1336
  };
1141
1337
  });
1142
1338
  const charts = ast.charts.map((c) => {
@@ -1153,6 +1349,18 @@ function buildSceneGraph(ast) {
1153
1349
  h: c.height ?? 240,
1154
1350
  };
1155
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
+ });
1156
1364
  // Set parentId for nested groups
1157
1365
  for (const g of groups) {
1158
1366
  for (const child of g.children) {
@@ -1183,6 +1391,7 @@ function buildSceneGraph(ast) {
1183
1391
  tables,
1184
1392
  notes,
1185
1393
  charts,
1394
+ markdowns,
1186
1395
  animation: { steps: ast.steps, currentStep: -1 },
1187
1396
  styles: ast.styles,
1188
1397
  config: ast.config,
@@ -1207,6 +1416,9 @@ function noteMap(sg) {
1207
1416
  function chartMap(sg) {
1208
1417
  return new Map(sg.charts.map((c) => [c.id, c]));
1209
1418
  }
1419
+ function markdownMap(sg) {
1420
+ return new Map((sg.markdowns ?? []).map(m => [m.id, m]));
1421
+ }
1210
1422
 
1211
1423
  // ============================================================
1212
1424
  // sketchmark — Layout Engine (Flexbox-style, recursive)
@@ -1247,30 +1459,40 @@ function sizeNode(n) {
1247
1459
  n.h = n.height;
1248
1460
  const labelW = Math.round(n.label.length * FONT_PX_PER_CHAR + BASE_PAD);
1249
1461
  switch (n.shape) {
1250
- case 'circle':
1462
+ case "circle":
1251
1463
  n.w = n.w || Math.max(84, Math.min(MAX_W, labelW));
1252
1464
  n.h = n.h || n.w;
1253
1465
  break;
1254
- case 'diamond':
1466
+ case "diamond":
1255
1467
  n.w = n.w || Math.max(130, Math.min(MAX_W, labelW + 30));
1256
1468
  n.h = n.h || Math.max(62, n.w * 0.46);
1257
1469
  break;
1258
- case 'hexagon':
1470
+ case "hexagon":
1259
1471
  n.w = n.w || Math.max(126, Math.min(MAX_W, labelW + 20));
1260
1472
  n.h = n.h || Math.max(54, n.w * 0.44);
1261
1473
  break;
1262
- case 'triangle':
1474
+ case "triangle":
1263
1475
  n.w = n.w || Math.max(108, Math.min(MAX_W, labelW + 10));
1264
- n.h = n.h || Math.max(64, n.w * 0.60);
1476
+ n.h = n.h || Math.max(64, n.w * 0.6);
1265
1477
  break;
1266
- case 'cylinder':
1478
+ case "cylinder":
1267
1479
  n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1268
1480
  n.h = n.h || 66;
1269
1481
  break;
1270
- case 'parallelogram':
1482
+ case "parallelogram":
1271
1483
  n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW + 28));
1272
1484
  n.h = n.h || 50;
1273
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
+ }
1274
1496
  default:
1275
1497
  n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1276
1498
  n.h = n.h || 52;
@@ -1278,9 +1500,13 @@ function sizeNode(n) {
1278
1500
  }
1279
1501
  }
1280
1502
  function sizeNote(n) {
1281
- const maxChars = Math.max(...n.lines.map(l => l.length));
1503
+ const maxChars = Math.max(...n.lines.map((l) => l.length));
1282
1504
  n.w = Math.max(120, Math.ceil(maxChars * NOTE_FONT) + NOTE_PAD_X * 2);
1283
1505
  n.h = n.lines.length * NOTE_LINE_H + NOTE_PAD_Y * 2;
1506
+ if (n.width && n.w < n.width)
1507
+ n.w = n.width; // ← add
1508
+ if (n.height && n.h < n.height)
1509
+ n.h = n.height; // ← add
1284
1510
  }
1285
1511
  // ── Table auto-sizing ─────────────────────────────────────
1286
1512
  function sizeTable(t) {
@@ -1290,7 +1516,7 @@ function sizeTable(t) {
1290
1516
  t.h = labelH + rowH;
1291
1517
  return;
1292
1518
  }
1293
- const numCols = Math.max(...rows.map(r => r.cells.length));
1519
+ const numCols = Math.max(...rows.map((r) => r.cells.length));
1294
1520
  const colW = Array(numCols).fill(MIN_COL_W);
1295
1521
  for (const row of rows) {
1296
1522
  row.cells.forEach((cell, i) => {
@@ -1299,110 +1525,128 @@ function sizeTable(t) {
1299
1525
  }
1300
1526
  t.colWidths = colW;
1301
1527
  t.w = colW.reduce((s, w) => s + w, 0);
1302
- const nHeader = rows.filter(r => r.kind === 'header').length;
1303
- 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;
1304
1530
  t.h = labelH + nHeader * headerH + nData * rowH;
1305
1531
  }
1306
1532
  function sizeChart(c) {
1307
1533
  c.w = c.w || 320;
1308
1534
  c.h = c.h || 240;
1309
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
+ }
1310
1541
  // ── Item size helpers ─────────────────────────────────────
1311
- function iW(r, nm, gm, tm, ntm, cm) {
1312
- if (r.kind === 'node')
1542
+ function iW(r, nm, gm, tm, ntm, cm, mdm) {
1543
+ if (r.kind === "node")
1313
1544
  return nm.get(r.id).w;
1314
- if (r.kind === 'table')
1545
+ if (r.kind === "table")
1315
1546
  return tm.get(r.id).w;
1316
- if (r.kind === 'note')
1547
+ if (r.kind === "note")
1317
1548
  return ntm.get(r.id).w;
1318
- if (r.kind === 'chart')
1549
+ if (r.kind === "chart")
1319
1550
  return cm.get(r.id).w;
1551
+ if (r.kind === "markdown")
1552
+ return mdm.get(r.id).w;
1320
1553
  return gm.get(r.id).w;
1321
1554
  }
1322
- function iH(r, nm, gm, tm, ntm, cm) {
1323
- if (r.kind === 'node')
1555
+ function iH(r, nm, gm, tm, ntm, cm, mdm) {
1556
+ if (r.kind === "node")
1324
1557
  return nm.get(r.id).h;
1325
- if (r.kind === 'table')
1558
+ if (r.kind === "table")
1326
1559
  return tm.get(r.id).h;
1327
- if (r.kind === 'note')
1560
+ if (r.kind === "note")
1328
1561
  return ntm.get(r.id).h;
1329
- if (r.kind === 'chart')
1562
+ if (r.kind === "chart")
1330
1563
  return cm.get(r.id).h;
1564
+ if (r.kind === "markdown")
1565
+ return mdm.get(r.id).h;
1331
1566
  return gm.get(r.id).h;
1332
1567
  }
1333
- function setPos(r, x, y, nm, gm, tm, ntm, cm) {
1334
- if (r.kind === 'node') {
1568
+ function setPos(r, x, y, nm, gm, tm, ntm, cm, mdm) {
1569
+ if (r.kind === "node") {
1335
1570
  const n = nm.get(r.id);
1336
1571
  n.x = Math.round(x);
1337
1572
  n.y = Math.round(y);
1338
1573
  return;
1339
1574
  }
1340
- if (r.kind === 'table') {
1575
+ if (r.kind === "table") {
1341
1576
  const t = tm.get(r.id);
1342
1577
  t.x = Math.round(x);
1343
1578
  t.y = Math.round(y);
1344
1579
  return;
1345
1580
  }
1346
- if (r.kind === 'note') {
1581
+ if (r.kind === "note") {
1347
1582
  const nt = ntm.get(r.id);
1348
1583
  nt.x = Math.round(x);
1349
1584
  nt.y = Math.round(y);
1350
1585
  return;
1351
1586
  }
1352
- if (r.kind === 'chart') {
1587
+ if (r.kind === "chart") {
1353
1588
  const c = cm.get(r.id);
1354
1589
  c.x = Math.round(x);
1355
1590
  c.y = Math.round(y);
1356
1591
  return;
1357
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
+ }
1358
1599
  const g = gm.get(r.id);
1359
1600
  g.x = Math.round(x);
1360
1601
  g.y = Math.round(y);
1361
1602
  }
1362
1603
  // ── Pass 1: Measure (bottom-up) ───────────────────────────
1363
1604
  // Recursively computes w, h for a group from its children's sizes.
1364
- function measure(g, nm, gm, tm, ntm, cm) {
1605
+ function measure(g, nm, gm, tm, ntm, cm, mdm) {
1365
1606
  // Recurse into nested groups first; size tables before reading their dims
1366
1607
  for (const r of g.children) {
1367
- if (r.kind === 'group')
1368
- measure(gm.get(r.id), nm, gm, tm, ntm, cm);
1369
- 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")
1370
1611
  sizeTable(tm.get(r.id));
1371
- if (r.kind === 'note')
1612
+ if (r.kind === "note")
1372
1613
  sizeNote(ntm.get(r.id));
1373
- if (r.kind === 'chart')
1614
+ if (r.kind === "chart")
1374
1615
  sizeChart(cm.get(r.id));
1616
+ if (r.kind === "markdown")
1617
+ sizeMarkdown(mdm.get(r.id));
1375
1618
  }
1376
1619
  const { padding: pad, gap, columns, layout } = g;
1377
1620
  const kids = g.children;
1621
+ const labelH = g.label ? GROUP_LABEL_H : 0;
1378
1622
  if (!kids.length) {
1379
1623
  g.w = pad * 2;
1380
- g.h = pad * 2 + GROUP_LABEL_H;
1624
+ g.h = pad * 2 + labelH;
1381
1625
  if (g.width && g.w < g.width)
1382
1626
  g.w = g.width;
1383
1627
  if (g.height && g.h < g.height)
1384
1628
  g.h = g.height;
1385
1629
  return;
1386
1630
  }
1387
- const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1388
- 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));
1389
1633
  const n = kids.length;
1390
- if (layout === 'row') {
1634
+ if (layout === "row") {
1391
1635
  g.w = ws.reduce((s, w) => s + w, 0) + gap * (n - 1) + pad * 2;
1392
- g.h = Math.max(...hs) + pad * 2 + GROUP_LABEL_H;
1636
+ g.h = Math.max(...hs) + pad * 2 + labelH;
1393
1637
  }
1394
- else if (layout === 'grid') {
1638
+ else if (layout === "grid") {
1395
1639
  const cols = Math.max(1, columns);
1396
1640
  const rows = Math.ceil(n / cols);
1397
1641
  const cellW = Math.max(...ws);
1398
1642
  const cellH = Math.max(...hs);
1399
1643
  g.w = cols * cellW + (cols - 1) * gap + pad * 2;
1400
- g.h = rows * cellH + (rows - 1) * gap + pad * 2 + GROUP_LABEL_H;
1644
+ g.h = rows * cellH + (rows - 1) * gap + pad * 2 + labelH;
1401
1645
  }
1402
1646
  else {
1403
1647
  // column (default)
1404
1648
  g.w = Math.max(...ws) + pad * 2;
1405
- 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;
1406
1650
  }
1407
1651
  // Clamp to minWidth / minHeight — this is what gives distribute() free
1408
1652
  // space to work with for justify=center/end/space-between/space-around
@@ -1417,21 +1661,32 @@ function distribute(sizes, contentSize, gap, justify) {
1417
1661
  const totalSize = sizes.reduce((s, v) => s + v, 0);
1418
1662
  const gapCount = n - 1;
1419
1663
  switch (justify) {
1420
- case 'center': {
1664
+ case "center": {
1421
1665
  const total = totalSize + gap * gapCount;
1422
- 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
+ };
1423
1670
  }
1424
- case 'end': {
1671
+ case "end": {
1425
1672
  const total = totalSize + gap * gapCount;
1426
- 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
+ };
1427
1677
  }
1428
- case 'space-between': {
1429
- 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;
1430
1682
  return { start: 0, gaps: Array(gapCount).fill(g2) };
1431
1683
  }
1432
- case 'space-around': {
1684
+ case "space-around": {
1433
1685
  const space = n > 0 ? (contentSize - totalSize) / n : gap;
1434
- 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
+ };
1435
1690
  }
1436
1691
  default: // start
1437
1692
  return { start: 0, gaps: Array(gapCount).fill(gap) };
@@ -1439,70 +1694,73 @@ function distribute(sizes, contentSize, gap, justify) {
1439
1694
  }
1440
1695
  // ── Pass 2: Place (top-down) ──────────────────────────────
1441
1696
  // Assigns x, y to each child. Assumes g.x / g.y already set by parent.
1442
- function place(g, nm, gm, tm, ntm, cm) {
1697
+ function place(g, nm, gm, tm, ntm, cm, mdm) {
1443
1698
  const { padding: pad, gap, columns, layout, align, justify } = g;
1699
+ const labelH = g.label ? GROUP_LABEL_H : 0;
1444
1700
  const contentX = g.x + pad;
1445
- const contentY = g.y + GROUP_LABEL_H + pad;
1701
+ const contentY = g.y + labelH + pad;
1446
1702
  const contentW = g.w - pad * 2;
1447
- const contentH = g.h - pad * 2 - GROUP_LABEL_H;
1703
+ const contentH = g.h - pad * 2 - labelH;
1448
1704
  const kids = g.children;
1449
1705
  if (!kids.length)
1450
1706
  return;
1451
- if (layout === 'row') {
1452
- const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1453
- 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));
1454
1710
  const maxH = Math.max(...hs);
1455
1711
  const { start, gaps } = distribute(ws, contentW, gap, justify);
1456
1712
  let x = contentX + start;
1457
1713
  for (let i = 0; i < kids.length; i++) {
1458
1714
  let y;
1459
1715
  switch (align) {
1460
- case 'center':
1716
+ case "center":
1461
1717
  y = contentY + (maxH - hs[i]) / 2;
1462
1718
  break;
1463
- case 'end':
1719
+ case "end":
1464
1720
  y = contentY + maxH - hs[i];
1465
1721
  break;
1466
- default: y = contentY;
1722
+ default:
1723
+ y = contentY;
1467
1724
  }
1468
- setPos(kids[i], x, y, nm, gm, tm, ntm, cm);
1725
+ setPos(kids[i], x, y, nm, gm, tm, ntm, cm, mdm);
1469
1726
  x += ws[i] + (i < gaps.length ? gaps[i] : 0);
1470
1727
  }
1471
1728
  }
1472
- else if (layout === 'grid') {
1729
+ else if (layout === "grid") {
1473
1730
  const cols = Math.max(1, columns);
1474
- const cellW = Math.max(...kids.map(r => iW(r, nm, gm, tm, ntm, cm)));
1475
- 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)));
1476
1733
  kids.forEach((ref, i) => {
1477
- 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);
1478
1735
  });
1479
1736
  }
1480
1737
  else {
1481
1738
  // column (default)
1482
- const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1483
- 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));
1484
1741
  const maxW = Math.max(...ws);
1485
1742
  const { start, gaps } = distribute(hs, contentH, gap, justify);
1486
1743
  let y = contentY + start;
1487
1744
  for (let i = 0; i < kids.length; i++) {
1488
1745
  let x;
1489
1746
  switch (align) {
1490
- case 'center':
1747
+ case "center":
1491
1748
  x = contentX + (maxW - ws[i]) / 2;
1492
1749
  break;
1493
- case 'end':
1750
+ case "end":
1494
1751
  x = contentX + maxW - ws[i];
1495
1752
  break;
1496
- default: x = contentX;
1753
+ default:
1754
+ x = contentX;
1497
1755
  }
1498
- setPos(kids[i], x, y, nm, gm, tm, ntm, cm);
1756
+ setPos(kids[i], x, y, nm, gm, tm, ntm, cm, mdm);
1499
1757
  y += hs[i] + (i < gaps.length ? gaps[i] : 0);
1500
1758
  }
1501
1759
  }
1502
1760
  // Recurse into nested groups
1503
1761
  for (const r of kids) {
1504
- if (r.kind === 'group')
1505
- 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);
1506
1764
  }
1507
1765
  }
1508
1766
  // ── Edge routing ──────────────────────────────────────────
@@ -1512,9 +1770,9 @@ function connPoint(n, other) {
1512
1770
  const dx = ox - cx, dy = oy - cy;
1513
1771
  if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
1514
1772
  return [cx, cy];
1515
- if (n.shape === 'circle') {
1773
+ if (n.shape === "circle") {
1516
1774
  const r = n.w * 0.44, len = Math.sqrt(dx * dx + dy * dy);
1517
- return [cx + dx / len * r, cy + dy / len * r];
1775
+ return [cx + (dx / len) * r, cy + (dy / len) * r];
1518
1776
  }
1519
1777
  const hw = n.w / 2 - 2, hh = n.h / 2 - 2;
1520
1778
  const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
@@ -1559,9 +1817,12 @@ function routeEdges(sg) {
1559
1817
  }
1560
1818
  function connPt(src, dstCX, dstCY) {
1561
1819
  // SceneNode has a .shape field; use the existing connPoint for it
1562
- if ('shape' in src && src.shape) {
1820
+ if ("shape" in src && src.shape) {
1563
1821
  return connPoint(src, {
1564
- x: dstCX - 1, y: dstCY - 1, w: 2, h: 2});
1822
+ x: dstCX - 1,
1823
+ y: dstCY - 1,
1824
+ w: 2,
1825
+ h: 2});
1565
1826
  }
1566
1827
  return rectConnPoint$2(src.x, src.y, src.w, src.h, dstCX, dstCY);
1567
1828
  }
@@ -1574,84 +1835,84 @@ function routeEdges(sg) {
1574
1835
  }
1575
1836
  const dstCX = dst.x + dst.w / 2, dstCY = dst.y + dst.h / 2;
1576
1837
  const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
1577
- e.points = [
1578
- connPt(src, dstCX, dstCY),
1579
- connPt(dst, srcCX, srcCY),
1580
- ];
1838
+ e.points = [connPt(src, dstCX, dstCY), connPt(dst, srcCX, srcCY)];
1581
1839
  }
1582
1840
  }
1583
1841
  function computeBounds(sg, margin) {
1584
1842
  const allX = [
1585
- ...sg.nodes.map(n => n.x + n.w),
1586
- ...sg.groups.filter(g => g.w).map(g => g.x + g.w),
1587
- ...sg.tables.map(t => t.x + t.w),
1588
- ...sg.notes.map(n => n.x + n.w),
1589
- ...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),
1590
1849
  ];
1591
1850
  const allY = [
1592
- ...sg.nodes.map(n => n.y + n.h),
1593
- ...sg.groups.filter(g => g.h).map(g => g.y + g.h),
1594
- ...sg.tables.map(t => t.y + t.h),
1595
- ...sg.notes.map(n => n.y + n.h),
1596
- ...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),
1597
1857
  ];
1598
1858
  sg.width = (allX.length ? Math.max(...allX) : 400) + margin;
1599
1859
  sg.height = (allY.length ? Math.max(...allY) : 300) + margin;
1600
1860
  }
1601
1861
  // ── Public entry point ────────────────────────────────────
1602
1862
  function layout(sg) {
1603
- const GAP_MAIN = Number(sg.config['gap'] ?? DEFAULT_GAP_MAIN);
1604
- 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);
1605
1865
  const nm = nodeMap(sg);
1606
1866
  const gm = groupMap(sg);
1607
1867
  const tm = tableMap(sg);
1608
1868
  const ntm = noteMap(sg);
1609
1869
  const cm = chartMap(sg);
1610
- console.log('[layout] sg.charts:', sg.charts.map(c => c.id));
1611
- console.log('[layout] sg.rootOrder:', sg.rootOrder.map(r => r.kind + ':' + r.id));
1870
+ const mdm = markdownMap(sg);
1612
1871
  // 1. Size all nodes and tables
1613
1872
  sg.nodes.forEach(sizeNode);
1614
1873
  sg.tables.forEach(sizeTable);
1615
1874
  sg.notes.forEach(sizeNote);
1616
1875
  sg.charts.forEach(sizeChart);
1876
+ sg.markdowns.forEach(sizeMarkdown);
1617
1877
  // src/layout/index.ts — after sg.charts.forEach(sizeChart);
1618
1878
  // 2. Identify root vs nested items
1619
- const nestedGroupIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'group').map(c => c.id)));
1620
- const groupedNodeIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'node').map(c => c.id)));
1621
- const groupedTableIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'table').map(c => c.id)));
1622
- const groupedNoteIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'note').map(c => c.id)));
1623
- const groupedChartIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'chart').map(c => c.id)));
1624
- const rootGroups = sg.groups.filter(g => !nestedGroupIds.has(g.id));
1625
- const rootNodes = sg.nodes.filter(n => !groupedNodeIds.has(n.id));
1626
- const rootTables = sg.tables.filter(t => !groupedTableIds.has(t.id));
1627
- const rootNotes = sg.notes.filter(n => !groupedNoteIds.has(n.id));
1628
- 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));
1629
1891
  // 3. Measure root groups bottom-up
1630
1892
  for (const g of rootGroups)
1631
- measure(g, nm, gm, tm, ntm, cm);
1893
+ measure(g, nm, gm, tm, ntm, cm, mdm);
1632
1894
  // 4. Build root order
1633
1895
  // sg.rootOrder preserves DSL declaration order.
1634
1896
  // Fall back: groups, then nodes, then tables.
1635
1897
  const rootOrder = sg.rootOrder?.length
1636
1898
  ? sg.rootOrder
1637
1899
  : [
1638
- ...rootGroups.map(g => ({ kind: 'group', id: g.id })),
1639
- ...rootNodes.map(n => ({ kind: 'node', id: n.id })),
1640
- ...rootTables.map(t => ({ kind: 'table', id: t.id })),
1641
- ...rootNotes.map(n => ({ kind: 'note', id: n.id })),
1642
- ...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 })),
1643
1906
  ];
1644
1907
  // 5. Root-level layout
1645
1908
  // sg.layout:
1646
1909
  // 'row' → items flow left to right (default)
1647
1910
  // 'column' → items flow top to bottom
1648
1911
  // 'grid' → config columns=N grid
1649
- const rootLayout = (sg.layout ?? 'row');
1650
- const rootCols = Number(sg.config['columns'] ?? 1);
1651
- const useGrid = rootLayout === 'grid' && rootCols > 0;
1652
- const useColumn = rootLayout === 'column';
1653
- console.log('[layout] sized charts:', sg.charts.map(c => `${c.id} w=${c.w} h=${c.h}`));
1654
- 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";
1655
1916
  if (useGrid) {
1656
1917
  // ── Grid: per-row heights, per-column widths (no wasted space) ──
1657
1918
  const cols = rootCols;
@@ -1662,22 +1923,26 @@ function layout(sg) {
1662
1923
  const col = idx % cols;
1663
1924
  const row = Math.floor(idx / cols);
1664
1925
  let w = 0, h = 0;
1665
- if (ref.kind === 'group') {
1926
+ if (ref.kind === "group") {
1666
1927
  w = gm.get(ref.id).w;
1667
1928
  h = gm.get(ref.id).h;
1668
1929
  }
1669
- else if (ref.kind === 'table') {
1930
+ else if (ref.kind === "table") {
1670
1931
  w = tm.get(ref.id).w;
1671
1932
  h = tm.get(ref.id).h;
1672
1933
  }
1673
- else if (ref.kind === 'note') {
1934
+ else if (ref.kind === "note") {
1674
1935
  w = ntm.get(ref.id).w;
1675
1936
  h = ntm.get(ref.id).h;
1676
1937
  }
1677
- else if (ref.kind === 'chart') {
1938
+ else if (ref.kind === "chart") {
1678
1939
  w = cm.get(ref.id).w;
1679
1940
  h = cm.get(ref.id).h;
1680
1941
  }
1942
+ else if (ref.kind === "markdown") {
1943
+ w = mdm.get(ref.id).w;
1944
+ h = mdm.get(ref.id).h;
1945
+ }
1681
1946
  else {
1682
1947
  w = nm.get(ref.id).w;
1683
1948
  h = nm.get(ref.id).h;
@@ -1700,22 +1965,26 @@ function layout(sg) {
1700
1965
  rootOrder.forEach((ref, idx) => {
1701
1966
  const x = colX[idx % cols];
1702
1967
  const y = rowY[Math.floor(idx / cols)];
1703
- if (ref.kind === 'group') {
1968
+ if (ref.kind === "group") {
1704
1969
  gm.get(ref.id).x = x;
1705
1970
  gm.get(ref.id).y = y;
1706
1971
  }
1707
- else if (ref.kind === 'table') {
1972
+ else if (ref.kind === "table") {
1708
1973
  tm.get(ref.id).x = x;
1709
1974
  tm.get(ref.id).y = y;
1710
1975
  }
1711
- else if (ref.kind === 'note') {
1976
+ else if (ref.kind === "note") {
1712
1977
  ntm.get(ref.id).x = x;
1713
1978
  ntm.get(ref.id).y = y;
1714
1979
  }
1715
- else if (ref.kind === 'chart') {
1980
+ else if (ref.kind === "chart") {
1716
1981
  cm.get(ref.id).x = x;
1717
1982
  cm.get(ref.id).y = y;
1718
1983
  }
1984
+ else if (ref.kind === "markdown") {
1985
+ mdm.get(ref.id).x = x;
1986
+ mdm.get(ref.id).y = y;
1987
+ }
1719
1988
  else {
1720
1989
  nm.get(ref.id).x = x;
1721
1990
  nm.get(ref.id).y = y;
@@ -1727,44 +1996,52 @@ function layout(sg) {
1727
1996
  let pos = MARGIN;
1728
1997
  for (const ref of rootOrder) {
1729
1998
  let w = 0, h = 0;
1730
- if (ref.kind === 'group') {
1999
+ if (ref.kind === "group") {
1731
2000
  w = gm.get(ref.id).w;
1732
2001
  h = gm.get(ref.id).h;
1733
2002
  }
1734
- else if (ref.kind === 'table') {
2003
+ else if (ref.kind === "table") {
1735
2004
  w = tm.get(ref.id).w;
1736
2005
  h = tm.get(ref.id).h;
1737
2006
  }
1738
- else if (ref.kind === 'note') {
2007
+ else if (ref.kind === "note") {
1739
2008
  w = ntm.get(ref.id).w;
1740
2009
  h = ntm.get(ref.id).h;
1741
2010
  }
1742
- else if (ref.kind === 'chart') {
2011
+ else if (ref.kind === "chart") {
1743
2012
  w = cm.get(ref.id).w;
1744
2013
  h = cm.get(ref.id).h;
1745
2014
  }
2015
+ else if (ref.kind === "markdown") {
2016
+ w = mdm.get(ref.id).w;
2017
+ h = mdm.get(ref.id).h;
2018
+ }
1746
2019
  else {
1747
2020
  w = nm.get(ref.id).w;
1748
2021
  h = nm.get(ref.id).h;
1749
2022
  }
1750
2023
  const x = useColumn ? MARGIN : pos;
1751
2024
  const y = useColumn ? pos : MARGIN;
1752
- if (ref.kind === 'group') {
2025
+ if (ref.kind === "group") {
1753
2026
  gm.get(ref.id).x = x;
1754
2027
  gm.get(ref.id).y = y;
1755
2028
  }
1756
- else if (ref.kind === 'table') {
2029
+ else if (ref.kind === "table") {
1757
2030
  tm.get(ref.id).x = x;
1758
2031
  tm.get(ref.id).y = y;
1759
2032
  }
1760
- else if (ref.kind === 'note') {
2033
+ else if (ref.kind === "note") {
1761
2034
  ntm.get(ref.id).x = x;
1762
2035
  ntm.get(ref.id).y = y;
1763
2036
  }
1764
- else if (ref.kind === 'chart') {
2037
+ else if (ref.kind === "chart") {
1765
2038
  cm.get(ref.id).x = x;
1766
2039
  cm.get(ref.id).y = y;
1767
2040
  }
2041
+ else if (ref.kind === "markdown") {
2042
+ mdm.get(ref.id).x = x;
2043
+ mdm.get(ref.id).y = y;
2044
+ }
1768
2045
  else {
1769
2046
  nm.get(ref.id).x = x;
1770
2047
  nm.get(ref.id).y = y;
@@ -1774,10 +2051,9 @@ function layout(sg) {
1774
2051
  }
1775
2052
  // 6. Place children within each root group (top-down, recursive)
1776
2053
  for (const g of rootGroups)
1777
- place(g, nm, gm, tm, ntm, cm);
2054
+ place(g, nm, gm, tm, ntm, cm, mdm);
1778
2055
  // 7. Route edges and compute canvas size
1779
2056
  routeEdges(sg);
1780
- console.log('[layout] chart positions:', sg.charts.map(c => `${c.id} x=${c.x} y=${c.y}`));
1781
2057
  computeBounds(sg, MARGIN);
1782
2058
  return sg;
1783
2059
  }
@@ -2409,6 +2685,83 @@ function listThemes() {
2409
2685
  }
2410
2686
  const THEME_NAMES = Object.keys(PALETTES);
2411
2687
 
2688
+ // ============================================================
2689
+ // sketchmark — Font Registry
2690
+ // ============================================================
2691
+ // built-in named fonts — user can reference these by short name
2692
+ const BUILTIN_FONTS = {
2693
+ // hand-drawn
2694
+ caveat: {
2695
+ family: "'Caveat', cursive",
2696
+ url: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600&display=swap',
2697
+ },
2698
+ handlee: {
2699
+ family: "'Handlee', cursive",
2700
+ url: 'https://fonts.googleapis.com/css2?family=Handlee&display=swap',
2701
+ },
2702
+ 'indie-flower': {
2703
+ family: "'Indie Flower', cursive",
2704
+ url: 'https://fonts.googleapis.com/css2?family=Indie+Flower&display=swap',
2705
+ },
2706
+ 'patrick-hand': {
2707
+ family: "'Patrick Hand', cursive",
2708
+ url: 'https://fonts.googleapis.com/css2?family=Patrick+Hand&display=swap',
2709
+ },
2710
+ // clean / readable
2711
+ 'dm-mono': {
2712
+ family: "'DM Mono', monospace",
2713
+ url: 'https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&display=swap',
2714
+ },
2715
+ 'jetbrains': {
2716
+ family: "'JetBrains Mono', monospace",
2717
+ url: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap',
2718
+ },
2719
+ 'instrument': {
2720
+ family: "'Instrument Serif', serif",
2721
+ url: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap',
2722
+ },
2723
+ 'playfair': {
2724
+ family: "'Playfair Display', serif",
2725
+ url: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap',
2726
+ },
2727
+ // system fallbacks (no URL needed)
2728
+ system: { family: 'system-ui, sans-serif' },
2729
+ mono: { family: "'Courier New', monospace" },
2730
+ serif: { family: 'Georgia, serif' },
2731
+ };
2732
+ // default — what renders when no font is specified
2733
+ const DEFAULT_FONT = 'system-ui, sans-serif';
2734
+ // resolve a short name or pass-through a quoted CSS family
2735
+ function resolveFont(nameOrFamily) {
2736
+ const key = nameOrFamily.toLowerCase().trim();
2737
+ if (BUILTIN_FONTS[key])
2738
+ return BUILTIN_FONTS[key].family;
2739
+ return nameOrFamily; // treat as raw CSS font-family
2740
+ }
2741
+ // inject a <link> into <head> for a built-in font (browser only)
2742
+ function loadFont(name) {
2743
+ if (typeof document === 'undefined')
2744
+ return;
2745
+ const key = name.toLowerCase().trim();
2746
+ const def = BUILTIN_FONTS[key];
2747
+ if (!def?.url || def.loaded)
2748
+ return;
2749
+ if (document.querySelector(`link[data-sketchmark-font="${key}"]`))
2750
+ return;
2751
+ const link = document.createElement('link');
2752
+ link.rel = 'stylesheet';
2753
+ link.href = def.url;
2754
+ link.setAttribute('data-sketchmark-font', key);
2755
+ document.head.appendChild(link);
2756
+ def.loaded = true;
2757
+ }
2758
+ // user registers their own font (already loaded via CSS/link)
2759
+ function registerFont(name, family, url) {
2760
+ BUILTIN_FONTS[name.toLowerCase()] = { family, url };
2761
+ if (url)
2762
+ loadFont(name);
2763
+ }
2764
+
2412
2765
  // ============================================================
2413
2766
  // sketchmark — SVG Renderer (rough.js hand-drawn)
2414
2767
  // ============================================================
@@ -2421,17 +2774,93 @@ function hashStr$3(s) {
2421
2774
  return h;
2422
2775
  }
2423
2776
  const BASE_ROUGH = { roughness: 1.3, bowing: 0.7 };
2424
- // ── SVG helpers ───────────────────────────────────────────
2425
- function mkMultilineText(lines, x, cy, // vertical center of the whole block
2426
- sz = 14, wt = 500, col = "#1a1208", anchor = "middle", lineH = 18) {
2777
+ // ── Small helper: load + resolve font from style or fall back ─────────────
2778
+ function resolveStyleFont$1(style, fallback) {
2779
+ const raw = String(style["font"] ?? "");
2780
+ if (!raw)
2781
+ return fallback;
2782
+ loadFont(raw);
2783
+ return resolveFont(raw);
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
+ }
2805
+ // ── SVG text helpers ──────────────────────────────────────────────────────
2806
+ /**
2807
+ * Single-line SVG text element.
2808
+ *
2809
+ * | param | maps to SVG attr |
2810
+ * |---------------|--------------------------|
2811
+ * txt | textContent |
2812
+ * x, y | x, y |
2813
+ * sz | font-size |
2814
+ * wt | font-weight |
2815
+ * col | fill |
2816
+ * anchor | text-anchor |
2817
+ * font | font-family |
2818
+ * letterSpacing | letter-spacing |
2819
+ */
2820
+ function mkText(txt, x, y, sz = 14, wt = 500, col = "#1a1208", anchor = "middle", font, letterSpacing) {
2821
+ const t = se("text");
2822
+ t.setAttribute("x", String(x));
2823
+ t.setAttribute("y", String(y));
2824
+ t.setAttribute("text-anchor", anchor);
2825
+ t.setAttribute("dominant-baseline", "middle");
2826
+ t.setAttribute("font-family", font ?? "var(--font-sans, system-ui, sans-serif)");
2827
+ t.setAttribute("font-size", String(sz));
2828
+ t.setAttribute("font-weight", String(wt));
2829
+ t.setAttribute("fill", col);
2830
+ t.setAttribute("pointer-events", "none");
2831
+ t.setAttribute("user-select", "none");
2832
+ if (letterSpacing != null)
2833
+ t.setAttribute("letter-spacing", String(letterSpacing));
2834
+ t.textContent = txt;
2835
+ return t;
2836
+ }
2837
+ /**
2838
+ * Multi-line SVG text element using <tspan> per line.
2839
+ *
2840
+ * | param | maps to SVG attr |
2841
+ * |---------------|--------------------------|
2842
+ * lines | one <tspan> each |
2843
+ * x | tspan x |
2844
+ * cy | vertical centre of block |
2845
+ * sz | font-size |
2846
+ * wt | font-weight |
2847
+ * col | fill |
2848
+ * anchor | text-anchor |
2849
+ * lineH | dy between tspans (px) |
2850
+ * font | font-family |
2851
+ * letterSpacing | letter-spacing |
2852
+ */
2853
+ function mkMultilineText(lines, x, cy, sz = 14, wt = 500, col = "#1a1208", anchor = "middle", lineH = 18, font, letterSpacing) {
2427
2854
  const t = se("text");
2428
2855
  t.setAttribute("text-anchor", anchor);
2429
- t.setAttribute("font-family", "var(--font-sans, system-ui, sans-serif)");
2856
+ t.setAttribute("font-family", font ?? "var(--font-sans, system-ui, sans-serif)");
2430
2857
  t.setAttribute("font-size", String(sz));
2431
2858
  t.setAttribute("font-weight", String(wt));
2432
2859
  t.setAttribute("fill", col);
2433
2860
  t.setAttribute("pointer-events", "none");
2434
2861
  t.setAttribute("user-select", "none");
2862
+ if (letterSpacing != null)
2863
+ t.setAttribute("letter-spacing", String(letterSpacing));
2435
2864
  // vertically centre the whole block
2436
2865
  const totalH = (lines.length - 1) * lineH;
2437
2866
  const startY = cy - totalH / 2;
@@ -2445,21 +2874,6 @@ sz = 14, wt = 500, col = "#1a1208", anchor = "middle", lineH = 18) {
2445
2874
  });
2446
2875
  return t;
2447
2876
  }
2448
- function mkText(txt, x, y, sz = 14, wt = 500, col = "#1a1208", anchor = "middle") {
2449
- const t = se("text");
2450
- t.setAttribute("x", String(x));
2451
- t.setAttribute("y", String(y));
2452
- t.setAttribute("text-anchor", anchor);
2453
- t.setAttribute("dominant-baseline", "middle");
2454
- t.setAttribute("font-family", "var(--font-sans, system-ui, sans-serif)");
2455
- t.setAttribute("font-size", String(sz));
2456
- t.setAttribute("font-weight", String(wt));
2457
- t.setAttribute("fill", col);
2458
- t.setAttribute("pointer-events", "none");
2459
- t.setAttribute("user-select", "none");
2460
- t.textContent = txt;
2461
- return t;
2462
- }
2463
2877
  function mkGroup(id, cls) {
2464
2878
  const g = se("g");
2465
2879
  if (id)
@@ -2468,7 +2882,7 @@ function mkGroup(id, cls) {
2468
2882
  g.setAttribute("class", cls);
2469
2883
  return g;
2470
2884
  }
2471
- // ── Arrow direction from connector ────────────────────────
2885
+ // ── Arrow direction from connector ────────────────────────────────────────
2472
2886
  function connMeta$1(connector) {
2473
2887
  if (connector === "--")
2474
2888
  return { arrowAt: "none", dashed: false };
@@ -2483,7 +2897,7 @@ function connMeta$1(connector) {
2483
2897
  return { arrowAt: "start", dashed };
2484
2898
  return { arrowAt: "end", dashed };
2485
2899
  }
2486
- // ── Generic rect connection point ─────────────────────────
2900
+ // ── Generic rect connection point ─────────────────────────────────────────
2487
2901
  function rectConnPoint$1(rx, ry, rw, rh, ox, oy) {
2488
2902
  const cx = rx + rw / 2, cy = ry + rh / 2;
2489
2903
  const dx = ox - cx, dy = oy - cy;
@@ -2508,7 +2922,7 @@ function getConnPoint$1(src, dstCX, dstCY) {
2508
2922
  }
2509
2923
  return rectConnPoint$1(src.x, src.y, src.w, src.h, dstCX, dstCY);
2510
2924
  }
2511
- // ── Group depth (for paint order) ─────────────────────────
2925
+ // ── Group depth (for paint order) ─────────────────────────────────────────
2512
2926
  function groupDepth$1(g, gm) {
2513
2927
  let d = 0;
2514
2928
  let cur = g;
@@ -2518,7 +2932,7 @@ function groupDepth$1(g, gm) {
2518
2932
  }
2519
2933
  return d;
2520
2934
  }
2521
- // ── Node shapes ───────────────────────────────────────────
2935
+ // ── Node shapes ───────────────────────────────────────────────────────────
2522
2936
  function renderShape$1(rc, n, palette) {
2523
2937
  const s = n.style ?? {};
2524
2938
  const fill = String(s.fill ?? palette.nodeFill);
@@ -2591,19 +3005,18 @@ function renderShape$1(rc, n, palette) {
2591
3005
  return [];
2592
3006
  case "image": {
2593
3007
  if (n.imageUrl) {
2594
- const img = document.createElementNS("http://www.w3.org/2000/svg", "image");
3008
+ const img = document.createElementNS(NS, "image");
2595
3009
  img.setAttribute("href", n.imageUrl);
2596
3010
  img.setAttribute("x", String(n.x + 1));
2597
3011
  img.setAttribute("y", String(n.y + 1));
2598
3012
  img.setAttribute("width", String(n.w - 2));
2599
3013
  img.setAttribute("height", String(n.h - 2));
2600
3014
  img.setAttribute("preserveAspectRatio", "xMidYMid meet");
2601
- // optional: clip to rounded rect
2602
3015
  const clipId = `clip-${n.id}`;
2603
- const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
2604
- const clip = document.createElementNS("http://www.w3.org/2000/svg", "clipPath");
3016
+ const defs = document.createElementNS(NS, "defs");
3017
+ const clip = document.createElementNS(NS, "clipPath");
2605
3018
  clip.setAttribute("id", clipId);
2606
- const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
3019
+ const rect = document.createElementNS(NS, "rect");
2607
3020
  rect.setAttribute("x", String(n.x + 1));
2608
3021
  rect.setAttribute("y", String(n.y + 1));
2609
3022
  rect.setAttribute("width", String(n.w - 2));
@@ -2612,15 +3025,12 @@ function renderShape$1(rc, n, palette) {
2612
3025
  clip.appendChild(rect);
2613
3026
  defs.appendChild(clip);
2614
3027
  img.setAttribute("clip-path", `url(#${clipId})`);
2615
- // border box drawn on top
2616
3028
  const border = rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
2617
3029
  ...opts,
2618
3030
  fill: "none",
2619
- fillStyle: "solid",
2620
3031
  });
2621
3032
  return [defs, img, border];
2622
3033
  }
2623
- // fallback: no URL → grey placeholder box
2624
3034
  return [
2625
3035
  rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
2626
3036
  ...opts,
@@ -2633,7 +3043,7 @@ function renderShape$1(rc, n, palette) {
2633
3043
  return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts)];
2634
3044
  }
2635
3045
  }
2636
- // ── Arrowhead ─────────────────────────────────────────────
3046
+ // ── Arrowhead ─────────────────────────────────────────────────────────────
2637
3047
  function arrowHead(rc, x, y, angle, col, seed) {
2638
3048
  const as = 12;
2639
3049
  return rc.polygon([
@@ -2657,14 +3067,22 @@ function arrowHead(rc, x, y, angle, col, seed) {
2657
3067
  }
2658
3068
  function renderToSVG(sg, container, options = {}) {
2659
3069
  if (typeof rough === "undefined") {
2660
- throw new Error('rough.js is not loaded. Add <script src="https://unpkg.com/roughjs/bundled/rough.js"></script>');
3070
+ throw new Error("rough.js is not loaded.");
2661
3071
  }
2662
3072
  const isDark = options.theme === "dark" ||
2663
3073
  (options.theme === "auto" &&
2664
3074
  window.matchMedia?.("(prefers-color-scheme:dark)").matches);
2665
- // Resolve palette: DSL config takes priority, then options.theme, then light
2666
3075
  const themeName = String(sg.config[THEME_CONFIG_KEY] ?? (isDark ? "dark" : "light"));
2667
3076
  const palette = resolvePalette(themeName);
3077
+ // ── Diagram-level font ──────────────────────────────────
3078
+ const diagramFont = (() => {
3079
+ const raw = String(sg.config["font"] ?? "");
3080
+ if (raw) {
3081
+ loadFont(raw);
3082
+ return resolveFont(raw);
3083
+ }
3084
+ return DEFAULT_FONT;
3085
+ })();
2668
3086
  BASE_ROUGH.roughness = options.roughness ?? 1.3;
2669
3087
  BASE_ROUGH.bowing = options.bowing ?? 0.7;
2670
3088
  let svg;
@@ -2681,14 +3099,7 @@ function renderToSVG(sg, container, options = {}) {
2681
3099
  svg.setAttribute("height", String(sg.height));
2682
3100
  svg.setAttribute("viewBox", `0 0 ${sg.width} ${sg.height}`);
2683
3101
  svg.style.fontFamily = "var(--font-sans, system-ui, sans-serif)";
2684
- // Background rect so exported SVGs have correct bg
2685
- // const bgRect = se("rect") as SVGRectElement;
2686
- // bgRect.setAttribute("x", "0");
2687
- // bgRect.setAttribute("y", "0");
2688
- // bgRect.setAttribute("width", String(sg.width));
2689
- // bgRect.setAttribute("height", String(sg.height));
2690
- // bgRect.setAttribute("fill", palette.background);
2691
- // svg.appendChild(bgRect);
3102
+ // ── Background ─────────────────────────────────────────
2692
3103
  if (!options.transparent) {
2693
3104
  const bgRect = se("rect");
2694
3105
  bgRect.setAttribute("x", "0");
@@ -2704,9 +3115,9 @@ function renderToSVG(sg, container, options = {}) {
2704
3115
  const titleColor = String(sg.config["title-color"] ?? palette.titleText);
2705
3116
  const titleSize = Number(sg.config["title-size"] ?? 18);
2706
3117
  const titleWeight = Number(sg.config["title-weight"] ?? 600);
2707
- svg.appendChild(mkText(sg.title, sg.width / 2, 26, titleSize, titleWeight, titleColor));
3118
+ svg.appendChild(mkText(sg.title, sg.width / 2, 26, titleSize, titleWeight, titleColor, "middle", diagramFont));
2708
3119
  }
2709
- // ── Groups (depth-sorted: outermost first) ────────────────
3120
+ // ── Groups ───────────────────────────────────────────────
2710
3121
  const gmMap = new Map(sg.groups.map((g) => [g.id, g]));
2711
3122
  const sortedGroups = [...sg.groups].sort((a, b) => groupDepth$1(a, gmMap) - groupDepth$1(b, gmMap));
2712
3123
  const GL = mkGroup("grp-layer");
@@ -2726,8 +3137,16 @@ function renderToSVG(sg, container, options = {}) {
2726
3137
  strokeWidth: Number(gs.strokeWidth ?? 1.2),
2727
3138
  strokeLineDash: gs.strokeDash ?? palette.groupDash,
2728
3139
  }));
2729
- const labelColor = gs.color ? String(gs.color) : palette.groupLabel;
2730
- gg.appendChild(mkText(g.label, g.x + 14, g.y + 14, 12, 500, labelColor, "start"));
3140
+ // ── Group label typography ──────────────────────────
3141
+ // supports: font, font-size, letter-spacing
3142
+ // always left-anchored (single line)
3143
+ const gLabelColor = gs.color ? String(gs.color) : palette.groupLabel;
3144
+ const gFontSize = Number(gs.fontSize ?? 12);
3145
+ const gFont = resolveStyleFont$1(gs, diagramFont);
3146
+ const gLetterSpacing = gs.letterSpacing;
3147
+ if (g.label) {
3148
+ gg.appendChild(mkText(g.label, g.x + 14, g.y + 14, gFontSize, 500, gLabelColor, "start", gFont, gLetterSpacing));
3149
+ }
2731
3150
  GL.appendChild(gg);
2732
3151
  }
2733
3152
  svg.appendChild(GL);
@@ -2781,7 +3200,13 @@ function renderToSVG(sg, container, options = {}) {
2781
3200
  bg.setAttribute("rx", "3");
2782
3201
  bg.setAttribute("opacity", "0.9");
2783
3202
  eg.appendChild(bg);
2784
- eg.appendChild(mkText(e.label, mx, my, 11, 400, palette.edgeLabelText));
3203
+ // ── Edge label typography ───────────────────────
3204
+ // supports: font, font-size, letter-spacing
3205
+ // always center-anchored (single line floating on edge)
3206
+ const eFontSize = Number(e.style?.fontSize ?? 11);
3207
+ const eFont = resolveStyleFont$1(e.style ?? {}, diagramFont);
3208
+ const eLetterSpacing = e.style?.letterSpacing;
3209
+ eg.appendChild(mkText(e.label, mx, my, eFontSize, 400, palette.edgeLabelText, "middle", eFont, eLetterSpacing));
2785
3210
  }
2786
3211
  EL.appendChild(eg);
2787
3212
  }
@@ -2791,14 +3216,45 @@ function renderToSVG(sg, container, options = {}) {
2791
3216
  for (const n of sg.nodes) {
2792
3217
  const ng = mkGroup(`node-${n.id}`, "ng");
2793
3218
  renderShape$1(rc, n, palette).forEach((s) => ng.appendChild(s));
3219
+ // ── Node / text typography ─────────────────────────
3220
+ // supports: font, font-size, letter-spacing, text-align, line-height
2794
3221
  const fontSize = Number(n.style?.fontSize ?? (n.shape === "text" ? 13 : 14));
2795
3222
  const fontWeight = n.style?.fontWeight ?? (n.shape === "text" ? 400 : 500);
2796
- const lines = n.label.split("\n");
3223
+ const textColor = String(n.style?.color ??
3224
+ (n.shape === "text" ? palette.edgeLabelText : palette.nodeText));
3225
+ const nodeFont = resolveStyleFont$1(n.style ?? {}, diagramFont);
3226
+ const textAlign = String(n.style?.textAlign ?? "center");
3227
+ const anchorMap = {
3228
+ left: "start",
3229
+ center: "middle",
3230
+ right: "end",
3231
+ };
3232
+ const textAnchor = anchorMap[textAlign] ?? "middle";
3233
+ // line-height is a multiplier (e.g. 1.4 = 140% of font-size)
3234
+ const lineHeight = Number(n.style?.lineHeight ?? 1.3) * fontSize;
3235
+ const letterSpacing = n.style?.letterSpacing;
3236
+ // x shifts for left / right alignment
3237
+ const textX = textAlign === "left"
3238
+ ? n.x + 8
3239
+ : textAlign === "right"
3240
+ ? n.x + n.w - 8
3241
+ : n.x + n.w / 2;
3242
+ const lines = n.shape === 'text' && !n.label.includes('\n')
3243
+ ? wrapText$1(n.label, n.w - 16, fontSize)
3244
+ : n.label.split('\n');
3245
+ const verticalAlign = String(n.style?.verticalAlign ?? "middle");
3246
+ const nodeBodyTop = n.y + 6;
3247
+ const nodeBodyBottom = n.y + n.h - 6;
3248
+ const nodeBodyMid = n.y + n.h / 2;
3249
+ const blockH = (lines.length - 1) * lineHeight;
3250
+ const textCY = verticalAlign === "top"
3251
+ ? nodeBodyTop + blockH / 2
3252
+ : verticalAlign === "bottom"
3253
+ ? nodeBodyBottom - blockH / 2
3254
+ : nodeBodyMid;
2797
3255
  ng.appendChild(lines.length > 1
2798
- ? mkMultilineText(lines, n.x + n.w / 2, n.y + n.h / 2, fontSize, fontWeight, String(n.style?.color ??
2799
- (n.shape === "text" ? palette.edgeLabelText : palette.nodeText)))
2800
- : mkText(n.label, n.x + n.w / 2, n.y + n.h / 2, fontSize, fontWeight, String(n.style?.color ??
2801
- (n.shape === "text" ? palette.edgeLabelText : palette.nodeText))));
3256
+ ? mkMultilineText(lines, textX, textCY, fontSize, fontWeight, textColor, textAnchor, lineHeight, nodeFont, letterSpacing)
3257
+ : mkText(n.label, textX, textCY, fontSize, fontWeight, textColor, textAnchor, nodeFont, letterSpacing));
2802
3258
  if (options.interactive) {
2803
3259
  ng.style.cursor = "pointer";
2804
3260
  ng.addEventListener("click", () => options.onNodeClick?.(n.id));
@@ -2824,7 +3280,12 @@ function renderToSVG(sg, container, options = {}) {
2824
3280
  const hdrText = String(gs.color ?? palette.tableHeaderText);
2825
3281
  const divCol = palette.tableDivider;
2826
3282
  const pad = t.labelH;
2827
- // Outer border
3283
+ // ── Table-level font (applies to label + all cells) ─
3284
+ // supports: font, font-size, letter-spacing
3285
+ const tFontSize = Number(gs.fontSize ?? 12);
3286
+ const tFont = resolveStyleFont$1(gs, diagramFont);
3287
+ const tLetterSpacing = gs.letterSpacing;
3288
+ // outer border
2828
3289
  tg.appendChild(rc.rectangle(t.x, t.y, t.w, t.h, {
2829
3290
  ...BASE_ROUGH,
2830
3291
  seed: hashStr$3(t.id),
@@ -2833,20 +3294,19 @@ function renderToSVG(sg, container, options = {}) {
2833
3294
  stroke: strk,
2834
3295
  strokeWidth: 1.5,
2835
3296
  }));
2836
- // Label strip separator
3297
+ // label strip separator
2837
3298
  tg.appendChild(rc.line(t.x, t.y + pad, t.x + t.w, t.y + pad, {
2838
3299
  roughness: 0.6,
2839
3300
  seed: hashStr$3(t.id + "l"),
2840
3301
  stroke: strk,
2841
3302
  strokeWidth: 1,
2842
3303
  }));
2843
- // Label text
2844
- tg.appendChild(mkText(t.label, t.x + 10, t.y + pad / 2, 12, 500, textCol, "start"));
2845
- // Rows
3304
+ // ── Table label: font, font-size, letter-spacing (always left) ──
3305
+ tg.appendChild(mkText(t.label, t.x + 10, t.y + pad / 2, tFontSize, 500, textCol, "start", tFont, tLetterSpacing));
3306
+ // rows
2846
3307
  let rowY = t.y + pad;
2847
3308
  for (const row of t.rows) {
2848
3309
  const rh = row.kind === "header" ? t.headerH : t.rowH;
2849
- // Header background fill
2850
3310
  if (row.kind === "header") {
2851
3311
  const hdrBg = se("rect");
2852
3312
  hdrBg.setAttribute("x", String(t.x + 1));
@@ -2856,19 +3316,34 @@ function renderToSVG(sg, container, options = {}) {
2856
3316
  hdrBg.setAttribute("fill", hdrFill);
2857
3317
  tg.appendChild(hdrBg);
2858
3318
  }
2859
- // Row separator
2860
3319
  tg.appendChild(rc.line(t.x, rowY + rh, t.x + t.w, rowY + rh, {
2861
3320
  roughness: 0.4,
2862
3321
  seed: hashStr$3(t.id + rowY),
2863
3322
  stroke: row.kind === "header" ? strk : divCol,
2864
3323
  strokeWidth: row.kind === "header" ? 1.2 : 0.6,
2865
3324
  }));
2866
- // Cell text + col separators
3325
+ // ── Cell text: font, font-size, letter-spacing, text-align ──
3326
+ // text-align applies to data rows; header is always centered
3327
+ const cellAlignProp = row.kind === "header" ? "center" : String(gs.textAlign ?? "center");
3328
+ const cellAnchorMap = {
3329
+ left: "start",
3330
+ center: "middle",
3331
+ right: "end",
3332
+ };
3333
+ const cellAnchor = cellAnchorMap[cellAlignProp] ?? "middle";
3334
+ const cellFw = row.kind === "header" ? 600 : 400;
3335
+ const cellColor = row.kind === "header" ? hdrText : textCol;
2867
3336
  let cx = t.x;
2868
3337
  row.cells.forEach((cell, i) => {
2869
3338
  const cw = t.colWidths[i] ?? 60;
2870
- const fw = row.kind === "header" ? 600 : 400;
2871
- tg.appendChild(mkText(cell, cx + cw / 2, rowY + rh / 2, 12, fw, row.kind === "header" ? hdrText : textCol));
3339
+ // x position shifts with alignment
3340
+ const cellX = cellAnchor === "start"
3341
+ ? cx + 6
3342
+ : cellAnchor === "end"
3343
+ ? cx + cw - 6
3344
+ : cx + cw / 2;
3345
+ // ← was missing tg.appendChild — cells were invisible before
3346
+ tg.appendChild(mkText(cell, cellX, rowY + rh / 2, tFontSize, cellFw, cellColor, cellAnchor, tFont, tLetterSpacing));
2872
3347
  if (i < row.cells.length - 1) {
2873
3348
  tg.appendChild(rc.line(cx + cw, t.y + pad, cx + cw, t.y + t.h, {
2874
3349
  roughness: 0.3,
@@ -2897,6 +3372,25 @@ function renderToSVG(sg, container, options = {}) {
2897
3372
  const strk = String(gs.stroke ?? palette.noteStroke);
2898
3373
  const fold = 14;
2899
3374
  const { x, y, w, h } = n;
3375
+ // ── Note typography ─────────────────────────────────
3376
+ // supports: font, font-size, letter-spacing, text-align, line-height
3377
+ const nFontSize = Number(gs.fontSize ?? 12);
3378
+ const nFont = resolveStyleFont$1(gs, diagramFont);
3379
+ const nLetterSpacing = gs.letterSpacing;
3380
+ const nLineHeight = Number(gs.lineHeight ?? 1.4) * nFontSize;
3381
+ const nTextAlign = String(gs.textAlign ?? "left");
3382
+ const nAnchorMap = {
3383
+ left: "start",
3384
+ center: "middle",
3385
+ right: "end",
3386
+ };
3387
+ const nAnchor = nAnchorMap[nTextAlign] ?? "start";
3388
+ // x position for the text block (pad from left, with alignment)
3389
+ const nTextX = nTextAlign === "right"
3390
+ ? x + w - fold - 6
3391
+ : nTextAlign === "center"
3392
+ ? x + (w - fold) / 2
3393
+ : x + 12;
2900
3394
  ng.appendChild(rc.polygon([
2901
3395
  [x, y],
2902
3396
  [x + w - fold, y],
@@ -2923,12 +3417,75 @@ function renderToSVG(sg, container, options = {}) {
2923
3417
  stroke: strk,
2924
3418
  strokeWidth: 0.8,
2925
3419
  }));
2926
- n.lines.forEach((line, i) => {
2927
- ng.appendChild(mkText(line, x + 12, y + 12 + i * 20 + 10, 12, 400, String(gs.color ?? palette.noteText), "start"));
2928
- });
3420
+ const nVerticalAlign = String(gs.verticalAlign ?? "top");
3421
+ const bodyTop = y + fold + 8; // below the fold triangle
3422
+ const bodyBottom = y + h - 8; // above bottom edge
3423
+ const bodyMid = (bodyTop + bodyBottom) / 2;
3424
+ const blockH = (n.lines.length - 1) * nLineHeight;
3425
+ const blockCY = nVerticalAlign === "bottom"
3426
+ ? bodyBottom - blockH / 2
3427
+ : nVerticalAlign === "middle"
3428
+ ? bodyMid
3429
+ : bodyTop + blockH / 2;
3430
+ // multiline: use mkMultilineText so line-height is respected
3431
+ if (n.lines.length > 1) {
3432
+ // vertical centre of the text block inside the note
3433
+ ng.appendChild(mkMultilineText(n.lines, nTextX, blockCY, nFontSize, 400, String(gs.color ?? palette.noteText), nAnchor, nLineHeight, nFont, nLetterSpacing));
3434
+ }
3435
+ else {
3436
+ ng.appendChild(mkText(n.lines[0] ?? "", nTextX, blockCY, nFontSize, 400, String(gs.color ?? palette.noteText), nAnchor, nFont, nLetterSpacing));
3437
+ }
2929
3438
  NoteL.appendChild(ng);
2930
3439
  }
2931
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);
2932
3489
  // ── Charts ────────────────────────────────────────────────
2933
3490
  const CL = mkGroup("chart-layer");
2934
3491
  for (const c of sg.charts) {
@@ -3202,22 +3759,83 @@ function hashStr$1(s) {
3202
3759
  h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
3203
3760
  return h;
3204
3761
  }
3205
- // ── Arrow direction from connector (mirrors svg/index.ts)
3762
+ // ── Small helper: load + resolve font from a style map ────────────────────
3763
+ function resolveStyleFont(style, fallback) {
3764
+ const raw = String(style['font'] ?? '');
3765
+ if (!raw)
3766
+ return fallback;
3767
+ loadFont(raw);
3768
+ return resolveFont(raw);
3769
+ }
3770
+ // ── Canvas text helpers ────────────────────────────────────────────────────
3771
+ function drawText(ctx, txt, x, y, sz = 14, wt = 500, col = '#1a1208', align = 'center', font = 'system-ui, sans-serif', letterSpacing) {
3772
+ ctx.save();
3773
+ ctx.font = `${wt} ${sz}px ${font}`;
3774
+ ctx.fillStyle = col;
3775
+ ctx.textAlign = align;
3776
+ ctx.textBaseline = 'middle';
3777
+ if (letterSpacing) {
3778
+ // Canvas has no native letter-spacing — draw char by char
3779
+ const chars = txt.split('');
3780
+ const totalW = ctx.measureText(txt).width + letterSpacing * (chars.length - 1);
3781
+ let startX = align === 'center' ? x - totalW / 2
3782
+ : align === 'right' ? x - totalW
3783
+ : x;
3784
+ ctx.textAlign = 'left';
3785
+ for (const ch of chars) {
3786
+ ctx.fillText(ch, startX, y);
3787
+ startX += ctx.measureText(ch).width + letterSpacing;
3788
+ }
3789
+ }
3790
+ else {
3791
+ ctx.fillText(txt, x, y);
3792
+ }
3793
+ ctx.restore();
3794
+ }
3795
+ function drawMultilineText(ctx, lines, x, cy, sz = 14, wt = 500, col = '#1a1208', align = 'center', lineH = 18, font = 'system-ui, sans-serif', letterSpacing) {
3796
+ const totalH = (lines.length - 1) * lineH;
3797
+ const startY = cy - totalH / 2;
3798
+ lines.forEach((line, i) => {
3799
+ drawText(ctx, line, x, startY + i * lineH, sz, wt, col, align, font, letterSpacing);
3800
+ });
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
+ }
3823
+ // ── Arrow direction ────────────────────────────────────────────────────────
3206
3824
  function connMeta(connector) {
3207
- if (connector === "--")
3208
- return { arrowAt: "none", dashed: false };
3209
- if (connector === "---")
3210
- return { arrowAt: "none", dashed: true };
3211
- const bidir = connector.includes("<") && connector.includes(">");
3825
+ if (connector === '--')
3826
+ return { arrowAt: 'none', dashed: false };
3827
+ if (connector === '---')
3828
+ return { arrowAt: 'none', dashed: true };
3829
+ const bidir = connector.includes('<') && connector.includes('>');
3212
3830
  if (bidir)
3213
- return { arrowAt: "both", dashed: connector.includes("--") };
3214
- const back = connector.startsWith("<");
3215
- const dashed = connector.includes("--");
3831
+ return { arrowAt: 'both', dashed: connector.includes('--') };
3832
+ const back = connector.startsWith('<');
3833
+ const dashed = connector.includes('--');
3216
3834
  if (back)
3217
- return { arrowAt: "start", dashed };
3218
- return { arrowAt: "end", dashed };
3835
+ return { arrowAt: 'start', dashed };
3836
+ return { arrowAt: 'end', dashed };
3219
3837
  }
3220
- // ── Generic rect connection point ─────────────────────────
3838
+ // ── Rect connection point ──────────────────────────────────────────────────
3221
3839
  function rectConnPoint(rx, ry, rw, rh, ox, oy) {
3222
3840
  const cx = rx + rw / 2, cy = ry + rh / 2;
3223
3841
  const dx = ox - cx, dy = oy - cy;
@@ -3230,19 +3848,16 @@ function rectConnPoint(rx, ry, rw, rh, ox, oy) {
3230
3848
  return [cx + t * dx, cy + t * dy];
3231
3849
  }
3232
3850
  function resolveEndpoint(id, nm, tm, gm, cm, ntm) {
3233
- return (nm.get(id) ?? tm.get(id) ?? gm.get(id) ?? cm.get(id) ?? ntm.get(id) ?? null);
3851
+ return nm.get(id) ?? tm.get(id) ?? gm.get(id) ?? cm.get(id) ?? ntm.get(id) ?? null;
3234
3852
  }
3235
3853
  function getConnPoint(src, dstCX, dstCY) {
3236
- if ("shape" in src && src.shape) {
3854
+ if ('shape' in src && src.shape) {
3237
3855
  return connPoint(src, {
3238
- x: dstCX - 1,
3239
- y: dstCY - 1,
3240
- w: 2,
3241
- h: 2});
3856
+ x: dstCX - 1, y: dstCY - 1, w: 2, h: 2});
3242
3857
  }
3243
3858
  return rectConnPoint(src.x, src.y, src.w, src.h, dstCX, dstCY);
3244
3859
  }
3245
- // ── Group depth (for paint order, outermost first) ────────
3860
+ // ── Group depth ────────────────────────────────────────────────────────────
3246
3861
  function groupDepth(g, gm) {
3247
3862
  let d = 0;
3248
3863
  let cur = g;
@@ -3252,80 +3867,57 @@ function groupDepth(g, gm) {
3252
3867
  }
3253
3868
  return d;
3254
3869
  }
3255
- // ── Node shapes ───────────────────────────────────────────
3870
+ // ── Node shapes ────────────────────────────────────────────────────────────
3256
3871
  function renderShape(rc, ctx, n, palette, R) {
3257
3872
  const s = n.style ?? {};
3258
3873
  const fill = String(s.fill ?? palette.nodeFill);
3259
3874
  const stroke = String(s.stroke ?? palette.nodeStroke);
3260
3875
  const opts = {
3261
- ...R,
3262
- seed: hashStr$1(n.id),
3263
- fill,
3264
- fillStyle: "solid",
3265
- stroke,
3266
- strokeWidth: Number(s.strokeWidth ?? 1.9),
3876
+ ...R, seed: hashStr$1(n.id),
3877
+ fill, fillStyle: 'solid',
3878
+ stroke, strokeWidth: Number(s.strokeWidth ?? 1.9),
3267
3879
  };
3268
3880
  const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
3269
3881
  const hw = n.w / 2 - 2;
3270
3882
  switch (n.shape) {
3271
- case "circle":
3883
+ case 'circle':
3272
3884
  rc.ellipse(cx, cy, n.w * 0.88, n.h * 0.88, opts);
3273
3885
  break;
3274
- case "diamond":
3275
- rc.polygon([
3276
- [cx, n.y + 2],
3277
- [cx + hw, cy],
3278
- [cx, n.y + n.h - 2],
3279
- [cx - hw, cy],
3280
- ], opts);
3886
+ case 'diamond':
3887
+ rc.polygon([[cx, n.y + 2], [cx + hw, cy], [cx, n.y + n.h - 2], [cx - hw, cy]], opts);
3281
3888
  break;
3282
- case "hexagon": {
3889
+ case 'hexagon': {
3283
3890
  const hw2 = hw * 0.56;
3284
3891
  rc.polygon([
3285
- [cx - hw2, n.y + 3],
3286
- [cx + hw2, n.y + 3],
3287
- [cx + hw, cy],
3288
- [cx + hw2, n.y + n.h - 3],
3289
- [cx - hw2, n.y + n.h - 3],
3290
- [cx - hw, cy],
3892
+ [cx - hw2, n.y + 3], [cx + hw2, n.y + 3], [cx + hw, cy],
3893
+ [cx + hw2, n.y + n.h - 3], [cx - hw2, n.y + n.h - 3], [cx - hw, cy],
3291
3894
  ], opts);
3292
3895
  break;
3293
3896
  }
3294
- case "triangle":
3295
- rc.polygon([
3296
- [cx, n.y + 3],
3297
- [n.x + n.w - 3, n.y + n.h - 3],
3298
- [n.x + 3, n.y + n.h - 3],
3299
- ], opts);
3897
+ case 'triangle':
3898
+ rc.polygon([[cx, n.y + 3], [n.x + n.w - 3, n.y + n.h - 3], [n.x + 3, n.y + n.h - 3]], opts);
3300
3899
  break;
3301
- case "cylinder": {
3900
+ case 'cylinder': {
3302
3901
  const eH = 18;
3303
3902
  rc.rectangle(n.x + 3, n.y + eH / 2, n.w - 6, n.h - eH, opts);
3304
3903
  rc.ellipse(cx, n.y + eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6 });
3305
- rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, {
3306
- ...opts,
3307
- roughness: 0.6,
3308
- fill: "none",
3309
- });
3904
+ rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6, fill: 'none' });
3310
3905
  break;
3311
3906
  }
3312
- case "parallelogram":
3907
+ case 'parallelogram':
3313
3908
  rc.polygon([
3314
- [n.x + 18, n.y + 1],
3315
- [n.x + n.w - 1, n.y + 1],
3316
- [n.x + n.w - 18, n.y + n.h - 1],
3317
- [n.x + 1, n.y + n.h - 1],
3909
+ [n.x + 18, n.y + 1], [n.x + n.w - 1, n.y + 1],
3910
+ [n.x + n.w - 18, n.y + n.h - 1], [n.x + 1, n.y + n.h - 1],
3318
3911
  ], opts);
3319
3912
  break;
3320
- case "text":
3321
- break; // text nodes: no background shape
3322
- case "image": {
3913
+ case 'text':
3914
+ break; // no shape drawn
3915
+ case 'image': {
3323
3916
  if (n.imageUrl) {
3324
3917
  const img = new Image();
3325
- img.crossOrigin = "anonymous";
3918
+ img.crossOrigin = 'anonymous';
3326
3919
  img.onload = () => {
3327
3920
  ctx.save();
3328
- // rounded clip
3329
3921
  ctx.beginPath();
3330
3922
  const r = 6;
3331
3923
  ctx.moveTo(n.x + r, n.y);
@@ -3341,21 +3933,12 @@ function renderShape(rc, ctx, n, palette, R) {
3341
3933
  ctx.clip();
3342
3934
  ctx.drawImage(img, n.x + 1, n.y + 1, n.w - 2, n.h - 2);
3343
3935
  ctx.restore();
3344
- // border on top
3345
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
3346
- ...opts,
3347
- fill: "none",
3348
- });
3936
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: 'none' });
3349
3937
  };
3350
3938
  img.src = n.imageUrl;
3351
3939
  }
3352
3940
  else {
3353
- // placeholder
3354
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
3355
- ...opts,
3356
- fill: "#e0e0e0",
3357
- stroke: "#999999",
3358
- });
3941
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: '#e0e0e0', stroke: '#999999' });
3359
3942
  }
3360
3943
  return;
3361
3944
  }
@@ -3364,57 +3947,52 @@ function renderShape(rc, ctx, n, palette, R) {
3364
3947
  break;
3365
3948
  }
3366
3949
  }
3367
- // ── Arrowhead ─────────────────────────────────────────────
3950
+ // ── Arrowhead ─────────────────────────────────────────────────────────────
3368
3951
  function drawArrowHead(rc, x, y, angle, col, seed, R) {
3369
3952
  const as = 12;
3370
3953
  rc.polygon([
3371
3954
  [x, y],
3372
- [
3373
- x - as * Math.cos(angle - Math.PI / 6.5),
3374
- y - as * Math.sin(angle - Math.PI / 6.5),
3375
- ],
3376
- [
3377
- x - as * Math.cos(angle + Math.PI / 6.5),
3378
- y - as * Math.sin(angle + Math.PI / 6.5),
3379
- ],
3380
- ], {
3381
- roughness: 0.3,
3382
- seed,
3383
- fill: col,
3384
- fillStyle: "solid",
3385
- stroke: col,
3386
- strokeWidth: 0.8,
3387
- });
3955
+ [x - as * Math.cos(angle - Math.PI / 6.5), y - as * Math.sin(angle - Math.PI / 6.5)],
3956
+ [x - as * Math.cos(angle + Math.PI / 6.5), y - as * Math.sin(angle + Math.PI / 6.5)],
3957
+ ], { roughness: 0.3, seed, fill: col, fillStyle: 'solid', stroke: col, strokeWidth: 0.8 });
3388
3958
  }
3959
+ // ── Public API ─────────────────────────────────────────────────────────────
3389
3960
  function renderToCanvas(sg, canvas, options = {}) {
3390
- if (typeof rough === "undefined")
3391
- throw new Error("rough.js not loaded");
3961
+ if (typeof rough === 'undefined')
3962
+ throw new Error('rough.js not loaded');
3392
3963
  const scale = options.scale ?? window.devicePixelRatio ?? 1;
3393
3964
  canvas.width = sg.width * scale;
3394
3965
  canvas.height = sg.height * scale;
3395
- canvas.style.width = sg.width + "px";
3396
- canvas.style.height = sg.height + "px";
3397
- const ctx = canvas.getContext("2d");
3966
+ canvas.style.width = sg.width + 'px';
3967
+ canvas.style.height = sg.height + 'px';
3968
+ const ctx = canvas.getContext('2d');
3398
3969
  ctx.scale(scale, scale);
3399
- if (options.transparent) {
3400
- ctx.clearRect(0, 0, canvas.width, canvas.height);
3401
- }
3402
- // ── Resolve palette (mirrors SVG renderer) ───────────────
3403
- const isDark = options.theme === "dark" ||
3404
- (options.theme === "auto" &&
3405
- window.matchMedia?.("(prefers-color-scheme:dark)").matches);
3406
- const themeName = String(sg.config[THEME_CONFIG_KEY] ?? (isDark ? "dark" : "light"));
3970
+ // ── Palette ──────────────────────────────────────────────
3971
+ const isDark = options.theme === 'dark' ||
3972
+ (options.theme === 'auto' &&
3973
+ typeof window !== 'undefined' &&
3974
+ window.matchMedia?.('(prefers-color-scheme:dark)').matches);
3975
+ const themeName = String(sg.config[THEME_CONFIG_KEY] ?? (isDark ? 'dark' : 'light'));
3407
3976
  const palette = resolvePalette(themeName);
3977
+ // ── Diagram-level font ───────────────────────────────────
3978
+ const diagramFont = (() => {
3979
+ const raw = String(sg.config['font'] ?? '');
3980
+ if (raw) {
3981
+ loadFont(raw);
3982
+ return resolveFont(raw);
3983
+ }
3984
+ return DEFAULT_FONT;
3985
+ })();
3986
+ // ── Background ───────────────────────────────────────────
3408
3987
  if (!options.transparent) {
3409
3988
  ctx.fillStyle = options.background ?? palette.background;
3410
3989
  ctx.fillRect(0, 0, sg.width, sg.height);
3411
3990
  }
3991
+ else {
3992
+ ctx.clearRect(0, 0, sg.width, sg.height);
3993
+ }
3412
3994
  const rc = rough.canvas(canvas);
3413
- const R = {
3414
- roughness: options.roughness ?? 1.3,
3415
- bowing: options.bowing ?? 0.7,
3416
- };
3417
- // ── Lookup maps ──────────────────────────────────────────
3995
+ const R = { roughness: options.roughness ?? 1.3, bowing: options.bowing ?? 0.7 };
3418
3996
  const nm = nodeMap(sg);
3419
3997
  const tm = tableMap(sg);
3420
3998
  const gm = groupMap(sg);
@@ -3422,36 +4000,35 @@ function renderToCanvas(sg, canvas, options = {}) {
3422
4000
  const ntm = noteMap(sg);
3423
4001
  // ── Title ────────────────────────────────────────────────
3424
4002
  if (sg.title) {
3425
- ctx.save();
3426
- ctx.font = "600 18px system-ui, sans-serif";
3427
- ctx.fillStyle = palette.titleText;
3428
- ctx.textAlign = "center";
3429
- ctx.fillText(sg.title, sg.width / 2, 28);
3430
- ctx.restore();
4003
+ const titleSize = Number(sg.config['title-size'] ?? 18);
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);
3431
4007
  }
3432
- // ── Groups (depth-sorted: outermost first) ────────────────
4008
+ // ── Groups (outermost first) ─────────────────────────────
3433
4009
  const sortedGroups = [...sg.groups].sort((a, b) => groupDepth(a, gm) - groupDepth(b, gm));
3434
4010
  for (const g of sortedGroups) {
3435
4011
  if (!g.w)
3436
4012
  continue;
3437
4013
  const gs = g.style ?? {};
3438
4014
  rc.rectangle(g.x, g.y, g.w, g.h, {
3439
- ...R,
3440
- roughness: 1.7,
3441
- bowing: 0.4,
3442
- seed: hashStr$1(g.id),
4015
+ ...R, roughness: 1.7, bowing: 0.4, seed: hashStr$1(g.id),
3443
4016
  fill: String(gs.fill ?? palette.groupFill),
3444
- fillStyle: "solid",
4017
+ fillStyle: 'solid',
3445
4018
  stroke: String(gs.stroke ?? palette.groupStroke),
3446
4019
  strokeWidth: Number(gs.strokeWidth ?? 1.2),
3447
4020
  strokeLineDash: gs.strokeDash ?? palette.groupDash,
3448
4021
  });
3449
- ctx.save();
3450
- ctx.font = "500 12px system-ui, sans-serif";
3451
- ctx.fillStyle = gs.color ? String(gs.color) : palette.groupLabel;
3452
- ctx.textAlign = "left";
3453
- ctx.fillText(g.label, g.x + 14, g.y + 16);
3454
- ctx.restore();
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
+ }
3455
4032
  }
3456
4033
  // ── Edges ─────────────────────────────────────────────────
3457
4034
  for (const e of sg.edges) {
@@ -3468,62 +4045,75 @@ function renderToCanvas(sg, canvas, options = {}) {
3468
4045
  const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) || 1;
3469
4046
  const nx = (x2 - x1) / len, ny = (y2 - y1) / len;
3470
4047
  const HEAD = 13;
3471
- const sx1 = arrowAt === "start" || arrowAt === "both" ? x1 + nx * HEAD : x1;
3472
- const sy1 = arrowAt === "start" || arrowAt === "both" ? y1 + ny * HEAD : y1;
3473
- const sx2 = arrowAt === "end" || arrowAt === "both" ? x2 - nx * HEAD : x2;
3474
- const sy2 = arrowAt === "end" || arrowAt === "both" ? y2 - ny * HEAD : y2;
4048
+ const sx1 = arrowAt === 'start' || arrowAt === 'both' ? x1 + nx * HEAD : x1;
4049
+ const sy1 = arrowAt === 'start' || arrowAt === 'both' ? y1 + ny * HEAD : y1;
4050
+ const sx2 = arrowAt === 'end' || arrowAt === 'both' ? x2 - nx * HEAD : x2;
4051
+ const sy2 = arrowAt === 'end' || arrowAt === 'both' ? y2 - ny * HEAD : y2;
3475
4052
  rc.line(sx1, sy1, sx2, sy2, {
3476
- ...R,
3477
- roughness: 0.9,
3478
- seed: hashStr$1(e.from + e.to),
4053
+ ...R, roughness: 0.9, seed: hashStr$1(e.from + e.to),
3479
4054
  stroke: ecol,
3480
4055
  strokeWidth: Number(e.style?.strokeWidth ?? 1.6),
3481
4056
  ...(dashed ? { strokeLineDash: [6, 5] } : {}),
3482
4057
  });
3483
4058
  const ang = Math.atan2(y2 - y1, x2 - x1);
3484
- if (arrowAt === "end" || arrowAt === "both")
4059
+ if (arrowAt === 'end' || arrowAt === 'both')
3485
4060
  drawArrowHead(rc, x2, y2, ang, ecol, hashStr$1(e.to));
3486
- if (arrowAt === "start" || arrowAt === "both")
3487
- drawArrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$1(e.from + "back"));
4061
+ if (arrowAt === 'start' || arrowAt === 'both')
4062
+ drawArrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$1(e.from + 'back'));
3488
4063
  if (e.label) {
3489
4064
  const mx = (x1 + x2) / 2 - ny * 14;
3490
4065
  const my = (y1 + y2) / 2 + nx * 14;
4066
+ // ── Edge label: font, font-size, letter-spacing ──
4067
+ // always center-anchored (single line)
4068
+ const eFontSize = Number(e.style?.fontSize ?? 11);
4069
+ const eFont = resolveStyleFont(e.style ?? {}, diagramFont);
4070
+ const eLetterSpacing = e.style?.letterSpacing;
3491
4071
  ctx.save();
3492
- ctx.font = "400 11px system-ui, sans-serif";
3493
- ctx.textAlign = "center";
4072
+ ctx.font = `400 ${eFontSize}px ${eFont}`;
3494
4073
  const tw = ctx.measureText(e.label).width + 12;
4074
+ ctx.restore();
3495
4075
  ctx.fillStyle = palette.edgeLabelBg;
3496
4076
  ctx.fillRect(mx - tw / 2, my - 8, tw, 15);
3497
- ctx.fillStyle = palette.edgeLabelText;
3498
- ctx.fillText(e.label, mx, my + 3);
3499
- ctx.restore();
4077
+ drawText(ctx, e.label, mx, my + 3, eFontSize, 400, palette.edgeLabelText, 'center', eFont, eLetterSpacing);
3500
4078
  }
3501
4079
  }
3502
4080
  // ── Nodes ─────────────────────────────────────────────────
3503
4081
  for (const n of sg.nodes) {
3504
4082
  renderShape(rc, ctx, n, palette, R);
3505
- const s = n.style ?? {};
3506
- const fontSize = Number(s.fontSize ?? (n.shape === "text" ? 13 : 14));
3507
- const fontWeight = s.fontWeight ?? (n.shape === "text" ? 400 : 500);
3508
- const textColor = String(s.color ??
3509
- (n.shape === "text" ? palette.edgeLabelText : palette.nodeText));
3510
- ctx.save();
3511
- ctx.font = `${fontWeight} ${fontSize}px system-ui, sans-serif`;
3512
- ctx.fillStyle = textColor;
3513
- ctx.textAlign = "center";
3514
- ctx.textBaseline = "middle";
3515
- const lines = n.label.split("\n");
3516
- if (lines.length === 1) {
3517
- ctx.fillText(n.label, n.x + n.w / 2, n.y + n.h / 2);
4083
+ // ── Node / text typography ─────────────────────────
4084
+ // supports: font, font-size, letter-spacing, text-align,
4085
+ // vertical-align, line-height, word-wrap (text shape)
4086
+ const fontSize = Number(n.style?.fontSize ?? (n.shape === 'text' ? 13 : 14));
4087
+ const fontWeight = n.style?.fontWeight ?? (n.shape === 'text' ? 400 : 500);
4088
+ const textColor = String(n.style?.color ??
4089
+ (n.shape === 'text' ? palette.edgeLabelText : palette.nodeText));
4090
+ const nodeFont = resolveStyleFont(n.style ?? {}, diagramFont);
4091
+ const textAlign = String(n.style?.textAlign ?? 'center');
4092
+ const lineHeight = Number(n.style?.lineHeight ?? 1.3) * fontSize;
4093
+ const letterSpacing = n.style?.letterSpacing;
4094
+ const vertAlign = String(n.style?.verticalAlign ?? 'middle');
4095
+ // x shifts for left/right alignment
4096
+ const textX = textAlign === 'left' ? n.x + 8
4097
+ : textAlign === 'right' ? n.x + n.w - 8
4098
+ : n.x + n.w / 2;
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)
4111
+ if (lines.length > 1) {
4112
+ drawMultilineText(ctx, lines, textX, textCY, fontSize, fontWeight, textColor, textAlign, lineHeight, nodeFont, letterSpacing);
3518
4113
  }
3519
4114
  else {
3520
- const lineH = fontSize * 1.35;
3521
- const startY = n.y + n.h / 2 - ((lines.length - 1) * lineH) / 2;
3522
- lines.forEach((line, i) => {
3523
- ctx.fillText(line, n.x + n.w / 2, startY + i * lineH);
3524
- });
4115
+ drawText(ctx, lines[0] ?? '', textX, textCY, fontSize, fontWeight, textColor, textAlign, nodeFont, letterSpacing);
3525
4116
  }
3526
- ctx.restore();
3527
4117
  }
3528
4118
  // ── Tables ────────────────────────────────────────────────
3529
4119
  for (const t of sg.tables) {
@@ -3532,65 +4122,53 @@ function renderToCanvas(sg, canvas, options = {}) {
3532
4122
  const strk = String(gs.stroke ?? palette.tableStroke);
3533
4123
  const textCol = String(gs.color ?? palette.tableText);
3534
4124
  const pad = t.labelH;
3535
- // Outer border
4125
+ // ── Table-level font ────────────────────────────────
4126
+ // supports: font, font-size, letter-spacing
4127
+ // cells also support text-align
4128
+ const tFontSize = Number(gs.fontSize ?? 12);
4129
+ const tFont = resolveStyleFont(gs, diagramFont);
4130
+ const tLetterSpacing = gs.letterSpacing;
3536
4131
  rc.rectangle(t.x, t.y, t.w, t.h, {
3537
- ...R,
3538
- seed: hashStr$1(t.id),
3539
- fill,
3540
- fillStyle: "solid",
3541
- stroke: strk,
3542
- strokeWidth: 1.5,
4132
+ ...R, seed: hashStr$1(t.id),
4133
+ fill, fillStyle: 'solid', stroke: strk, strokeWidth: 1.5,
3543
4134
  });
3544
- // Label strip separator
3545
4135
  rc.line(t.x, t.y + pad, t.x + t.w, t.y + pad, {
3546
- roughness: 0.6,
3547
- seed: hashStr$1(t.id + "l"),
3548
- stroke: strk,
3549
- strokeWidth: 1,
4136
+ roughness: 0.6, seed: hashStr$1(t.id + 'l'), stroke: strk, strokeWidth: 1,
3550
4137
  });
3551
- // Label text
3552
- ctx.save();
3553
- ctx.font = "500 12px system-ui, sans-serif";
3554
- ctx.fillStyle = textCol;
3555
- ctx.textAlign = "left";
3556
- ctx.textBaseline = "middle";
3557
- ctx.fillText(t.label, t.x + 10, t.y + pad / 2);
3558
- ctx.restore();
3559
- // Rows
4138
+ // ── Table label: always left-anchored ───────────────
4139
+ drawText(ctx, t.label, t.x + 10, t.y + pad / 2, tFontSize, 500, textCol, 'left', tFont, tLetterSpacing);
3560
4140
  let rowY = t.y + pad;
3561
4141
  for (const row of t.rows) {
3562
- const rh = row.kind === "header" ? t.headerH : t.rowH;
3563
- // Header background
3564
- if (row.kind === "header") {
4142
+ const rh = row.kind === 'header' ? t.headerH : t.rowH;
4143
+ if (row.kind === 'header') {
3565
4144
  ctx.fillStyle = palette.tableHeaderFill;
3566
4145
  ctx.fillRect(t.x + 1, rowY + 1, t.w - 2, rh - 1);
3567
4146
  }
3568
- // Row separator
3569
4147
  rc.line(t.x, rowY + rh, t.x + t.w, rowY + rh, {
3570
- roughness: 0.4,
3571
- seed: hashStr$1(t.id + rowY),
3572
- stroke: row.kind === "header" ? strk : palette.tableDivider,
3573
- strokeWidth: row.kind === "header" ? 1.2 : 0.6,
4148
+ roughness: 0.4, seed: hashStr$1(t.id + rowY),
4149
+ stroke: row.kind === 'header' ? strk : palette.tableDivider,
4150
+ strokeWidth: row.kind === 'header' ? 1.2 : 0.6,
3574
4151
  });
3575
- // Cell text + column separators
4152
+ // ── Cell text ───────────────────────────────────
4153
+ // header always centered; data rows respect gs.textAlign
4154
+ const cellAlignProp = (row.kind === 'header'
4155
+ ? 'center'
4156
+ : String(gs.textAlign ?? 'center'));
4157
+ const cellFw = row.kind === 'header' ? 600 : 400;
4158
+ const cellColor = row.kind === 'header'
4159
+ ? String(gs.color ?? palette.tableHeaderText)
4160
+ : textCol;
3576
4161
  let cx = t.x;
3577
4162
  row.cells.forEach((cell, i) => {
3578
4163
  const cw = t.colWidths[i] ?? 60;
3579
- const fw = row.kind === "header" ? 600 : 400;
3580
- ctx.save();
3581
- ctx.font = `${fw} 12px system-ui, sans-serif`;
3582
- ctx.fillStyle =
3583
- row.kind === "header" ? palette.tableHeaderText : textCol;
3584
- ctx.textAlign = "center";
3585
- ctx.textBaseline = "middle";
3586
- ctx.fillText(cell, cx + cw / 2, rowY + rh / 2);
3587
- ctx.restore();
4164
+ const cellX = cellAlignProp === 'left' ? cx + 6
4165
+ : cellAlignProp === 'right' ? cx + cw - 6
4166
+ : cx + cw / 2;
4167
+ drawText(ctx, cell, cellX, rowY + rh / 2, tFontSize, cellFw, cellColor, cellAlignProp, tFont, tLetterSpacing);
3588
4168
  if (i < row.cells.length - 1) {
3589
4169
  rc.line(cx + cw, t.y + pad, cx + cw, t.y + t.h, {
3590
- roughness: 0.3,
3591
- seed: hashStr$1(t.id + "c" + i),
3592
- stroke: palette.tableDivider,
3593
- strokeWidth: 0.5,
4170
+ roughness: 0.3, seed: hashStr$1(t.id + 'c' + i),
4171
+ stroke: palette.tableDivider, strokeWidth: 0.5,
3594
4172
  });
3595
4173
  }
3596
4174
  cx += cw;
@@ -3605,44 +4183,106 @@ function renderToCanvas(sg, canvas, options = {}) {
3605
4183
  const strk = String(gs.stroke ?? palette.noteStroke);
3606
4184
  const fold = 14;
3607
4185
  const { x, y, w, h } = n;
3608
- // Note body (folded corner polygon)
3609
4186
  rc.polygon([
3610
4187
  [x, y],
3611
4188
  [x + w - fold, y],
3612
4189
  [x + w, y + fold],
3613
4190
  [x + w, y + h],
3614
4191
  [x, y + h],
3615
- ], {
3616
- ...R,
3617
- seed: hashStr$1(n.id),
3618
- fill,
3619
- fillStyle: "solid",
3620
- stroke: strk,
3621
- strokeWidth: 1.2,
3622
- });
3623
- // Folded corner triangle
4192
+ ], { ...R, seed: hashStr$1(n.id), fill, fillStyle: 'solid', stroke: strk, strokeWidth: 1.2 });
3624
4193
  rc.polygon([
3625
4194
  [x + w - fold, y],
3626
4195
  [x + w, y + fold],
3627
4196
  [x + w - fold, y + fold],
3628
- ], {
3629
- roughness: 0.4,
3630
- seed: hashStr$1(n.id + "f"),
3631
- fill: palette.noteFold,
3632
- fillStyle: "solid",
3633
- stroke: strk,
3634
- strokeWidth: 0.8,
3635
- });
3636
- // Text lines
3637
- ctx.save();
3638
- ctx.font = "400 12px system-ui, sans-serif";
3639
- ctx.fillStyle = String(gs.color ?? palette.noteText);
3640
- ctx.textAlign = "left";
3641
- ctx.textBaseline = "middle";
3642
- n.lines.forEach((line, i) => {
3643
- ctx.fillText(line, x + 12, y + 12 + i * 20 + 10);
3644
- });
3645
- ctx.restore();
4197
+ ], { roughness: 0.4, seed: hashStr$1(n.id + 'f'),
4198
+ fill: palette.noteFold, fillStyle: 'solid', stroke: strk, strokeWidth: 0.8 });
4199
+ // ── Note typography ─────────────────────────────────
4200
+ // supports: font, font-size, letter-spacing, text-align,
4201
+ // vertical-align, line-height
4202
+ const nFontSize = Number(gs.fontSize ?? 12);
4203
+ const nFont = resolveStyleFont(gs, diagramFont);
4204
+ const nLetterSpacing = gs.letterSpacing;
4205
+ const nLineHeight = Number(gs.lineHeight ?? 1.4) * nFontSize;
4206
+ const nTextAlign = String(gs.textAlign ?? 'left');
4207
+ const nVertAlign = String(gs.verticalAlign ?? 'top');
4208
+ const nColor = String(gs.color ?? palette.noteText);
4209
+ const nTextX = nTextAlign === 'right' ? x + w - fold - 6
4210
+ : nTextAlign === 'center' ? x + (w - fold) / 2
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)
4219
+ if (n.lines.length > 1) {
4220
+ drawMultilineText(ctx, n.lines, nTextX, blockCY, nFontSize, 400, nColor, nTextAlign, nLineHeight, nFont, nLetterSpacing);
4221
+ }
4222
+ else {
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];
4285
+ }
3646
4286
  }
3647
4287
  // ── Charts ────────────────────────────────────────────────
3648
4288
  for (const c of sg.charts) {
@@ -3654,19 +4294,19 @@ function renderToCanvas(sg, canvas, options = {}) {
3654
4294
  }, R);
3655
4295
  }
3656
4296
  }
3657
- // ── Export canvas to PNG blob ─────────────────────────────
4297
+ // ── Export helpers ─────────────────────────────────────────────────────────
3658
4298
  function canvasToPNGBlob(canvas) {
3659
4299
  return new Promise((resolve, reject) => {
3660
- canvas.toBlob((blob) => {
4300
+ canvas.toBlob(blob => {
3661
4301
  if (blob)
3662
4302
  resolve(blob);
3663
4303
  else
3664
- reject(new Error("Canvas toBlob failed"));
3665
- }, "image/png");
4304
+ reject(new Error('Canvas toBlob failed'));
4305
+ }, 'image/png');
3666
4306
  });
3667
4307
  }
3668
4308
  function canvasToPNGDataURL(canvas) {
3669
- return canvas.toDataURL("image/png");
4309
+ return canvas.toDataURL('image/png');
3670
4310
  }
3671
4311
 
3672
4312
  // ============================================================
@@ -4601,6 +5241,89 @@ class EventEmitter {
4601
5241
  }
4602
5242
  }
4603
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.anmism7.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
+
4604
5327
  // ============================================================
4605
5328
  // sketchmark — Public API
4606
5329
  // ============================================================
@@ -4665,5 +5388,5 @@ function render(options) {
4665
5388
  return instance;
4666
5389
  }
4667
5390
 
4668
- export { ANIMATION_CSS, AnimationController, 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, nodeMap, parse, parseHex, render, renderToCanvas, renderToSVG, 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 };
4669
5392
  //# sourceMappingURL=index.js.map