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.cjs CHANGED
@@ -24,6 +24,7 @@ const KEYWORDS = new Set([
24
24
  "step",
25
25
  "config",
26
26
  "theme",
27
+ "bare",
27
28
  "bar-chart",
28
29
  "line-chart",
29
30
  "pie-chart",
@@ -63,6 +64,7 @@ const KEYWORDS = new Set([
63
64
  "dag",
64
65
  "tree",
65
66
  "force",
67
+ "markdown",
66
68
  ]);
67
69
  const ARROW_PATTERNS = ["<-->", "<->", "-->", "<--", "->", "<-", "---", "--"];
68
70
  // Characters that can start an arrow pattern — used to decide whether a '-'
@@ -100,6 +102,23 @@ function tokenize(src) {
100
102
  i++;
101
103
  continue;
102
104
  }
105
+ if (ch === '"' && peek(1) === '"' && peek(2) === '"') {
106
+ i += 3; // skip opening """
107
+ let raw = "";
108
+ while (i < src.length) {
109
+ if (src[i] === '"' && src[i + 1] === '"' && src[i + 2] === '"') {
110
+ i += 3; // skip closing """
111
+ break;
112
+ }
113
+ if (src[i] === "\n") {
114
+ line++;
115
+ lineStart = i + 1;
116
+ }
117
+ raw += src[i++];
118
+ }
119
+ add("STRING_BLOCK", raw);
120
+ continue;
121
+ }
103
122
  // Strings
104
123
  if (ch === '"' || ch === "'") {
105
124
  const q = ch;
@@ -250,6 +269,18 @@ function propsToStyle(p) {
250
269
  s.fontSize = parseFloat(p["font-size"]);
251
270
  if (p["font-weight"])
252
271
  s.fontWeight = p["font-weight"];
272
+ if (p["text-align"])
273
+ s.textAlign = p["text-align"];
274
+ if (p.padding)
275
+ s.padding = parseFloat(p.padding);
276
+ if (p["vertical-align"])
277
+ s.verticalAlign = p["vertical-align"];
278
+ if (p["line-height"])
279
+ s.lineHeight = parseFloat(p["line-height"]);
280
+ if (p["letter-spacing"])
281
+ s.letterSpacing = parseFloat(p["letter-spacing"]);
282
+ if (p.font)
283
+ s.font = p.font;
253
284
  if (p["dash"]) {
254
285
  const parts = p["dash"]
255
286
  .split(",")
@@ -287,6 +318,7 @@ function parse(src) {
287
318
  notes: [],
288
319
  charts: [],
289
320
  tables: [],
321
+ markdowns: [],
290
322
  styles: {},
291
323
  themes: {},
292
324
  config: {},
@@ -297,6 +329,7 @@ function parse(src) {
297
329
  const noteIds = new Set();
298
330
  const chartIds = new Set();
299
331
  const groupIds = new Set();
332
+ const markdownIds = new Set();
300
333
  let i = 0;
301
334
  const cur = () => flat[i] ?? flat[flat.length - 1];
302
335
  const peek1 = () => flat[i + 1] ?? flat[flat.length - 1];
@@ -476,6 +509,8 @@ function parse(src) {
476
509
  label: rawLabel.replace(/\\n/g, "\n"),
477
510
  theme: props.theme,
478
511
  style: propsToStyle(props),
512
+ ...(props.width ? { width: parseFloat(props.width) } : {}),
513
+ ...(props.height ? { height: parseFloat(props.height) } : {}),
479
514
  };
480
515
  }
481
516
  // ── parseGroup ───────────────────────────────────────────
@@ -508,7 +543,7 @@ function parse(src) {
508
543
  const group = {
509
544
  kind: "group",
510
545
  id,
511
- label: props.label ?? id,
546
+ label: props.label ?? "",
512
547
  children: [],
513
548
  layout: props.layout,
514
549
  columns: props.columns !== undefined ? parseInt(props.columns, 10) : undefined,
@@ -534,8 +569,18 @@ function parse(src) {
534
569
  break;
535
570
  const v = cur().value;
536
571
  // ── Nested group ──────────────────────────────────
537
- if (v === "group") {
572
+ if (v === "group" || v === "bare") {
573
+ const isBare = v === "bare";
538
574
  const nested = parseGroup();
575
+ if (isBare) {
576
+ nested.label = "";
577
+ nested.style = {
578
+ ...nested.style,
579
+ fill: nested.style?.fill ?? "none",
580
+ stroke: nested.style?.stroke ?? "none",
581
+ strokeWidth: nested.style?.strokeWidth ?? 0,
582
+ };
583
+ }
539
584
  ast.groups.push(nested);
540
585
  groupIds.add(nested.id);
541
586
  group.children.push({ kind: "group", id: nested.id });
@@ -557,6 +602,21 @@ function parse(src) {
557
602
  group.children.push({ kind: "note", id: note.id });
558
603
  continue;
559
604
  }
605
+ // ── Markdown ───────────────────────────────────────
606
+ if (v === "markdown") {
607
+ const md = parseMarkdown(id);
608
+ ast.markdowns.push(md);
609
+ markdownIds.add(md.id);
610
+ group.children.push({ kind: "markdown", id: md.id });
611
+ continue;
612
+ }
613
+ if (v === "bare") {
614
+ // treat exactly like 'group' but inject defaults
615
+ const grp = parseGroup(); // reuse parseGroup
616
+ grp.label = "";
617
+ grp.style = { ...grp.style, stroke: "none", fill: "none" };
618
+ // rest is identical to group handling
619
+ }
560
620
  // ── Chart ──────────────────────────────────────────
561
621
  if (CHART_TYPES.includes(v)) {
562
622
  const chart = parseChart(v);
@@ -600,71 +660,71 @@ function parse(src) {
600
660
  function parseStep() {
601
661
  skip();
602
662
  const toks = lineTokens();
603
- const action = (toks[0]?.value ?? 'highlight');
604
- let target = toks[1]?.value ?? '';
605
- if (toks[2]?.type === 'ARROW' && toks[3]) {
663
+ const action = (toks[0]?.value ?? "highlight");
664
+ let target = toks[1]?.value ?? "";
665
+ if (toks[2]?.type === "ARROW" && toks[3]) {
606
666
  target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
607
667
  }
608
- const step = { kind: 'step', action, target };
668
+ const step = { kind: "step", action, target };
609
669
  for (let j = 2; j < toks.length; j++) {
610
670
  const k = toks[j]?.value;
611
671
  const eq = toks[j + 1];
612
672
  const vt = toks[j + 2];
613
673
  // key=value form
614
- if (eq?.type === 'EQUALS' && vt) {
615
- if (k === 'dx') {
674
+ if (eq?.type === "EQUALS" && vt) {
675
+ if (k === "dx") {
616
676
  step.dx = parseFloat(vt.value);
617
677
  j += 2;
618
678
  continue;
619
679
  }
620
- if (k === 'dy') {
680
+ if (k === "dy") {
621
681
  step.dy = parseFloat(vt.value);
622
682
  j += 2;
623
683
  continue;
624
684
  }
625
- if (k === 'duration') {
685
+ if (k === "duration") {
626
686
  step.duration = parseFloat(vt.value);
627
687
  j += 2;
628
688
  continue;
629
689
  }
630
- if (k === 'delay') {
690
+ if (k === "delay") {
631
691
  step.delay = parseFloat(vt.value);
632
692
  j += 2;
633
693
  continue;
634
694
  }
635
- if (k === 'factor') {
695
+ if (k === "factor") {
636
696
  step.factor = parseFloat(vt.value);
637
697
  j += 2;
638
698
  continue;
639
699
  }
640
- if (k === 'deg') {
700
+ if (k === "deg") {
641
701
  step.deg = parseFloat(vt.value);
642
702
  j += 2;
643
703
  continue;
644
704
  }
645
- if (k === 'fill') {
705
+ if (k === "fill") {
646
706
  step.value = vt.value;
647
707
  j += 2;
648
708
  continue;
649
709
  }
650
- if (k === 'color') {
710
+ if (k === "color") {
651
711
  step.value = vt.value;
652
712
  j += 2;
653
713
  continue;
654
714
  }
655
715
  }
656
716
  // bare key value (legacy)
657
- if (k === 'delay' && eq?.type === 'NUMBER') {
717
+ if (k === "delay" && eq?.type === "NUMBER") {
658
718
  step.delay = parseFloat(eq.value);
659
719
  j++;
660
720
  continue;
661
721
  }
662
- if (k === 'duration' && eq?.type === 'NUMBER') {
722
+ if (k === "duration" && eq?.type === "NUMBER") {
663
723
  step.duration = parseFloat(eq.value);
664
724
  j++;
665
725
  continue;
666
726
  }
667
- if (k === 'trigger') {
727
+ if (k === "trigger") {
668
728
  step.trigger = eq?.value;
669
729
  j++;
670
730
  continue;
@@ -864,6 +924,40 @@ function parse(src) {
864
924
  skip();
865
925
  return table;
866
926
  }
927
+ // ── parseMarkdown ─────────────────────────────────────────
928
+ function parseMarkdown(groupId) {
929
+ skip(); // 'markdown'
930
+ const toks = lineTokens();
931
+ let id = groupId ? groupId + "_" + uid("md") : uid("md");
932
+ if (toks[0])
933
+ id = toks[0].value;
934
+ const props = {};
935
+ let j = 1;
936
+ while (j < toks.length - 1) {
937
+ const k = toks[j], eq = toks[j + 1];
938
+ if (eq?.type === "EQUALS" && j + 2 < toks.length) {
939
+ props[k.value] = toks[j + 2].value;
940
+ j += 3;
941
+ }
942
+ else
943
+ j++;
944
+ }
945
+ skipNL();
946
+ let content = "";
947
+ if (cur().type === "STRING_BLOCK") {
948
+ content = cur().value;
949
+ skip();
950
+ }
951
+ return {
952
+ kind: "markdown",
953
+ id,
954
+ content: content.trim(),
955
+ width: props.width ? parseFloat(props.width) : undefined,
956
+ height: props.height ? parseFloat(props.height) : undefined,
957
+ theme: props.theme,
958
+ style: propsToStyle(props),
959
+ };
960
+ }
867
961
  // ── Main parse loop ─────────────────────────────────────
868
962
  skipNL();
869
963
  if (cur().value === "diagram")
@@ -977,8 +1071,18 @@ function parse(src) {
977
1071
  continue;
978
1072
  }
979
1073
  // group
980
- if (v === "group") {
1074
+ if (v === "group" || v === "bare") {
1075
+ const isBare = v === "bare";
981
1076
  const grp = parseGroup();
1077
+ if (isBare) {
1078
+ grp.label = "";
1079
+ grp.style = {
1080
+ ...grp.style,
1081
+ fill: grp.style?.fill ?? "none",
1082
+ stroke: grp.style?.stroke ?? "none",
1083
+ strokeWidth: grp.style?.strokeWidth ?? 0,
1084
+ };
1085
+ }
982
1086
  ast.groups.push(grp);
983
1087
  groupIds.add(grp.id);
984
1088
  ast.rootOrder.push({ kind: "group", id: grp.id });
@@ -1013,6 +1117,13 @@ function parse(src) {
1013
1117
  ast.rootOrder.push({ kind: "chart", id: chart.id }); // ← ADD
1014
1118
  continue;
1015
1119
  }
1120
+ if (v === "markdown") {
1121
+ const md = parseMarkdown();
1122
+ ast.markdowns.push(md);
1123
+ markdownIds.add(md.id);
1124
+ ast.rootOrder.push({ kind: "markdown", id: md.id });
1125
+ continue;
1126
+ }
1016
1127
  // edge: A -> B (MUST come before shape check)
1017
1128
  if (t.type === "IDENT" || t.type === "STRING" || t.type === "KEYWORD") {
1018
1129
  const nextTok = flat[i + 1];
@@ -1062,11 +1173,94 @@ function parse(src) {
1062
1173
  node.style = { ...ast.styles[node.id], ...node.style };
1063
1174
  }
1064
1175
  }
1065
- console.log("[parse] charts:", ast.charts.map((c) => c.id));
1066
- console.log("[parse] rootOrder:", ast.rootOrder.map((r) => r.kind + ":" + r.id));
1067
1176
  return ast;
1068
1177
  }
1069
1178
 
1179
+ // ============================================================
1180
+ // sketchmark — Markdown inline parser
1181
+ // Supports: # h1 ## h2 ### h3 **bold** *italic* blank lines
1182
+ // ============================================================
1183
+ // ── Font sizes per line kind ──────────────────────────────
1184
+ const LINE_FONT_SIZE = {
1185
+ h1: 40,
1186
+ h2: 28,
1187
+ h3: 20,
1188
+ p: 15,
1189
+ blank: 0,
1190
+ };
1191
+ const LINE_FONT_WEIGHT = {
1192
+ h1: 700,
1193
+ h2: 600,
1194
+ h3: 600,
1195
+ p: 400,
1196
+ blank: 400,
1197
+ };
1198
+ // Spacing below each line kind (px)
1199
+ const LINE_SPACING = {
1200
+ h1: 52,
1201
+ h2: 38,
1202
+ h3: 28,
1203
+ p: 22,
1204
+ blank: 10,
1205
+ };
1206
+ // ── Parse a full markdown string into lines ───────────────
1207
+ function parseMarkdownContent(content) {
1208
+ const raw = content.split('\n');
1209
+ const lines = [];
1210
+ for (const line of raw) {
1211
+ const t = line.trim();
1212
+ if (!t) {
1213
+ lines.push({ kind: 'blank', runs: [] });
1214
+ continue;
1215
+ }
1216
+ if (t.startsWith('### ')) {
1217
+ lines.push({ kind: 'h3', runs: parseInline(t.slice(4)) });
1218
+ }
1219
+ else if (t.startsWith('## ')) {
1220
+ lines.push({ kind: 'h2', runs: parseInline(t.slice(3)) });
1221
+ }
1222
+ else if (t.startsWith('# ')) {
1223
+ lines.push({ kind: 'h1', runs: parseInline(t.slice(2)) });
1224
+ }
1225
+ else {
1226
+ lines.push({ kind: 'p', runs: parseInline(t) });
1227
+ }
1228
+ }
1229
+ // strip leading/trailing blank lines
1230
+ while (lines.length && lines[0].kind === 'blank')
1231
+ lines.shift();
1232
+ while (lines.length && lines[lines.length - 1].kind === 'blank')
1233
+ lines.pop();
1234
+ return lines;
1235
+ }
1236
+ // ── Parse inline bold/italic spans ───────────────────────
1237
+ function parseInline(text) {
1238
+ const runs = [];
1239
+ // Order matters: check ** before *
1240
+ const re = /(\*\*(.+?)\*\*|\*(.+?)\*|[^*]+)/g;
1241
+ let m;
1242
+ while ((m = re.exec(text)) !== null) {
1243
+ if (m[0].startsWith('**')) {
1244
+ runs.push({ text: m[2], bold: true });
1245
+ }
1246
+ else if (m[0].startsWith('*')) {
1247
+ runs.push({ text: m[3], italic: true });
1248
+ }
1249
+ else {
1250
+ if (m[0])
1251
+ runs.push({ text: m[0] });
1252
+ }
1253
+ }
1254
+ return runs;
1255
+ }
1256
+ // ── Calculate natural height of a parsed block ────────────
1257
+ function calcMarkdownHeight(lines, pad = 16) {
1258
+ let h = pad * 2; // top + bottom
1259
+ for (const line of lines)
1260
+ h += LINE_SPACING[line.kind];
1261
+ return h;
1262
+ }
1263
+
1070
1264
  // ============================================================
1071
1265
  // sketchmark — Scene Graph
1072
1266
  // ============================================================
@@ -1139,6 +1333,8 @@ function buildSceneGraph(ast) {
1139
1333
  y: 0,
1140
1334
  w: 0,
1141
1335
  h: 0,
1336
+ width: n.width,
1337
+ height: n.height,
1142
1338
  };
1143
1339
  });
1144
1340
  const charts = ast.charts.map((c) => {
@@ -1155,6 +1351,18 @@ function buildSceneGraph(ast) {
1155
1351
  h: c.height ?? 240,
1156
1352
  };
1157
1353
  });
1354
+ const markdowns = (ast.markdowns ?? []).map(m => {
1355
+ const themeStyle = m.theme ? (ast.themes[m.theme] ?? {}) : {};
1356
+ return {
1357
+ id: m.id,
1358
+ content: m.content,
1359
+ lines: parseMarkdownContent(m.content),
1360
+ style: { ...themeStyle, ...m.style },
1361
+ width: m.width,
1362
+ height: m.height,
1363
+ x: 0, y: 0, w: 0, h: 0,
1364
+ };
1365
+ });
1158
1366
  // Set parentId for nested groups
1159
1367
  for (const g of groups) {
1160
1368
  for (const child of g.children) {
@@ -1185,6 +1393,7 @@ function buildSceneGraph(ast) {
1185
1393
  tables,
1186
1394
  notes,
1187
1395
  charts,
1396
+ markdowns,
1188
1397
  animation: { steps: ast.steps, currentStep: -1 },
1189
1398
  styles: ast.styles,
1190
1399
  config: ast.config,
@@ -1209,6 +1418,9 @@ function noteMap(sg) {
1209
1418
  function chartMap(sg) {
1210
1419
  return new Map(sg.charts.map((c) => [c.id, c]));
1211
1420
  }
1421
+ function markdownMap(sg) {
1422
+ return new Map((sg.markdowns ?? []).map(m => [m.id, m]));
1423
+ }
1212
1424
 
1213
1425
  // ============================================================
1214
1426
  // sketchmark — Layout Engine (Flexbox-style, recursive)
@@ -1249,30 +1461,40 @@ function sizeNode(n) {
1249
1461
  n.h = n.height;
1250
1462
  const labelW = Math.round(n.label.length * FONT_PX_PER_CHAR + BASE_PAD);
1251
1463
  switch (n.shape) {
1252
- case 'circle':
1464
+ case "circle":
1253
1465
  n.w = n.w || Math.max(84, Math.min(MAX_W, labelW));
1254
1466
  n.h = n.h || n.w;
1255
1467
  break;
1256
- case 'diamond':
1468
+ case "diamond":
1257
1469
  n.w = n.w || Math.max(130, Math.min(MAX_W, labelW + 30));
1258
1470
  n.h = n.h || Math.max(62, n.w * 0.46);
1259
1471
  break;
1260
- case 'hexagon':
1472
+ case "hexagon":
1261
1473
  n.w = n.w || Math.max(126, Math.min(MAX_W, labelW + 20));
1262
1474
  n.h = n.h || Math.max(54, n.w * 0.44);
1263
1475
  break;
1264
- case 'triangle':
1476
+ case "triangle":
1265
1477
  n.w = n.w || Math.max(108, Math.min(MAX_W, labelW + 10));
1266
- n.h = n.h || Math.max(64, n.w * 0.60);
1478
+ n.h = n.h || Math.max(64, n.w * 0.6);
1267
1479
  break;
1268
- case 'cylinder':
1480
+ case "cylinder":
1269
1481
  n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1270
1482
  n.h = n.h || 66;
1271
1483
  break;
1272
- case 'parallelogram':
1484
+ case "parallelogram":
1273
1485
  n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW + 28));
1274
1486
  n.h = n.h || 50;
1275
1487
  break;
1488
+ case "text": {
1489
+ // read fontSize from style if set, otherwise use default
1490
+ const fontSize = Number(n.style?.fontSize ?? 13);
1491
+ const charWidth = fontSize * 0.55;
1492
+ const maxW = n.width ?? 400;
1493
+ const approxLines = Math.ceil((n.label.length * charWidth) / (maxW - 16));
1494
+ n.w = maxW;
1495
+ n.h = n.height ?? Math.max(24, approxLines * fontSize * 1.5 + 8);
1496
+ break;
1497
+ }
1276
1498
  default:
1277
1499
  n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1278
1500
  n.h = n.h || 52;
@@ -1280,9 +1502,13 @@ function sizeNode(n) {
1280
1502
  }
1281
1503
  }
1282
1504
  function sizeNote(n) {
1283
- const maxChars = Math.max(...n.lines.map(l => l.length));
1505
+ const maxChars = Math.max(...n.lines.map((l) => l.length));
1284
1506
  n.w = Math.max(120, Math.ceil(maxChars * NOTE_FONT) + NOTE_PAD_X * 2);
1285
1507
  n.h = n.lines.length * NOTE_LINE_H + NOTE_PAD_Y * 2;
1508
+ if (n.width && n.w < n.width)
1509
+ n.w = n.width; // ← add
1510
+ if (n.height && n.h < n.height)
1511
+ n.h = n.height; // ← add
1286
1512
  }
1287
1513
  // ── Table auto-sizing ─────────────────────────────────────
1288
1514
  function sizeTable(t) {
@@ -1292,7 +1518,7 @@ function sizeTable(t) {
1292
1518
  t.h = labelH + rowH;
1293
1519
  return;
1294
1520
  }
1295
- const numCols = Math.max(...rows.map(r => r.cells.length));
1521
+ const numCols = Math.max(...rows.map((r) => r.cells.length));
1296
1522
  const colW = Array(numCols).fill(MIN_COL_W);
1297
1523
  for (const row of rows) {
1298
1524
  row.cells.forEach((cell, i) => {
@@ -1301,110 +1527,128 @@ function sizeTable(t) {
1301
1527
  }
1302
1528
  t.colWidths = colW;
1303
1529
  t.w = colW.reduce((s, w) => s + w, 0);
1304
- const nHeader = rows.filter(r => r.kind === 'header').length;
1305
- const nData = rows.filter(r => r.kind === 'data').length;
1530
+ const nHeader = rows.filter((r) => r.kind === "header").length;
1531
+ const nData = rows.filter((r) => r.kind === "data").length;
1306
1532
  t.h = labelH + nHeader * headerH + nData * rowH;
1307
1533
  }
1308
1534
  function sizeChart(c) {
1309
1535
  c.w = c.w || 320;
1310
1536
  c.h = c.h || 240;
1311
1537
  }
1538
+ function sizeMarkdown(m) {
1539
+ const pad = Number(m.style?.padding ?? 16);
1540
+ m.w = m.width ?? 400;
1541
+ m.h = m.height ?? calcMarkdownHeight(m.lines, pad);
1542
+ }
1312
1543
  // ── Item size helpers ─────────────────────────────────────
1313
- function iW(r, nm, gm, tm, ntm, cm) {
1314
- if (r.kind === 'node')
1544
+ function iW(r, nm, gm, tm, ntm, cm, mdm) {
1545
+ if (r.kind === "node")
1315
1546
  return nm.get(r.id).w;
1316
- if (r.kind === 'table')
1547
+ if (r.kind === "table")
1317
1548
  return tm.get(r.id).w;
1318
- if (r.kind === 'note')
1549
+ if (r.kind === "note")
1319
1550
  return ntm.get(r.id).w;
1320
- if (r.kind === 'chart')
1551
+ if (r.kind === "chart")
1321
1552
  return cm.get(r.id).w;
1553
+ if (r.kind === "markdown")
1554
+ return mdm.get(r.id).w;
1322
1555
  return gm.get(r.id).w;
1323
1556
  }
1324
- function iH(r, nm, gm, tm, ntm, cm) {
1325
- if (r.kind === 'node')
1557
+ function iH(r, nm, gm, tm, ntm, cm, mdm) {
1558
+ if (r.kind === "node")
1326
1559
  return nm.get(r.id).h;
1327
- if (r.kind === 'table')
1560
+ if (r.kind === "table")
1328
1561
  return tm.get(r.id).h;
1329
- if (r.kind === 'note')
1562
+ if (r.kind === "note")
1330
1563
  return ntm.get(r.id).h;
1331
- if (r.kind === 'chart')
1564
+ if (r.kind === "chart")
1332
1565
  return cm.get(r.id).h;
1566
+ if (r.kind === "markdown")
1567
+ return mdm.get(r.id).h;
1333
1568
  return gm.get(r.id).h;
1334
1569
  }
1335
- function setPos(r, x, y, nm, gm, tm, ntm, cm) {
1336
- if (r.kind === 'node') {
1570
+ function setPos(r, x, y, nm, gm, tm, ntm, cm, mdm) {
1571
+ if (r.kind === "node") {
1337
1572
  const n = nm.get(r.id);
1338
1573
  n.x = Math.round(x);
1339
1574
  n.y = Math.round(y);
1340
1575
  return;
1341
1576
  }
1342
- if (r.kind === 'table') {
1577
+ if (r.kind === "table") {
1343
1578
  const t = tm.get(r.id);
1344
1579
  t.x = Math.round(x);
1345
1580
  t.y = Math.round(y);
1346
1581
  return;
1347
1582
  }
1348
- if (r.kind === 'note') {
1583
+ if (r.kind === "note") {
1349
1584
  const nt = ntm.get(r.id);
1350
1585
  nt.x = Math.round(x);
1351
1586
  nt.y = Math.round(y);
1352
1587
  return;
1353
1588
  }
1354
- if (r.kind === 'chart') {
1589
+ if (r.kind === "chart") {
1355
1590
  const c = cm.get(r.id);
1356
1591
  c.x = Math.round(x);
1357
1592
  c.y = Math.round(y);
1358
1593
  return;
1359
1594
  }
1595
+ if (r.kind === "markdown") {
1596
+ const md = mdm.get(r.id);
1597
+ md.x = Math.round(x);
1598
+ md.y = Math.round(y);
1599
+ return;
1600
+ }
1360
1601
  const g = gm.get(r.id);
1361
1602
  g.x = Math.round(x);
1362
1603
  g.y = Math.round(y);
1363
1604
  }
1364
1605
  // ── Pass 1: Measure (bottom-up) ───────────────────────────
1365
1606
  // Recursively computes w, h for a group from its children's sizes.
1366
- function measure(g, nm, gm, tm, ntm, cm) {
1607
+ function measure(g, nm, gm, tm, ntm, cm, mdm) {
1367
1608
  // Recurse into nested groups first; size tables before reading their dims
1368
1609
  for (const r of g.children) {
1369
- if (r.kind === 'group')
1370
- measure(gm.get(r.id), nm, gm, tm, ntm, cm);
1371
- if (r.kind === 'table')
1610
+ if (r.kind === "group")
1611
+ measure(gm.get(r.id), nm, gm, tm, ntm, cm, mdm);
1612
+ if (r.kind === "table")
1372
1613
  sizeTable(tm.get(r.id));
1373
- if (r.kind === 'note')
1614
+ if (r.kind === "note")
1374
1615
  sizeNote(ntm.get(r.id));
1375
- if (r.kind === 'chart')
1616
+ if (r.kind === "chart")
1376
1617
  sizeChart(cm.get(r.id));
1618
+ if (r.kind === "markdown")
1619
+ sizeMarkdown(mdm.get(r.id));
1377
1620
  }
1378
1621
  const { padding: pad, gap, columns, layout } = g;
1379
1622
  const kids = g.children;
1623
+ const labelH = g.label ? GROUP_LABEL_H : 0;
1380
1624
  if (!kids.length) {
1381
1625
  g.w = pad * 2;
1382
- g.h = pad * 2 + GROUP_LABEL_H;
1626
+ g.h = pad * 2 + labelH;
1383
1627
  if (g.width && g.w < g.width)
1384
1628
  g.w = g.width;
1385
1629
  if (g.height && g.h < g.height)
1386
1630
  g.h = g.height;
1387
1631
  return;
1388
1632
  }
1389
- const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1390
- const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
1633
+ const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
1634
+ const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
1391
1635
  const n = kids.length;
1392
- if (layout === 'row') {
1636
+ if (layout === "row") {
1393
1637
  g.w = ws.reduce((s, w) => s + w, 0) + gap * (n - 1) + pad * 2;
1394
- g.h = Math.max(...hs) + pad * 2 + GROUP_LABEL_H;
1638
+ g.h = Math.max(...hs) + pad * 2 + labelH;
1395
1639
  }
1396
- else if (layout === 'grid') {
1640
+ else if (layout === "grid") {
1397
1641
  const cols = Math.max(1, columns);
1398
1642
  const rows = Math.ceil(n / cols);
1399
1643
  const cellW = Math.max(...ws);
1400
1644
  const cellH = Math.max(...hs);
1401
1645
  g.w = cols * cellW + (cols - 1) * gap + pad * 2;
1402
- g.h = rows * cellH + (rows - 1) * gap + pad * 2 + GROUP_LABEL_H;
1646
+ g.h = rows * cellH + (rows - 1) * gap + pad * 2 + labelH;
1403
1647
  }
1404
1648
  else {
1405
1649
  // column (default)
1406
1650
  g.w = Math.max(...ws) + pad * 2;
1407
- g.h = hs.reduce((s, h) => s + h, 0) + gap * (n - 1) + pad * 2 + GROUP_LABEL_H;
1651
+ g.h = hs.reduce((s, h) => s + h, 0) + gap * (n - 1) + pad * 2 + labelH;
1408
1652
  }
1409
1653
  // Clamp to minWidth / minHeight — this is what gives distribute() free
1410
1654
  // space to work with for justify=center/end/space-between/space-around
@@ -1419,21 +1663,32 @@ function distribute(sizes, contentSize, gap, justify) {
1419
1663
  const totalSize = sizes.reduce((s, v) => s + v, 0);
1420
1664
  const gapCount = n - 1;
1421
1665
  switch (justify) {
1422
- case 'center': {
1666
+ case "center": {
1423
1667
  const total = totalSize + gap * gapCount;
1424
- return { start: Math.max(0, (contentSize - total) / 2), gaps: Array(gapCount).fill(gap) };
1668
+ return {
1669
+ start: Math.max(0, (contentSize - total) / 2),
1670
+ gaps: Array(gapCount).fill(gap),
1671
+ };
1425
1672
  }
1426
- case 'end': {
1673
+ case "end": {
1427
1674
  const total = totalSize + gap * gapCount;
1428
- return { start: Math.max(0, contentSize - total), gaps: Array(gapCount).fill(gap) };
1675
+ return {
1676
+ start: Math.max(0, contentSize - total),
1677
+ gaps: Array(gapCount).fill(gap),
1678
+ };
1429
1679
  }
1430
- case 'space-between': {
1431
- const g2 = gapCount > 0 ? Math.max(gap, (contentSize - totalSize) / gapCount) : gap;
1680
+ case "space-between": {
1681
+ const g2 = gapCount > 0
1682
+ ? Math.max(gap, (contentSize - totalSize) / gapCount)
1683
+ : gap;
1432
1684
  return { start: 0, gaps: Array(gapCount).fill(g2) };
1433
1685
  }
1434
- case 'space-around': {
1686
+ case "space-around": {
1435
1687
  const space = n > 0 ? (contentSize - totalSize) / n : gap;
1436
- return { start: Math.max(0, space / 2), gaps: Array(gapCount).fill(Math.max(gap, space)) };
1688
+ return {
1689
+ start: Math.max(0, space / 2),
1690
+ gaps: Array(gapCount).fill(Math.max(gap, space)),
1691
+ };
1437
1692
  }
1438
1693
  default: // start
1439
1694
  return { start: 0, gaps: Array(gapCount).fill(gap) };
@@ -1441,70 +1696,73 @@ function distribute(sizes, contentSize, gap, justify) {
1441
1696
  }
1442
1697
  // ── Pass 2: Place (top-down) ──────────────────────────────
1443
1698
  // Assigns x, y to each child. Assumes g.x / g.y already set by parent.
1444
- function place(g, nm, gm, tm, ntm, cm) {
1699
+ function place(g, nm, gm, tm, ntm, cm, mdm) {
1445
1700
  const { padding: pad, gap, columns, layout, align, justify } = g;
1701
+ const labelH = g.label ? GROUP_LABEL_H : 0;
1446
1702
  const contentX = g.x + pad;
1447
- const contentY = g.y + GROUP_LABEL_H + pad;
1703
+ const contentY = g.y + labelH + pad;
1448
1704
  const contentW = g.w - pad * 2;
1449
- const contentH = g.h - pad * 2 - GROUP_LABEL_H;
1705
+ const contentH = g.h - pad * 2 - labelH;
1450
1706
  const kids = g.children;
1451
1707
  if (!kids.length)
1452
1708
  return;
1453
- if (layout === 'row') {
1454
- const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1455
- const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
1709
+ if (layout === "row") {
1710
+ const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
1711
+ const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
1456
1712
  const maxH = Math.max(...hs);
1457
1713
  const { start, gaps } = distribute(ws, contentW, gap, justify);
1458
1714
  let x = contentX + start;
1459
1715
  for (let i = 0; i < kids.length; i++) {
1460
1716
  let y;
1461
1717
  switch (align) {
1462
- case 'center':
1718
+ case "center":
1463
1719
  y = contentY + (maxH - hs[i]) / 2;
1464
1720
  break;
1465
- case 'end':
1721
+ case "end":
1466
1722
  y = contentY + maxH - hs[i];
1467
1723
  break;
1468
- default: y = contentY;
1724
+ default:
1725
+ y = contentY;
1469
1726
  }
1470
- setPos(kids[i], x, y, nm, gm, tm, ntm, cm);
1727
+ setPos(kids[i], x, y, nm, gm, tm, ntm, cm, mdm);
1471
1728
  x += ws[i] + (i < gaps.length ? gaps[i] : 0);
1472
1729
  }
1473
1730
  }
1474
- else if (layout === 'grid') {
1731
+ else if (layout === "grid") {
1475
1732
  const cols = Math.max(1, columns);
1476
- const cellW = Math.max(...kids.map(r => iW(r, nm, gm, tm, ntm, cm)));
1477
- const cellH = Math.max(...kids.map(r => iH(r, nm, gm, tm, ntm, cm)));
1733
+ const cellW = Math.max(...kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm)));
1734
+ const cellH = Math.max(...kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm)));
1478
1735
  kids.forEach((ref, i) => {
1479
- setPos(ref, contentX + (i % cols) * (cellW + gap), contentY + Math.floor(i / cols) * (cellH + gap), nm, gm, tm, ntm, cm);
1736
+ setPos(ref, contentX + (i % cols) * (cellW + gap), contentY + Math.floor(i / cols) * (cellH + gap), nm, gm, tm, ntm, cm, mdm);
1480
1737
  });
1481
1738
  }
1482
1739
  else {
1483
1740
  // column (default)
1484
- const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1485
- const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
1741
+ const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
1742
+ const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
1486
1743
  const maxW = Math.max(...ws);
1487
1744
  const { start, gaps } = distribute(hs, contentH, gap, justify);
1488
1745
  let y = contentY + start;
1489
1746
  for (let i = 0; i < kids.length; i++) {
1490
1747
  let x;
1491
1748
  switch (align) {
1492
- case 'center':
1749
+ case "center":
1493
1750
  x = contentX + (maxW - ws[i]) / 2;
1494
1751
  break;
1495
- case 'end':
1752
+ case "end":
1496
1753
  x = contentX + maxW - ws[i];
1497
1754
  break;
1498
- default: x = contentX;
1755
+ default:
1756
+ x = contentX;
1499
1757
  }
1500
- setPos(kids[i], x, y, nm, gm, tm, ntm, cm);
1758
+ setPos(kids[i], x, y, nm, gm, tm, ntm, cm, mdm);
1501
1759
  y += hs[i] + (i < gaps.length ? gaps[i] : 0);
1502
1760
  }
1503
1761
  }
1504
1762
  // Recurse into nested groups
1505
1763
  for (const r of kids) {
1506
- if (r.kind === 'group')
1507
- place(gm.get(r.id), nm, gm, tm, ntm, cm);
1764
+ if (r.kind === "group")
1765
+ place(gm.get(r.id), nm, gm, tm, ntm, cm, mdm);
1508
1766
  }
1509
1767
  }
1510
1768
  // ── Edge routing ──────────────────────────────────────────
@@ -1514,9 +1772,9 @@ function connPoint(n, other) {
1514
1772
  const dx = ox - cx, dy = oy - cy;
1515
1773
  if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
1516
1774
  return [cx, cy];
1517
- if (n.shape === 'circle') {
1775
+ if (n.shape === "circle") {
1518
1776
  const r = n.w * 0.44, len = Math.sqrt(dx * dx + dy * dy);
1519
- return [cx + dx / len * r, cy + dy / len * r];
1777
+ return [cx + (dx / len) * r, cy + (dy / len) * r];
1520
1778
  }
1521
1779
  const hw = n.w / 2 - 2, hh = n.h / 2 - 2;
1522
1780
  const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
@@ -1561,9 +1819,12 @@ function routeEdges(sg) {
1561
1819
  }
1562
1820
  function connPt(src, dstCX, dstCY) {
1563
1821
  // SceneNode has a .shape field; use the existing connPoint for it
1564
- if ('shape' in src && src.shape) {
1822
+ if ("shape" in src && src.shape) {
1565
1823
  return connPoint(src, {
1566
- x: dstCX - 1, y: dstCY - 1, w: 2, h: 2});
1824
+ x: dstCX - 1,
1825
+ y: dstCY - 1,
1826
+ w: 2,
1827
+ h: 2});
1567
1828
  }
1568
1829
  return rectConnPoint$2(src.x, src.y, src.w, src.h, dstCX, dstCY);
1569
1830
  }
@@ -1576,84 +1837,84 @@ function routeEdges(sg) {
1576
1837
  }
1577
1838
  const dstCX = dst.x + dst.w / 2, dstCY = dst.y + dst.h / 2;
1578
1839
  const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
1579
- e.points = [
1580
- connPt(src, dstCX, dstCY),
1581
- connPt(dst, srcCX, srcCY),
1582
- ];
1840
+ e.points = [connPt(src, dstCX, dstCY), connPt(dst, srcCX, srcCY)];
1583
1841
  }
1584
1842
  }
1585
1843
  function computeBounds(sg, margin) {
1586
1844
  const allX = [
1587
- ...sg.nodes.map(n => n.x + n.w),
1588
- ...sg.groups.filter(g => g.w).map(g => g.x + g.w),
1589
- ...sg.tables.map(t => t.x + t.w),
1590
- ...sg.notes.map(n => n.x + n.w),
1591
- ...sg.charts.map(c => c.x + c.w)
1845
+ ...sg.nodes.map((n) => n.x + n.w),
1846
+ ...sg.groups.filter((g) => g.w).map((g) => g.x + g.w),
1847
+ ...sg.tables.map((t) => t.x + t.w),
1848
+ ...sg.notes.map((n) => n.x + n.w),
1849
+ ...sg.charts.map((c) => c.x + c.w),
1850
+ ...sg.markdowns.map((m) => m.x + m.w),
1592
1851
  ];
1593
1852
  const allY = [
1594
- ...sg.nodes.map(n => n.y + n.h),
1595
- ...sg.groups.filter(g => g.h).map(g => g.y + g.h),
1596
- ...sg.tables.map(t => t.y + t.h),
1597
- ...sg.notes.map(n => n.y + n.h),
1598
- ...sg.charts.map(c => c.y + c.h)
1853
+ ...sg.nodes.map((n) => n.y + n.h),
1854
+ ...sg.groups.filter((g) => g.h).map((g) => g.y + g.h),
1855
+ ...sg.tables.map((t) => t.y + t.h),
1856
+ ...sg.notes.map((n) => n.y + n.h),
1857
+ ...sg.charts.map((c) => c.y + c.h),
1858
+ ...sg.markdowns.map((m) => m.y + m.h),
1599
1859
  ];
1600
1860
  sg.width = (allX.length ? Math.max(...allX) : 400) + margin;
1601
1861
  sg.height = (allY.length ? Math.max(...allY) : 300) + margin;
1602
1862
  }
1603
1863
  // ── Public entry point ────────────────────────────────────
1604
1864
  function layout(sg) {
1605
- const GAP_MAIN = Number(sg.config['gap'] ?? DEFAULT_GAP_MAIN);
1606
- const MARGIN = Number(sg.config['margin'] ?? DEFAULT_MARGIN);
1865
+ const GAP_MAIN = Number(sg.config["gap"] ?? DEFAULT_GAP_MAIN);
1866
+ const MARGIN = Number(sg.config["margin"] ?? DEFAULT_MARGIN);
1607
1867
  const nm = nodeMap(sg);
1608
1868
  const gm = groupMap(sg);
1609
1869
  const tm = tableMap(sg);
1610
1870
  const ntm = noteMap(sg);
1611
1871
  const cm = chartMap(sg);
1612
- console.log('[layout] sg.charts:', sg.charts.map(c => c.id));
1613
- console.log('[layout] sg.rootOrder:', sg.rootOrder.map(r => r.kind + ':' + r.id));
1872
+ const mdm = markdownMap(sg);
1614
1873
  // 1. Size all nodes and tables
1615
1874
  sg.nodes.forEach(sizeNode);
1616
1875
  sg.tables.forEach(sizeTable);
1617
1876
  sg.notes.forEach(sizeNote);
1618
1877
  sg.charts.forEach(sizeChart);
1878
+ sg.markdowns.forEach(sizeMarkdown);
1619
1879
  // src/layout/index.ts — after sg.charts.forEach(sizeChart);
1620
1880
  // 2. Identify root vs nested items
1621
- const nestedGroupIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'group').map(c => c.id)));
1622
- const groupedNodeIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'node').map(c => c.id)));
1623
- const groupedTableIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'table').map(c => c.id)));
1624
- const groupedNoteIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'note').map(c => c.id)));
1625
- const groupedChartIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'chart').map(c => c.id)));
1626
- const rootGroups = sg.groups.filter(g => !nestedGroupIds.has(g.id));
1627
- const rootNodes = sg.nodes.filter(n => !groupedNodeIds.has(n.id));
1628
- const rootTables = sg.tables.filter(t => !groupedTableIds.has(t.id));
1629
- const rootNotes = sg.notes.filter(n => !groupedNoteIds.has(n.id));
1630
- const rootCharts = sg.charts.filter(c => !groupedChartIds.has(c.id));
1881
+ const nestedGroupIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "group").map((c) => c.id)));
1882
+ const groupedNodeIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "node").map((c) => c.id)));
1883
+ const groupedTableIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "table").map((c) => c.id)));
1884
+ const groupedNoteIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "note").map((c) => c.id)));
1885
+ const groupedChartIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "chart").map((c) => c.id)));
1886
+ const groupedMarkdownIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "markdown").map((c) => c.id)));
1887
+ const rootGroups = sg.groups.filter((g) => !nestedGroupIds.has(g.id));
1888
+ const rootNodes = sg.nodes.filter((n) => !groupedNodeIds.has(n.id));
1889
+ const rootTables = sg.tables.filter((t) => !groupedTableIds.has(t.id));
1890
+ const rootNotes = sg.notes.filter((n) => !groupedNoteIds.has(n.id));
1891
+ const rootCharts = sg.charts.filter((c) => !groupedChartIds.has(c.id));
1892
+ const rootMarkdowns = sg.markdowns.filter((m) => !groupedMarkdownIds.has(m.id));
1631
1893
  // 3. Measure root groups bottom-up
1632
1894
  for (const g of rootGroups)
1633
- measure(g, nm, gm, tm, ntm, cm);
1895
+ measure(g, nm, gm, tm, ntm, cm, mdm);
1634
1896
  // 4. Build root order
1635
1897
  // sg.rootOrder preserves DSL declaration order.
1636
1898
  // Fall back: groups, then nodes, then tables.
1637
1899
  const rootOrder = sg.rootOrder?.length
1638
1900
  ? sg.rootOrder
1639
1901
  : [
1640
- ...rootGroups.map(g => ({ kind: 'group', id: g.id })),
1641
- ...rootNodes.map(n => ({ kind: 'node', id: n.id })),
1642
- ...rootTables.map(t => ({ kind: 'table', id: t.id })),
1643
- ...rootNotes.map(n => ({ kind: 'note', id: n.id })),
1644
- ...rootCharts.map(c => ({ kind: 'chart', id: c.id }))
1902
+ ...rootGroups.map((g) => ({ kind: "group", id: g.id })),
1903
+ ...rootNodes.map((n) => ({ kind: "node", id: n.id })),
1904
+ ...rootTables.map((t) => ({ kind: "table", id: t.id })),
1905
+ ...rootNotes.map((n) => ({ kind: "note", id: n.id })),
1906
+ ...rootCharts.map((c) => ({ kind: "chart", id: c.id })),
1907
+ ...rootMarkdowns.map((m) => ({ kind: "markdown", id: m.id })),
1645
1908
  ];
1646
1909
  // 5. Root-level layout
1647
1910
  // sg.layout:
1648
1911
  // 'row' → items flow left to right (default)
1649
1912
  // 'column' → items flow top to bottom
1650
1913
  // 'grid' → config columns=N grid
1651
- const rootLayout = (sg.layout ?? 'row');
1652
- const rootCols = Number(sg.config['columns'] ?? 1);
1653
- const useGrid = rootLayout === 'grid' && rootCols > 0;
1654
- const useColumn = rootLayout === 'column';
1655
- console.log('[layout] sized charts:', sg.charts.map(c => `${c.id} w=${c.w} h=${c.h}`));
1656
- console.log('[layout] rootOrder chart refs:', rootOrder.filter(r => r.kind === 'chart'));
1914
+ const rootLayout = (sg.layout ?? "row");
1915
+ const rootCols = Number(sg.config["columns"] ?? 1);
1916
+ const useGrid = rootLayout === "grid" && rootCols > 0;
1917
+ const useColumn = rootLayout === "column";
1657
1918
  if (useGrid) {
1658
1919
  // ── Grid: per-row heights, per-column widths (no wasted space) ──
1659
1920
  const cols = rootCols;
@@ -1664,22 +1925,26 @@ function layout(sg) {
1664
1925
  const col = idx % cols;
1665
1926
  const row = Math.floor(idx / cols);
1666
1927
  let w = 0, h = 0;
1667
- if (ref.kind === 'group') {
1928
+ if (ref.kind === "group") {
1668
1929
  w = gm.get(ref.id).w;
1669
1930
  h = gm.get(ref.id).h;
1670
1931
  }
1671
- else if (ref.kind === 'table') {
1932
+ else if (ref.kind === "table") {
1672
1933
  w = tm.get(ref.id).w;
1673
1934
  h = tm.get(ref.id).h;
1674
1935
  }
1675
- else if (ref.kind === 'note') {
1936
+ else if (ref.kind === "note") {
1676
1937
  w = ntm.get(ref.id).w;
1677
1938
  h = ntm.get(ref.id).h;
1678
1939
  }
1679
- else if (ref.kind === 'chart') {
1940
+ else if (ref.kind === "chart") {
1680
1941
  w = cm.get(ref.id).w;
1681
1942
  h = cm.get(ref.id).h;
1682
1943
  }
1944
+ else if (ref.kind === "markdown") {
1945
+ w = mdm.get(ref.id).w;
1946
+ h = mdm.get(ref.id).h;
1947
+ }
1683
1948
  else {
1684
1949
  w = nm.get(ref.id).w;
1685
1950
  h = nm.get(ref.id).h;
@@ -1702,22 +1967,26 @@ function layout(sg) {
1702
1967
  rootOrder.forEach((ref, idx) => {
1703
1968
  const x = colX[idx % cols];
1704
1969
  const y = rowY[Math.floor(idx / cols)];
1705
- if (ref.kind === 'group') {
1970
+ if (ref.kind === "group") {
1706
1971
  gm.get(ref.id).x = x;
1707
1972
  gm.get(ref.id).y = y;
1708
1973
  }
1709
- else if (ref.kind === 'table') {
1974
+ else if (ref.kind === "table") {
1710
1975
  tm.get(ref.id).x = x;
1711
1976
  tm.get(ref.id).y = y;
1712
1977
  }
1713
- else if (ref.kind === 'note') {
1978
+ else if (ref.kind === "note") {
1714
1979
  ntm.get(ref.id).x = x;
1715
1980
  ntm.get(ref.id).y = y;
1716
1981
  }
1717
- else if (ref.kind === 'chart') {
1982
+ else if (ref.kind === "chart") {
1718
1983
  cm.get(ref.id).x = x;
1719
1984
  cm.get(ref.id).y = y;
1720
1985
  }
1986
+ else if (ref.kind === "markdown") {
1987
+ mdm.get(ref.id).x = x;
1988
+ mdm.get(ref.id).y = y;
1989
+ }
1721
1990
  else {
1722
1991
  nm.get(ref.id).x = x;
1723
1992
  nm.get(ref.id).y = y;
@@ -1729,44 +1998,52 @@ function layout(sg) {
1729
1998
  let pos = MARGIN;
1730
1999
  for (const ref of rootOrder) {
1731
2000
  let w = 0, h = 0;
1732
- if (ref.kind === 'group') {
2001
+ if (ref.kind === "group") {
1733
2002
  w = gm.get(ref.id).w;
1734
2003
  h = gm.get(ref.id).h;
1735
2004
  }
1736
- else if (ref.kind === 'table') {
2005
+ else if (ref.kind === "table") {
1737
2006
  w = tm.get(ref.id).w;
1738
2007
  h = tm.get(ref.id).h;
1739
2008
  }
1740
- else if (ref.kind === 'note') {
2009
+ else if (ref.kind === "note") {
1741
2010
  w = ntm.get(ref.id).w;
1742
2011
  h = ntm.get(ref.id).h;
1743
2012
  }
1744
- else if (ref.kind === 'chart') {
2013
+ else if (ref.kind === "chart") {
1745
2014
  w = cm.get(ref.id).w;
1746
2015
  h = cm.get(ref.id).h;
1747
2016
  }
2017
+ else if (ref.kind === "markdown") {
2018
+ w = mdm.get(ref.id).w;
2019
+ h = mdm.get(ref.id).h;
2020
+ }
1748
2021
  else {
1749
2022
  w = nm.get(ref.id).w;
1750
2023
  h = nm.get(ref.id).h;
1751
2024
  }
1752
2025
  const x = useColumn ? MARGIN : pos;
1753
2026
  const y = useColumn ? pos : MARGIN;
1754
- if (ref.kind === 'group') {
2027
+ if (ref.kind === "group") {
1755
2028
  gm.get(ref.id).x = x;
1756
2029
  gm.get(ref.id).y = y;
1757
2030
  }
1758
- else if (ref.kind === 'table') {
2031
+ else if (ref.kind === "table") {
1759
2032
  tm.get(ref.id).x = x;
1760
2033
  tm.get(ref.id).y = y;
1761
2034
  }
1762
- else if (ref.kind === 'note') {
2035
+ else if (ref.kind === "note") {
1763
2036
  ntm.get(ref.id).x = x;
1764
2037
  ntm.get(ref.id).y = y;
1765
2038
  }
1766
- else if (ref.kind === 'chart') {
2039
+ else if (ref.kind === "chart") {
1767
2040
  cm.get(ref.id).x = x;
1768
2041
  cm.get(ref.id).y = y;
1769
2042
  }
2043
+ else if (ref.kind === "markdown") {
2044
+ mdm.get(ref.id).x = x;
2045
+ mdm.get(ref.id).y = y;
2046
+ }
1770
2047
  else {
1771
2048
  nm.get(ref.id).x = x;
1772
2049
  nm.get(ref.id).y = y;
@@ -1776,10 +2053,9 @@ function layout(sg) {
1776
2053
  }
1777
2054
  // 6. Place children within each root group (top-down, recursive)
1778
2055
  for (const g of rootGroups)
1779
- place(g, nm, gm, tm, ntm, cm);
2056
+ place(g, nm, gm, tm, ntm, cm, mdm);
1780
2057
  // 7. Route edges and compute canvas size
1781
2058
  routeEdges(sg);
1782
- console.log('[layout] chart positions:', sg.charts.map(c => `${c.id} x=${c.x} y=${c.y}`));
1783
2059
  computeBounds(sg, MARGIN);
1784
2060
  return sg;
1785
2061
  }
@@ -2411,6 +2687,83 @@ function listThemes() {
2411
2687
  }
2412
2688
  const THEME_NAMES = Object.keys(PALETTES);
2413
2689
 
2690
+ // ============================================================
2691
+ // sketchmark — Font Registry
2692
+ // ============================================================
2693
+ // built-in named fonts — user can reference these by short name
2694
+ const BUILTIN_FONTS = {
2695
+ // hand-drawn
2696
+ caveat: {
2697
+ family: "'Caveat', cursive",
2698
+ url: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600&display=swap',
2699
+ },
2700
+ handlee: {
2701
+ family: "'Handlee', cursive",
2702
+ url: 'https://fonts.googleapis.com/css2?family=Handlee&display=swap',
2703
+ },
2704
+ 'indie-flower': {
2705
+ family: "'Indie Flower', cursive",
2706
+ url: 'https://fonts.googleapis.com/css2?family=Indie+Flower&display=swap',
2707
+ },
2708
+ 'patrick-hand': {
2709
+ family: "'Patrick Hand', cursive",
2710
+ url: 'https://fonts.googleapis.com/css2?family=Patrick+Hand&display=swap',
2711
+ },
2712
+ // clean / readable
2713
+ 'dm-mono': {
2714
+ family: "'DM Mono', monospace",
2715
+ url: 'https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&display=swap',
2716
+ },
2717
+ 'jetbrains': {
2718
+ family: "'JetBrains Mono', monospace",
2719
+ url: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap',
2720
+ },
2721
+ 'instrument': {
2722
+ family: "'Instrument Serif', serif",
2723
+ url: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap',
2724
+ },
2725
+ 'playfair': {
2726
+ family: "'Playfair Display', serif",
2727
+ url: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap',
2728
+ },
2729
+ // system fallbacks (no URL needed)
2730
+ system: { family: 'system-ui, sans-serif' },
2731
+ mono: { family: "'Courier New', monospace" },
2732
+ serif: { family: 'Georgia, serif' },
2733
+ };
2734
+ // default — what renders when no font is specified
2735
+ const DEFAULT_FONT = 'system-ui, sans-serif';
2736
+ // resolve a short name or pass-through a quoted CSS family
2737
+ function resolveFont(nameOrFamily) {
2738
+ const key = nameOrFamily.toLowerCase().trim();
2739
+ if (BUILTIN_FONTS[key])
2740
+ return BUILTIN_FONTS[key].family;
2741
+ return nameOrFamily; // treat as raw CSS font-family
2742
+ }
2743
+ // inject a <link> into <head> for a built-in font (browser only)
2744
+ function loadFont(name) {
2745
+ if (typeof document === 'undefined')
2746
+ return;
2747
+ const key = name.toLowerCase().trim();
2748
+ const def = BUILTIN_FONTS[key];
2749
+ if (!def?.url || def.loaded)
2750
+ return;
2751
+ if (document.querySelector(`link[data-sketchmark-font="${key}"]`))
2752
+ return;
2753
+ const link = document.createElement('link');
2754
+ link.rel = 'stylesheet';
2755
+ link.href = def.url;
2756
+ link.setAttribute('data-sketchmark-font', key);
2757
+ document.head.appendChild(link);
2758
+ def.loaded = true;
2759
+ }
2760
+ // user registers their own font (already loaded via CSS/link)
2761
+ function registerFont(name, family, url) {
2762
+ BUILTIN_FONTS[name.toLowerCase()] = { family, url };
2763
+ if (url)
2764
+ loadFont(name);
2765
+ }
2766
+
2414
2767
  // ============================================================
2415
2768
  // sketchmark — SVG Renderer (rough.js hand-drawn)
2416
2769
  // ============================================================
@@ -2423,17 +2776,93 @@ function hashStr$3(s) {
2423
2776
  return h;
2424
2777
  }
2425
2778
  const BASE_ROUGH = { roughness: 1.3, bowing: 0.7 };
2426
- // ── SVG helpers ───────────────────────────────────────────
2427
- function mkMultilineText(lines, x, cy, // vertical center of the whole block
2428
- sz = 14, wt = 500, col = "#1a1208", anchor = "middle", lineH = 18) {
2779
+ // ── Small helper: load + resolve font from style or fall back ─────────────
2780
+ function resolveStyleFont$1(style, fallback) {
2781
+ const raw = String(style["font"] ?? "");
2782
+ if (!raw)
2783
+ return fallback;
2784
+ loadFont(raw);
2785
+ return resolveFont(raw);
2786
+ }
2787
+ function wrapText$1(text, maxWidth, fontSize) {
2788
+ const words = text.split(' ');
2789
+ const charsPerPx = fontSize * 0.55; // approximate
2790
+ const maxChars = Math.floor(maxWidth / charsPerPx);
2791
+ const lines = [];
2792
+ let current = '';
2793
+ for (const word of words) {
2794
+ const test = current ? `${current} ${word}` : word;
2795
+ if (test.length > maxChars && current) {
2796
+ lines.push(current);
2797
+ current = word;
2798
+ }
2799
+ else {
2800
+ current = test;
2801
+ }
2802
+ }
2803
+ if (current)
2804
+ lines.push(current);
2805
+ return lines;
2806
+ }
2807
+ // ── SVG text helpers ──────────────────────────────────────────────────────
2808
+ /**
2809
+ * Single-line SVG text element.
2810
+ *
2811
+ * | param | maps to SVG attr |
2812
+ * |---------------|--------------------------|
2813
+ * txt | textContent |
2814
+ * x, y | x, y |
2815
+ * sz | font-size |
2816
+ * wt | font-weight |
2817
+ * col | fill |
2818
+ * anchor | text-anchor |
2819
+ * font | font-family |
2820
+ * letterSpacing | letter-spacing |
2821
+ */
2822
+ function mkText(txt, x, y, sz = 14, wt = 500, col = "#1a1208", anchor = "middle", font, letterSpacing) {
2823
+ const t = se("text");
2824
+ t.setAttribute("x", String(x));
2825
+ t.setAttribute("y", String(y));
2826
+ t.setAttribute("text-anchor", anchor);
2827
+ t.setAttribute("dominant-baseline", "middle");
2828
+ t.setAttribute("font-family", font ?? "var(--font-sans, system-ui, sans-serif)");
2829
+ t.setAttribute("font-size", String(sz));
2830
+ t.setAttribute("font-weight", String(wt));
2831
+ t.setAttribute("fill", col);
2832
+ t.setAttribute("pointer-events", "none");
2833
+ t.setAttribute("user-select", "none");
2834
+ if (letterSpacing != null)
2835
+ t.setAttribute("letter-spacing", String(letterSpacing));
2836
+ t.textContent = txt;
2837
+ return t;
2838
+ }
2839
+ /**
2840
+ * Multi-line SVG text element using <tspan> per line.
2841
+ *
2842
+ * | param | maps to SVG attr |
2843
+ * |---------------|--------------------------|
2844
+ * lines | one <tspan> each |
2845
+ * x | tspan x |
2846
+ * cy | vertical centre of block |
2847
+ * sz | font-size |
2848
+ * wt | font-weight |
2849
+ * col | fill |
2850
+ * anchor | text-anchor |
2851
+ * lineH | dy between tspans (px) |
2852
+ * font | font-family |
2853
+ * letterSpacing | letter-spacing |
2854
+ */
2855
+ function mkMultilineText(lines, x, cy, sz = 14, wt = 500, col = "#1a1208", anchor = "middle", lineH = 18, font, letterSpacing) {
2429
2856
  const t = se("text");
2430
2857
  t.setAttribute("text-anchor", anchor);
2431
- t.setAttribute("font-family", "var(--font-sans, system-ui, sans-serif)");
2858
+ t.setAttribute("font-family", font ?? "var(--font-sans, system-ui, sans-serif)");
2432
2859
  t.setAttribute("font-size", String(sz));
2433
2860
  t.setAttribute("font-weight", String(wt));
2434
2861
  t.setAttribute("fill", col);
2435
2862
  t.setAttribute("pointer-events", "none");
2436
2863
  t.setAttribute("user-select", "none");
2864
+ if (letterSpacing != null)
2865
+ t.setAttribute("letter-spacing", String(letterSpacing));
2437
2866
  // vertically centre the whole block
2438
2867
  const totalH = (lines.length - 1) * lineH;
2439
2868
  const startY = cy - totalH / 2;
@@ -2447,21 +2876,6 @@ sz = 14, wt = 500, col = "#1a1208", anchor = "middle", lineH = 18) {
2447
2876
  });
2448
2877
  return t;
2449
2878
  }
2450
- function mkText(txt, x, y, sz = 14, wt = 500, col = "#1a1208", anchor = "middle") {
2451
- const t = se("text");
2452
- t.setAttribute("x", String(x));
2453
- t.setAttribute("y", String(y));
2454
- t.setAttribute("text-anchor", anchor);
2455
- t.setAttribute("dominant-baseline", "middle");
2456
- t.setAttribute("font-family", "var(--font-sans, system-ui, sans-serif)");
2457
- t.setAttribute("font-size", String(sz));
2458
- t.setAttribute("font-weight", String(wt));
2459
- t.setAttribute("fill", col);
2460
- t.setAttribute("pointer-events", "none");
2461
- t.setAttribute("user-select", "none");
2462
- t.textContent = txt;
2463
- return t;
2464
- }
2465
2879
  function mkGroup(id, cls) {
2466
2880
  const g = se("g");
2467
2881
  if (id)
@@ -2470,7 +2884,7 @@ function mkGroup(id, cls) {
2470
2884
  g.setAttribute("class", cls);
2471
2885
  return g;
2472
2886
  }
2473
- // ── Arrow direction from connector ────────────────────────
2887
+ // ── Arrow direction from connector ────────────────────────────────────────
2474
2888
  function connMeta$1(connector) {
2475
2889
  if (connector === "--")
2476
2890
  return { arrowAt: "none", dashed: false };
@@ -2485,7 +2899,7 @@ function connMeta$1(connector) {
2485
2899
  return { arrowAt: "start", dashed };
2486
2900
  return { arrowAt: "end", dashed };
2487
2901
  }
2488
- // ── Generic rect connection point ─────────────────────────
2902
+ // ── Generic rect connection point ─────────────────────────────────────────
2489
2903
  function rectConnPoint$1(rx, ry, rw, rh, ox, oy) {
2490
2904
  const cx = rx + rw / 2, cy = ry + rh / 2;
2491
2905
  const dx = ox - cx, dy = oy - cy;
@@ -2510,7 +2924,7 @@ function getConnPoint$1(src, dstCX, dstCY) {
2510
2924
  }
2511
2925
  return rectConnPoint$1(src.x, src.y, src.w, src.h, dstCX, dstCY);
2512
2926
  }
2513
- // ── Group depth (for paint order) ─────────────────────────
2927
+ // ── Group depth (for paint order) ─────────────────────────────────────────
2514
2928
  function groupDepth$1(g, gm) {
2515
2929
  let d = 0;
2516
2930
  let cur = g;
@@ -2520,7 +2934,7 @@ function groupDepth$1(g, gm) {
2520
2934
  }
2521
2935
  return d;
2522
2936
  }
2523
- // ── Node shapes ───────────────────────────────────────────
2937
+ // ── Node shapes ───────────────────────────────────────────────────────────
2524
2938
  function renderShape$1(rc, n, palette) {
2525
2939
  const s = n.style ?? {};
2526
2940
  const fill = String(s.fill ?? palette.nodeFill);
@@ -2593,19 +3007,18 @@ function renderShape$1(rc, n, palette) {
2593
3007
  return [];
2594
3008
  case "image": {
2595
3009
  if (n.imageUrl) {
2596
- const img = document.createElementNS("http://www.w3.org/2000/svg", "image");
3010
+ const img = document.createElementNS(NS, "image");
2597
3011
  img.setAttribute("href", n.imageUrl);
2598
3012
  img.setAttribute("x", String(n.x + 1));
2599
3013
  img.setAttribute("y", String(n.y + 1));
2600
3014
  img.setAttribute("width", String(n.w - 2));
2601
3015
  img.setAttribute("height", String(n.h - 2));
2602
3016
  img.setAttribute("preserveAspectRatio", "xMidYMid meet");
2603
- // optional: clip to rounded rect
2604
3017
  const clipId = `clip-${n.id}`;
2605
- const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
2606
- const clip = document.createElementNS("http://www.w3.org/2000/svg", "clipPath");
3018
+ const defs = document.createElementNS(NS, "defs");
3019
+ const clip = document.createElementNS(NS, "clipPath");
2607
3020
  clip.setAttribute("id", clipId);
2608
- const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
3021
+ const rect = document.createElementNS(NS, "rect");
2609
3022
  rect.setAttribute("x", String(n.x + 1));
2610
3023
  rect.setAttribute("y", String(n.y + 1));
2611
3024
  rect.setAttribute("width", String(n.w - 2));
@@ -2614,15 +3027,12 @@ function renderShape$1(rc, n, palette) {
2614
3027
  clip.appendChild(rect);
2615
3028
  defs.appendChild(clip);
2616
3029
  img.setAttribute("clip-path", `url(#${clipId})`);
2617
- // border box drawn on top
2618
3030
  const border = rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
2619
3031
  ...opts,
2620
3032
  fill: "none",
2621
- fillStyle: "solid",
2622
3033
  });
2623
3034
  return [defs, img, border];
2624
3035
  }
2625
- // fallback: no URL → grey placeholder box
2626
3036
  return [
2627
3037
  rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
2628
3038
  ...opts,
@@ -2635,7 +3045,7 @@ function renderShape$1(rc, n, palette) {
2635
3045
  return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts)];
2636
3046
  }
2637
3047
  }
2638
- // ── Arrowhead ─────────────────────────────────────────────
3048
+ // ── Arrowhead ─────────────────────────────────────────────────────────────
2639
3049
  function arrowHead(rc, x, y, angle, col, seed) {
2640
3050
  const as = 12;
2641
3051
  return rc.polygon([
@@ -2659,14 +3069,22 @@ function arrowHead(rc, x, y, angle, col, seed) {
2659
3069
  }
2660
3070
  function renderToSVG(sg, container, options = {}) {
2661
3071
  if (typeof rough === "undefined") {
2662
- throw new Error('rough.js is not loaded. Add <script src="https://unpkg.com/roughjs/bundled/rough.js"></script>');
3072
+ throw new Error("rough.js is not loaded.");
2663
3073
  }
2664
3074
  const isDark = options.theme === "dark" ||
2665
3075
  (options.theme === "auto" &&
2666
3076
  window.matchMedia?.("(prefers-color-scheme:dark)").matches);
2667
- // Resolve palette: DSL config takes priority, then options.theme, then light
2668
3077
  const themeName = String(sg.config[THEME_CONFIG_KEY] ?? (isDark ? "dark" : "light"));
2669
3078
  const palette = resolvePalette(themeName);
3079
+ // ── Diagram-level font ──────────────────────────────────
3080
+ const diagramFont = (() => {
3081
+ const raw = String(sg.config["font"] ?? "");
3082
+ if (raw) {
3083
+ loadFont(raw);
3084
+ return resolveFont(raw);
3085
+ }
3086
+ return DEFAULT_FONT;
3087
+ })();
2670
3088
  BASE_ROUGH.roughness = options.roughness ?? 1.3;
2671
3089
  BASE_ROUGH.bowing = options.bowing ?? 0.7;
2672
3090
  let svg;
@@ -2683,14 +3101,7 @@ function renderToSVG(sg, container, options = {}) {
2683
3101
  svg.setAttribute("height", String(sg.height));
2684
3102
  svg.setAttribute("viewBox", `0 0 ${sg.width} ${sg.height}`);
2685
3103
  svg.style.fontFamily = "var(--font-sans, system-ui, sans-serif)";
2686
- // Background rect so exported SVGs have correct bg
2687
- // const bgRect = se("rect") as SVGRectElement;
2688
- // bgRect.setAttribute("x", "0");
2689
- // bgRect.setAttribute("y", "0");
2690
- // bgRect.setAttribute("width", String(sg.width));
2691
- // bgRect.setAttribute("height", String(sg.height));
2692
- // bgRect.setAttribute("fill", palette.background);
2693
- // svg.appendChild(bgRect);
3104
+ // ── Background ─────────────────────────────────────────
2694
3105
  if (!options.transparent) {
2695
3106
  const bgRect = se("rect");
2696
3107
  bgRect.setAttribute("x", "0");
@@ -2706,9 +3117,9 @@ function renderToSVG(sg, container, options = {}) {
2706
3117
  const titleColor = String(sg.config["title-color"] ?? palette.titleText);
2707
3118
  const titleSize = Number(sg.config["title-size"] ?? 18);
2708
3119
  const titleWeight = Number(sg.config["title-weight"] ?? 600);
2709
- svg.appendChild(mkText(sg.title, sg.width / 2, 26, titleSize, titleWeight, titleColor));
3120
+ svg.appendChild(mkText(sg.title, sg.width / 2, 26, titleSize, titleWeight, titleColor, "middle", diagramFont));
2710
3121
  }
2711
- // ── Groups (depth-sorted: outermost first) ────────────────
3122
+ // ── Groups ───────────────────────────────────────────────
2712
3123
  const gmMap = new Map(sg.groups.map((g) => [g.id, g]));
2713
3124
  const sortedGroups = [...sg.groups].sort((a, b) => groupDepth$1(a, gmMap) - groupDepth$1(b, gmMap));
2714
3125
  const GL = mkGroup("grp-layer");
@@ -2728,8 +3139,16 @@ function renderToSVG(sg, container, options = {}) {
2728
3139
  strokeWidth: Number(gs.strokeWidth ?? 1.2),
2729
3140
  strokeLineDash: gs.strokeDash ?? palette.groupDash,
2730
3141
  }));
2731
- const labelColor = gs.color ? String(gs.color) : palette.groupLabel;
2732
- gg.appendChild(mkText(g.label, g.x + 14, g.y + 14, 12, 500, labelColor, "start"));
3142
+ // ── Group label typography ──────────────────────────
3143
+ // supports: font, font-size, letter-spacing
3144
+ // always left-anchored (single line)
3145
+ const gLabelColor = gs.color ? String(gs.color) : palette.groupLabel;
3146
+ const gFontSize = Number(gs.fontSize ?? 12);
3147
+ const gFont = resolveStyleFont$1(gs, diagramFont);
3148
+ const gLetterSpacing = gs.letterSpacing;
3149
+ if (g.label) {
3150
+ gg.appendChild(mkText(g.label, g.x + 14, g.y + 14, gFontSize, 500, gLabelColor, "start", gFont, gLetterSpacing));
3151
+ }
2733
3152
  GL.appendChild(gg);
2734
3153
  }
2735
3154
  svg.appendChild(GL);
@@ -2783,7 +3202,13 @@ function renderToSVG(sg, container, options = {}) {
2783
3202
  bg.setAttribute("rx", "3");
2784
3203
  bg.setAttribute("opacity", "0.9");
2785
3204
  eg.appendChild(bg);
2786
- eg.appendChild(mkText(e.label, mx, my, 11, 400, palette.edgeLabelText));
3205
+ // ── Edge label typography ───────────────────────
3206
+ // supports: font, font-size, letter-spacing
3207
+ // always center-anchored (single line floating on edge)
3208
+ const eFontSize = Number(e.style?.fontSize ?? 11);
3209
+ const eFont = resolveStyleFont$1(e.style ?? {}, diagramFont);
3210
+ const eLetterSpacing = e.style?.letterSpacing;
3211
+ eg.appendChild(mkText(e.label, mx, my, eFontSize, 400, palette.edgeLabelText, "middle", eFont, eLetterSpacing));
2787
3212
  }
2788
3213
  EL.appendChild(eg);
2789
3214
  }
@@ -2793,14 +3218,45 @@ function renderToSVG(sg, container, options = {}) {
2793
3218
  for (const n of sg.nodes) {
2794
3219
  const ng = mkGroup(`node-${n.id}`, "ng");
2795
3220
  renderShape$1(rc, n, palette).forEach((s) => ng.appendChild(s));
3221
+ // ── Node / text typography ─────────────────────────
3222
+ // supports: font, font-size, letter-spacing, text-align, line-height
2796
3223
  const fontSize = Number(n.style?.fontSize ?? (n.shape === "text" ? 13 : 14));
2797
3224
  const fontWeight = n.style?.fontWeight ?? (n.shape === "text" ? 400 : 500);
2798
- const lines = n.label.split("\n");
3225
+ const textColor = String(n.style?.color ??
3226
+ (n.shape === "text" ? palette.edgeLabelText : palette.nodeText));
3227
+ const nodeFont = resolveStyleFont$1(n.style ?? {}, diagramFont);
3228
+ const textAlign = String(n.style?.textAlign ?? "center");
3229
+ const anchorMap = {
3230
+ left: "start",
3231
+ center: "middle",
3232
+ right: "end",
3233
+ };
3234
+ const textAnchor = anchorMap[textAlign] ?? "middle";
3235
+ // line-height is a multiplier (e.g. 1.4 = 140% of font-size)
3236
+ const lineHeight = Number(n.style?.lineHeight ?? 1.3) * fontSize;
3237
+ const letterSpacing = n.style?.letterSpacing;
3238
+ // x shifts for left / right alignment
3239
+ const textX = textAlign === "left"
3240
+ ? n.x + 8
3241
+ : textAlign === "right"
3242
+ ? n.x + n.w - 8
3243
+ : n.x + n.w / 2;
3244
+ const lines = n.shape === 'text' && !n.label.includes('\n')
3245
+ ? wrapText$1(n.label, n.w - 16, fontSize)
3246
+ : n.label.split('\n');
3247
+ const verticalAlign = String(n.style?.verticalAlign ?? "middle");
3248
+ const nodeBodyTop = n.y + 6;
3249
+ const nodeBodyBottom = n.y + n.h - 6;
3250
+ const nodeBodyMid = n.y + n.h / 2;
3251
+ const blockH = (lines.length - 1) * lineHeight;
3252
+ const textCY = verticalAlign === "top"
3253
+ ? nodeBodyTop + blockH / 2
3254
+ : verticalAlign === "bottom"
3255
+ ? nodeBodyBottom - blockH / 2
3256
+ : nodeBodyMid;
2799
3257
  ng.appendChild(lines.length > 1
2800
- ? mkMultilineText(lines, n.x + n.w / 2, n.y + n.h / 2, fontSize, fontWeight, String(n.style?.color ??
2801
- (n.shape === "text" ? palette.edgeLabelText : palette.nodeText)))
2802
- : mkText(n.label, n.x + n.w / 2, n.y + n.h / 2, fontSize, fontWeight, String(n.style?.color ??
2803
- (n.shape === "text" ? palette.edgeLabelText : palette.nodeText))));
3258
+ ? mkMultilineText(lines, textX, textCY, fontSize, fontWeight, textColor, textAnchor, lineHeight, nodeFont, letterSpacing)
3259
+ : mkText(n.label, textX, textCY, fontSize, fontWeight, textColor, textAnchor, nodeFont, letterSpacing));
2804
3260
  if (options.interactive) {
2805
3261
  ng.style.cursor = "pointer";
2806
3262
  ng.addEventListener("click", () => options.onNodeClick?.(n.id));
@@ -2826,7 +3282,12 @@ function renderToSVG(sg, container, options = {}) {
2826
3282
  const hdrText = String(gs.color ?? palette.tableHeaderText);
2827
3283
  const divCol = palette.tableDivider;
2828
3284
  const pad = t.labelH;
2829
- // Outer border
3285
+ // ── Table-level font (applies to label + all cells) ─
3286
+ // supports: font, font-size, letter-spacing
3287
+ const tFontSize = Number(gs.fontSize ?? 12);
3288
+ const tFont = resolveStyleFont$1(gs, diagramFont);
3289
+ const tLetterSpacing = gs.letterSpacing;
3290
+ // outer border
2830
3291
  tg.appendChild(rc.rectangle(t.x, t.y, t.w, t.h, {
2831
3292
  ...BASE_ROUGH,
2832
3293
  seed: hashStr$3(t.id),
@@ -2835,20 +3296,19 @@ function renderToSVG(sg, container, options = {}) {
2835
3296
  stroke: strk,
2836
3297
  strokeWidth: 1.5,
2837
3298
  }));
2838
- // Label strip separator
3299
+ // label strip separator
2839
3300
  tg.appendChild(rc.line(t.x, t.y + pad, t.x + t.w, t.y + pad, {
2840
3301
  roughness: 0.6,
2841
3302
  seed: hashStr$3(t.id + "l"),
2842
3303
  stroke: strk,
2843
3304
  strokeWidth: 1,
2844
3305
  }));
2845
- // Label text
2846
- tg.appendChild(mkText(t.label, t.x + 10, t.y + pad / 2, 12, 500, textCol, "start"));
2847
- // Rows
3306
+ // ── Table label: font, font-size, letter-spacing (always left) ──
3307
+ tg.appendChild(mkText(t.label, t.x + 10, t.y + pad / 2, tFontSize, 500, textCol, "start", tFont, tLetterSpacing));
3308
+ // rows
2848
3309
  let rowY = t.y + pad;
2849
3310
  for (const row of t.rows) {
2850
3311
  const rh = row.kind === "header" ? t.headerH : t.rowH;
2851
- // Header background fill
2852
3312
  if (row.kind === "header") {
2853
3313
  const hdrBg = se("rect");
2854
3314
  hdrBg.setAttribute("x", String(t.x + 1));
@@ -2858,19 +3318,34 @@ function renderToSVG(sg, container, options = {}) {
2858
3318
  hdrBg.setAttribute("fill", hdrFill);
2859
3319
  tg.appendChild(hdrBg);
2860
3320
  }
2861
- // Row separator
2862
3321
  tg.appendChild(rc.line(t.x, rowY + rh, t.x + t.w, rowY + rh, {
2863
3322
  roughness: 0.4,
2864
3323
  seed: hashStr$3(t.id + rowY),
2865
3324
  stroke: row.kind === "header" ? strk : divCol,
2866
3325
  strokeWidth: row.kind === "header" ? 1.2 : 0.6,
2867
3326
  }));
2868
- // Cell text + col separators
3327
+ // ── Cell text: font, font-size, letter-spacing, text-align ──
3328
+ // text-align applies to data rows; header is always centered
3329
+ const cellAlignProp = row.kind === "header" ? "center" : String(gs.textAlign ?? "center");
3330
+ const cellAnchorMap = {
3331
+ left: "start",
3332
+ center: "middle",
3333
+ right: "end",
3334
+ };
3335
+ const cellAnchor = cellAnchorMap[cellAlignProp] ?? "middle";
3336
+ const cellFw = row.kind === "header" ? 600 : 400;
3337
+ const cellColor = row.kind === "header" ? hdrText : textCol;
2869
3338
  let cx = t.x;
2870
3339
  row.cells.forEach((cell, i) => {
2871
3340
  const cw = t.colWidths[i] ?? 60;
2872
- const fw = row.kind === "header" ? 600 : 400;
2873
- tg.appendChild(mkText(cell, cx + cw / 2, rowY + rh / 2, 12, fw, row.kind === "header" ? hdrText : textCol));
3341
+ // x position shifts with alignment
3342
+ const cellX = cellAnchor === "start"
3343
+ ? cx + 6
3344
+ : cellAnchor === "end"
3345
+ ? cx + cw - 6
3346
+ : cx + cw / 2;
3347
+ // ← was missing tg.appendChild — cells were invisible before
3348
+ tg.appendChild(mkText(cell, cellX, rowY + rh / 2, tFontSize, cellFw, cellColor, cellAnchor, tFont, tLetterSpacing));
2874
3349
  if (i < row.cells.length - 1) {
2875
3350
  tg.appendChild(rc.line(cx + cw, t.y + pad, cx + cw, t.y + t.h, {
2876
3351
  roughness: 0.3,
@@ -2899,6 +3374,25 @@ function renderToSVG(sg, container, options = {}) {
2899
3374
  const strk = String(gs.stroke ?? palette.noteStroke);
2900
3375
  const fold = 14;
2901
3376
  const { x, y, w, h } = n;
3377
+ // ── Note typography ─────────────────────────────────
3378
+ // supports: font, font-size, letter-spacing, text-align, line-height
3379
+ const nFontSize = Number(gs.fontSize ?? 12);
3380
+ const nFont = resolveStyleFont$1(gs, diagramFont);
3381
+ const nLetterSpacing = gs.letterSpacing;
3382
+ const nLineHeight = Number(gs.lineHeight ?? 1.4) * nFontSize;
3383
+ const nTextAlign = String(gs.textAlign ?? "left");
3384
+ const nAnchorMap = {
3385
+ left: "start",
3386
+ center: "middle",
3387
+ right: "end",
3388
+ };
3389
+ const nAnchor = nAnchorMap[nTextAlign] ?? "start";
3390
+ // x position for the text block (pad from left, with alignment)
3391
+ const nTextX = nTextAlign === "right"
3392
+ ? x + w - fold - 6
3393
+ : nTextAlign === "center"
3394
+ ? x + (w - fold) / 2
3395
+ : x + 12;
2902
3396
  ng.appendChild(rc.polygon([
2903
3397
  [x, y],
2904
3398
  [x + w - fold, y],
@@ -2925,12 +3419,75 @@ function renderToSVG(sg, container, options = {}) {
2925
3419
  stroke: strk,
2926
3420
  strokeWidth: 0.8,
2927
3421
  }));
2928
- n.lines.forEach((line, i) => {
2929
- ng.appendChild(mkText(line, x + 12, y + 12 + i * 20 + 10, 12, 400, String(gs.color ?? palette.noteText), "start"));
2930
- });
3422
+ const nVerticalAlign = String(gs.verticalAlign ?? "top");
3423
+ const bodyTop = y + fold + 8; // below the fold triangle
3424
+ const bodyBottom = y + h - 8; // above bottom edge
3425
+ const bodyMid = (bodyTop + bodyBottom) / 2;
3426
+ const blockH = (n.lines.length - 1) * nLineHeight;
3427
+ const blockCY = nVerticalAlign === "bottom"
3428
+ ? bodyBottom - blockH / 2
3429
+ : nVerticalAlign === "middle"
3430
+ ? bodyMid
3431
+ : bodyTop + blockH / 2;
3432
+ // multiline: use mkMultilineText so line-height is respected
3433
+ if (n.lines.length > 1) {
3434
+ // vertical centre of the text block inside the note
3435
+ ng.appendChild(mkMultilineText(n.lines, nTextX, blockCY, nFontSize, 400, String(gs.color ?? palette.noteText), nAnchor, nLineHeight, nFont, nLetterSpacing));
3436
+ }
3437
+ else {
3438
+ ng.appendChild(mkText(n.lines[0] ?? "", nTextX, blockCY, nFontSize, 400, String(gs.color ?? palette.noteText), nAnchor, nFont, nLetterSpacing));
3439
+ }
2931
3440
  NoteL.appendChild(ng);
2932
3441
  }
2933
3442
  svg.appendChild(NoteL);
3443
+ markdownMap(sg);
3444
+ const MDL = mkGroup('markdown-layer');
3445
+ for (const m of sg.markdowns) {
3446
+ const mg = mkGroup(`markdown-${m.id}`, 'mdg');
3447
+ const mFont = resolveStyleFont$1(m.style, diagramFont);
3448
+ const baseColor = String(m.style?.color ?? palette.nodeText);
3449
+ const textAlign = String(m.style?.textAlign ?? 'left');
3450
+ const anchor = textAlign === 'right' ? 'end'
3451
+ : textAlign === 'center' ? 'middle'
3452
+ : 'start';
3453
+ const PAD = Number(m.style?.padding ?? 16);
3454
+ const textX = textAlign === 'right' ? m.x + m.w - PAD
3455
+ : textAlign === 'center' ? m.x + m.w / 2
3456
+ : m.x + PAD;
3457
+ let y = m.y + PAD;
3458
+ for (const line of m.lines) {
3459
+ if (line.kind === 'blank') {
3460
+ y += LINE_SPACING.blank;
3461
+ continue;
3462
+ }
3463
+ const fontSize = LINE_FONT_SIZE[line.kind];
3464
+ const fontWeight = LINE_FONT_WEIGHT[line.kind];
3465
+ const t = se('text');
3466
+ t.setAttribute('x', String(textX));
3467
+ t.setAttribute('y', String(y + fontSize / 2));
3468
+ t.setAttribute('text-anchor', anchor);
3469
+ t.setAttribute('dominant-baseline', 'middle');
3470
+ t.setAttribute('font-family', mFont);
3471
+ t.setAttribute('font-size', String(fontSize));
3472
+ t.setAttribute('font-weight', String(fontWeight));
3473
+ t.setAttribute('fill', baseColor);
3474
+ t.setAttribute('pointer-events', 'none');
3475
+ t.setAttribute('user-select', 'none');
3476
+ for (const run of line.runs) {
3477
+ const span = se('tspan');
3478
+ span.textContent = run.text;
3479
+ if (run.bold)
3480
+ span.setAttribute('font-weight', '700');
3481
+ if (run.italic)
3482
+ span.setAttribute('font-style', 'italic');
3483
+ t.appendChild(span);
3484
+ }
3485
+ mg.appendChild(t);
3486
+ y += LINE_SPACING[line.kind];
3487
+ }
3488
+ MDL.appendChild(mg);
3489
+ }
3490
+ svg.appendChild(MDL);
2934
3491
  // ── Charts ────────────────────────────────────────────────
2935
3492
  const CL = mkGroup("chart-layer");
2936
3493
  for (const c of sg.charts) {
@@ -3204,22 +3761,83 @@ function hashStr$1(s) {
3204
3761
  h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
3205
3762
  return h;
3206
3763
  }
3207
- // ── Arrow direction from connector (mirrors svg/index.ts)
3764
+ // ── Small helper: load + resolve font from a style map ────────────────────
3765
+ function resolveStyleFont(style, fallback) {
3766
+ const raw = String(style['font'] ?? '');
3767
+ if (!raw)
3768
+ return fallback;
3769
+ loadFont(raw);
3770
+ return resolveFont(raw);
3771
+ }
3772
+ // ── Canvas text helpers ────────────────────────────────────────────────────
3773
+ function drawText(ctx, txt, x, y, sz = 14, wt = 500, col = '#1a1208', align = 'center', font = 'system-ui, sans-serif', letterSpacing) {
3774
+ ctx.save();
3775
+ ctx.font = `${wt} ${sz}px ${font}`;
3776
+ ctx.fillStyle = col;
3777
+ ctx.textAlign = align;
3778
+ ctx.textBaseline = 'middle';
3779
+ if (letterSpacing) {
3780
+ // Canvas has no native letter-spacing — draw char by char
3781
+ const chars = txt.split('');
3782
+ const totalW = ctx.measureText(txt).width + letterSpacing * (chars.length - 1);
3783
+ let startX = align === 'center' ? x - totalW / 2
3784
+ : align === 'right' ? x - totalW
3785
+ : x;
3786
+ ctx.textAlign = 'left';
3787
+ for (const ch of chars) {
3788
+ ctx.fillText(ch, startX, y);
3789
+ startX += ctx.measureText(ch).width + letterSpacing;
3790
+ }
3791
+ }
3792
+ else {
3793
+ ctx.fillText(txt, x, y);
3794
+ }
3795
+ ctx.restore();
3796
+ }
3797
+ function drawMultilineText(ctx, lines, x, cy, sz = 14, wt = 500, col = '#1a1208', align = 'center', lineH = 18, font = 'system-ui, sans-serif', letterSpacing) {
3798
+ const totalH = (lines.length - 1) * lineH;
3799
+ const startY = cy - totalH / 2;
3800
+ lines.forEach((line, i) => {
3801
+ drawText(ctx, line, x, startY + i * lineH, sz, wt, col, align, font, letterSpacing);
3802
+ });
3803
+ }
3804
+ // Soft word-wrap for `text` shape nodes
3805
+ function wrapText(text, maxWidth, fontSize) {
3806
+ const charWidth = fontSize * 0.55;
3807
+ const maxChars = Math.floor(maxWidth / charWidth);
3808
+ const words = text.split(' ');
3809
+ const lines = [];
3810
+ let current = '';
3811
+ for (const word of words) {
3812
+ const test = current ? `${current} ${word}` : word;
3813
+ if (test.length > maxChars && current) {
3814
+ lines.push(current);
3815
+ current = word;
3816
+ }
3817
+ else {
3818
+ current = test;
3819
+ }
3820
+ }
3821
+ if (current)
3822
+ lines.push(current);
3823
+ return lines.length ? lines : [text];
3824
+ }
3825
+ // ── Arrow direction ────────────────────────────────────────────────────────
3208
3826
  function connMeta(connector) {
3209
- if (connector === "--")
3210
- return { arrowAt: "none", dashed: false };
3211
- if (connector === "---")
3212
- return { arrowAt: "none", dashed: true };
3213
- const bidir = connector.includes("<") && connector.includes(">");
3827
+ if (connector === '--')
3828
+ return { arrowAt: 'none', dashed: false };
3829
+ if (connector === '---')
3830
+ return { arrowAt: 'none', dashed: true };
3831
+ const bidir = connector.includes('<') && connector.includes('>');
3214
3832
  if (bidir)
3215
- return { arrowAt: "both", dashed: connector.includes("--") };
3216
- const back = connector.startsWith("<");
3217
- const dashed = connector.includes("--");
3833
+ return { arrowAt: 'both', dashed: connector.includes('--') };
3834
+ const back = connector.startsWith('<');
3835
+ const dashed = connector.includes('--');
3218
3836
  if (back)
3219
- return { arrowAt: "start", dashed };
3220
- return { arrowAt: "end", dashed };
3837
+ return { arrowAt: 'start', dashed };
3838
+ return { arrowAt: 'end', dashed };
3221
3839
  }
3222
- // ── Generic rect connection point ─────────────────────────
3840
+ // ── Rect connection point ──────────────────────────────────────────────────
3223
3841
  function rectConnPoint(rx, ry, rw, rh, ox, oy) {
3224
3842
  const cx = rx + rw / 2, cy = ry + rh / 2;
3225
3843
  const dx = ox - cx, dy = oy - cy;
@@ -3232,19 +3850,16 @@ function rectConnPoint(rx, ry, rw, rh, ox, oy) {
3232
3850
  return [cx + t * dx, cy + t * dy];
3233
3851
  }
3234
3852
  function resolveEndpoint(id, nm, tm, gm, cm, ntm) {
3235
- return (nm.get(id) ?? tm.get(id) ?? gm.get(id) ?? cm.get(id) ?? ntm.get(id) ?? null);
3853
+ return nm.get(id) ?? tm.get(id) ?? gm.get(id) ?? cm.get(id) ?? ntm.get(id) ?? null;
3236
3854
  }
3237
3855
  function getConnPoint(src, dstCX, dstCY) {
3238
- if ("shape" in src && src.shape) {
3856
+ if ('shape' in src && src.shape) {
3239
3857
  return connPoint(src, {
3240
- x: dstCX - 1,
3241
- y: dstCY - 1,
3242
- w: 2,
3243
- h: 2});
3858
+ x: dstCX - 1, y: dstCY - 1, w: 2, h: 2});
3244
3859
  }
3245
3860
  return rectConnPoint(src.x, src.y, src.w, src.h, dstCX, dstCY);
3246
3861
  }
3247
- // ── Group depth (for paint order, outermost first) ────────
3862
+ // ── Group depth ────────────────────────────────────────────────────────────
3248
3863
  function groupDepth(g, gm) {
3249
3864
  let d = 0;
3250
3865
  let cur = g;
@@ -3254,80 +3869,57 @@ function groupDepth(g, gm) {
3254
3869
  }
3255
3870
  return d;
3256
3871
  }
3257
- // ── Node shapes ───────────────────────────────────────────
3872
+ // ── Node shapes ────────────────────────────────────────────────────────────
3258
3873
  function renderShape(rc, ctx, n, palette, R) {
3259
3874
  const s = n.style ?? {};
3260
3875
  const fill = String(s.fill ?? palette.nodeFill);
3261
3876
  const stroke = String(s.stroke ?? palette.nodeStroke);
3262
3877
  const opts = {
3263
- ...R,
3264
- seed: hashStr$1(n.id),
3265
- fill,
3266
- fillStyle: "solid",
3267
- stroke,
3268
- strokeWidth: Number(s.strokeWidth ?? 1.9),
3878
+ ...R, seed: hashStr$1(n.id),
3879
+ fill, fillStyle: 'solid',
3880
+ stroke, strokeWidth: Number(s.strokeWidth ?? 1.9),
3269
3881
  };
3270
3882
  const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
3271
3883
  const hw = n.w / 2 - 2;
3272
3884
  switch (n.shape) {
3273
- case "circle":
3885
+ case 'circle':
3274
3886
  rc.ellipse(cx, cy, n.w * 0.88, n.h * 0.88, opts);
3275
3887
  break;
3276
- case "diamond":
3277
- rc.polygon([
3278
- [cx, n.y + 2],
3279
- [cx + hw, cy],
3280
- [cx, n.y + n.h - 2],
3281
- [cx - hw, cy],
3282
- ], opts);
3888
+ case 'diamond':
3889
+ rc.polygon([[cx, n.y + 2], [cx + hw, cy], [cx, n.y + n.h - 2], [cx - hw, cy]], opts);
3283
3890
  break;
3284
- case "hexagon": {
3891
+ case 'hexagon': {
3285
3892
  const hw2 = hw * 0.56;
3286
3893
  rc.polygon([
3287
- [cx - hw2, n.y + 3],
3288
- [cx + hw2, n.y + 3],
3289
- [cx + hw, cy],
3290
- [cx + hw2, n.y + n.h - 3],
3291
- [cx - hw2, n.y + n.h - 3],
3292
- [cx - hw, cy],
3894
+ [cx - hw2, n.y + 3], [cx + hw2, n.y + 3], [cx + hw, cy],
3895
+ [cx + hw2, n.y + n.h - 3], [cx - hw2, n.y + n.h - 3], [cx - hw, cy],
3293
3896
  ], opts);
3294
3897
  break;
3295
3898
  }
3296
- case "triangle":
3297
- rc.polygon([
3298
- [cx, n.y + 3],
3299
- [n.x + n.w - 3, n.y + n.h - 3],
3300
- [n.x + 3, n.y + n.h - 3],
3301
- ], opts);
3899
+ case 'triangle':
3900
+ 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);
3302
3901
  break;
3303
- case "cylinder": {
3902
+ case 'cylinder': {
3304
3903
  const eH = 18;
3305
3904
  rc.rectangle(n.x + 3, n.y + eH / 2, n.w - 6, n.h - eH, opts);
3306
3905
  rc.ellipse(cx, n.y + eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6 });
3307
- rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, {
3308
- ...opts,
3309
- roughness: 0.6,
3310
- fill: "none",
3311
- });
3906
+ rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6, fill: 'none' });
3312
3907
  break;
3313
3908
  }
3314
- case "parallelogram":
3909
+ case 'parallelogram':
3315
3910
  rc.polygon([
3316
- [n.x + 18, n.y + 1],
3317
- [n.x + n.w - 1, n.y + 1],
3318
- [n.x + n.w - 18, n.y + n.h - 1],
3319
- [n.x + 1, n.y + n.h - 1],
3911
+ [n.x + 18, n.y + 1], [n.x + n.w - 1, n.y + 1],
3912
+ [n.x + n.w - 18, n.y + n.h - 1], [n.x + 1, n.y + n.h - 1],
3320
3913
  ], opts);
3321
3914
  break;
3322
- case "text":
3323
- break; // text nodes: no background shape
3324
- case "image": {
3915
+ case 'text':
3916
+ break; // no shape drawn
3917
+ case 'image': {
3325
3918
  if (n.imageUrl) {
3326
3919
  const img = new Image();
3327
- img.crossOrigin = "anonymous";
3920
+ img.crossOrigin = 'anonymous';
3328
3921
  img.onload = () => {
3329
3922
  ctx.save();
3330
- // rounded clip
3331
3923
  ctx.beginPath();
3332
3924
  const r = 6;
3333
3925
  ctx.moveTo(n.x + r, n.y);
@@ -3343,21 +3935,12 @@ function renderShape(rc, ctx, n, palette, R) {
3343
3935
  ctx.clip();
3344
3936
  ctx.drawImage(img, n.x + 1, n.y + 1, n.w - 2, n.h - 2);
3345
3937
  ctx.restore();
3346
- // border on top
3347
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
3348
- ...opts,
3349
- fill: "none",
3350
- });
3938
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: 'none' });
3351
3939
  };
3352
3940
  img.src = n.imageUrl;
3353
3941
  }
3354
3942
  else {
3355
- // placeholder
3356
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
3357
- ...opts,
3358
- fill: "#e0e0e0",
3359
- stroke: "#999999",
3360
- });
3943
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: '#e0e0e0', stroke: '#999999' });
3361
3944
  }
3362
3945
  return;
3363
3946
  }
@@ -3366,57 +3949,52 @@ function renderShape(rc, ctx, n, palette, R) {
3366
3949
  break;
3367
3950
  }
3368
3951
  }
3369
- // ── Arrowhead ─────────────────────────────────────────────
3952
+ // ── Arrowhead ─────────────────────────────────────────────────────────────
3370
3953
  function drawArrowHead(rc, x, y, angle, col, seed, R) {
3371
3954
  const as = 12;
3372
3955
  rc.polygon([
3373
3956
  [x, y],
3374
- [
3375
- x - as * Math.cos(angle - Math.PI / 6.5),
3376
- y - as * Math.sin(angle - Math.PI / 6.5),
3377
- ],
3378
- [
3379
- x - as * Math.cos(angle + Math.PI / 6.5),
3380
- y - as * Math.sin(angle + Math.PI / 6.5),
3381
- ],
3382
- ], {
3383
- roughness: 0.3,
3384
- seed,
3385
- fill: col,
3386
- fillStyle: "solid",
3387
- stroke: col,
3388
- strokeWidth: 0.8,
3389
- });
3957
+ [x - as * Math.cos(angle - Math.PI / 6.5), y - as * Math.sin(angle - Math.PI / 6.5)],
3958
+ [x - as * Math.cos(angle + Math.PI / 6.5), y - as * Math.sin(angle + Math.PI / 6.5)],
3959
+ ], { roughness: 0.3, seed, fill: col, fillStyle: 'solid', stroke: col, strokeWidth: 0.8 });
3390
3960
  }
3961
+ // ── Public API ─────────────────────────────────────────────────────────────
3391
3962
  function renderToCanvas(sg, canvas, options = {}) {
3392
- if (typeof rough === "undefined")
3393
- throw new Error("rough.js not loaded");
3963
+ if (typeof rough === 'undefined')
3964
+ throw new Error('rough.js not loaded');
3394
3965
  const scale = options.scale ?? window.devicePixelRatio ?? 1;
3395
3966
  canvas.width = sg.width * scale;
3396
3967
  canvas.height = sg.height * scale;
3397
- canvas.style.width = sg.width + "px";
3398
- canvas.style.height = sg.height + "px";
3399
- const ctx = canvas.getContext("2d");
3968
+ canvas.style.width = sg.width + 'px';
3969
+ canvas.style.height = sg.height + 'px';
3970
+ const ctx = canvas.getContext('2d');
3400
3971
  ctx.scale(scale, scale);
3401
- if (options.transparent) {
3402
- ctx.clearRect(0, 0, canvas.width, canvas.height);
3403
- }
3404
- // ── Resolve palette (mirrors SVG renderer) ───────────────
3405
- const isDark = options.theme === "dark" ||
3406
- (options.theme === "auto" &&
3407
- window.matchMedia?.("(prefers-color-scheme:dark)").matches);
3408
- const themeName = String(sg.config[THEME_CONFIG_KEY] ?? (isDark ? "dark" : "light"));
3972
+ // ── Palette ──────────────────────────────────────────────
3973
+ const isDark = options.theme === 'dark' ||
3974
+ (options.theme === 'auto' &&
3975
+ typeof window !== 'undefined' &&
3976
+ window.matchMedia?.('(prefers-color-scheme:dark)').matches);
3977
+ const themeName = String(sg.config[THEME_CONFIG_KEY] ?? (isDark ? 'dark' : 'light'));
3409
3978
  const palette = resolvePalette(themeName);
3979
+ // ── Diagram-level font ───────────────────────────────────
3980
+ const diagramFont = (() => {
3981
+ const raw = String(sg.config['font'] ?? '');
3982
+ if (raw) {
3983
+ loadFont(raw);
3984
+ return resolveFont(raw);
3985
+ }
3986
+ return DEFAULT_FONT;
3987
+ })();
3988
+ // ── Background ───────────────────────────────────────────
3410
3989
  if (!options.transparent) {
3411
3990
  ctx.fillStyle = options.background ?? palette.background;
3412
3991
  ctx.fillRect(0, 0, sg.width, sg.height);
3413
3992
  }
3993
+ else {
3994
+ ctx.clearRect(0, 0, sg.width, sg.height);
3995
+ }
3414
3996
  const rc = rough.canvas(canvas);
3415
- const R = {
3416
- roughness: options.roughness ?? 1.3,
3417
- bowing: options.bowing ?? 0.7,
3418
- };
3419
- // ── Lookup maps ──────────────────────────────────────────
3997
+ const R = { roughness: options.roughness ?? 1.3, bowing: options.bowing ?? 0.7 };
3420
3998
  const nm = nodeMap(sg);
3421
3999
  const tm = tableMap(sg);
3422
4000
  const gm = groupMap(sg);
@@ -3424,36 +4002,35 @@ function renderToCanvas(sg, canvas, options = {}) {
3424
4002
  const ntm = noteMap(sg);
3425
4003
  // ── Title ────────────────────────────────────────────────
3426
4004
  if (sg.title) {
3427
- ctx.save();
3428
- ctx.font = "600 18px system-ui, sans-serif";
3429
- ctx.fillStyle = palette.titleText;
3430
- ctx.textAlign = "center";
3431
- ctx.fillText(sg.title, sg.width / 2, 28);
3432
- ctx.restore();
4005
+ const titleSize = Number(sg.config['title-size'] ?? 18);
4006
+ const titleWeight = Number(sg.config['title-weight'] ?? 600);
4007
+ const titleColor = String(sg.config['title-color'] ?? palette.titleText);
4008
+ drawText(ctx, sg.title, sg.width / 2, 28, titleSize, titleWeight, titleColor, 'center', diagramFont);
3433
4009
  }
3434
- // ── Groups (depth-sorted: outermost first) ────────────────
4010
+ // ── Groups (outermost first) ─────────────────────────────
3435
4011
  const sortedGroups = [...sg.groups].sort((a, b) => groupDepth(a, gm) - groupDepth(b, gm));
3436
4012
  for (const g of sortedGroups) {
3437
4013
  if (!g.w)
3438
4014
  continue;
3439
4015
  const gs = g.style ?? {};
3440
4016
  rc.rectangle(g.x, g.y, g.w, g.h, {
3441
- ...R,
3442
- roughness: 1.7,
3443
- bowing: 0.4,
3444
- seed: hashStr$1(g.id),
4017
+ ...R, roughness: 1.7, bowing: 0.4, seed: hashStr$1(g.id),
3445
4018
  fill: String(gs.fill ?? palette.groupFill),
3446
- fillStyle: "solid",
4019
+ fillStyle: 'solid',
3447
4020
  stroke: String(gs.stroke ?? palette.groupStroke),
3448
4021
  strokeWidth: Number(gs.strokeWidth ?? 1.2),
3449
4022
  strokeLineDash: gs.strokeDash ?? palette.groupDash,
3450
4023
  });
3451
- ctx.save();
3452
- ctx.font = "500 12px system-ui, sans-serif";
3453
- ctx.fillStyle = gs.color ? String(gs.color) : palette.groupLabel;
3454
- ctx.textAlign = "left";
3455
- ctx.fillText(g.label, g.x + 14, g.y + 16);
3456
- ctx.restore();
4024
+ // ── Group label ──────────────────────────────────────
4025
+ // Only render when label has content — empty label = no reserved space
4026
+ // supports: font, font-size, letter-spacing (always left-anchored)
4027
+ if (g.label) {
4028
+ const gFontSize = Number(gs.fontSize ?? 12);
4029
+ const gFont = resolveStyleFont(gs, diagramFont);
4030
+ const gLetterSpacing = gs.letterSpacing;
4031
+ const gLabelColor = gs.color ? String(gs.color) : palette.groupLabel;
4032
+ drawText(ctx, g.label, g.x + 14, g.y + 16, gFontSize, 500, gLabelColor, 'left', gFont, gLetterSpacing);
4033
+ }
3457
4034
  }
3458
4035
  // ── Edges ─────────────────────────────────────────────────
3459
4036
  for (const e of sg.edges) {
@@ -3470,62 +4047,75 @@ function renderToCanvas(sg, canvas, options = {}) {
3470
4047
  const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) || 1;
3471
4048
  const nx = (x2 - x1) / len, ny = (y2 - y1) / len;
3472
4049
  const HEAD = 13;
3473
- const sx1 = arrowAt === "start" || arrowAt === "both" ? x1 + nx * HEAD : x1;
3474
- const sy1 = arrowAt === "start" || arrowAt === "both" ? y1 + ny * HEAD : y1;
3475
- const sx2 = arrowAt === "end" || arrowAt === "both" ? x2 - nx * HEAD : x2;
3476
- const sy2 = arrowAt === "end" || arrowAt === "both" ? y2 - ny * HEAD : y2;
4050
+ const sx1 = arrowAt === 'start' || arrowAt === 'both' ? x1 + nx * HEAD : x1;
4051
+ const sy1 = arrowAt === 'start' || arrowAt === 'both' ? y1 + ny * HEAD : y1;
4052
+ const sx2 = arrowAt === 'end' || arrowAt === 'both' ? x2 - nx * HEAD : x2;
4053
+ const sy2 = arrowAt === 'end' || arrowAt === 'both' ? y2 - ny * HEAD : y2;
3477
4054
  rc.line(sx1, sy1, sx2, sy2, {
3478
- ...R,
3479
- roughness: 0.9,
3480
- seed: hashStr$1(e.from + e.to),
4055
+ ...R, roughness: 0.9, seed: hashStr$1(e.from + e.to),
3481
4056
  stroke: ecol,
3482
4057
  strokeWidth: Number(e.style?.strokeWidth ?? 1.6),
3483
4058
  ...(dashed ? { strokeLineDash: [6, 5] } : {}),
3484
4059
  });
3485
4060
  const ang = Math.atan2(y2 - y1, x2 - x1);
3486
- if (arrowAt === "end" || arrowAt === "both")
4061
+ if (arrowAt === 'end' || arrowAt === 'both')
3487
4062
  drawArrowHead(rc, x2, y2, ang, ecol, hashStr$1(e.to));
3488
- if (arrowAt === "start" || arrowAt === "both")
3489
- drawArrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$1(e.from + "back"));
4063
+ if (arrowAt === 'start' || arrowAt === 'both')
4064
+ drawArrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$1(e.from + 'back'));
3490
4065
  if (e.label) {
3491
4066
  const mx = (x1 + x2) / 2 - ny * 14;
3492
4067
  const my = (y1 + y2) / 2 + nx * 14;
4068
+ // ── Edge label: font, font-size, letter-spacing ──
4069
+ // always center-anchored (single line)
4070
+ const eFontSize = Number(e.style?.fontSize ?? 11);
4071
+ const eFont = resolveStyleFont(e.style ?? {}, diagramFont);
4072
+ const eLetterSpacing = e.style?.letterSpacing;
3493
4073
  ctx.save();
3494
- ctx.font = "400 11px system-ui, sans-serif";
3495
- ctx.textAlign = "center";
4074
+ ctx.font = `400 ${eFontSize}px ${eFont}`;
3496
4075
  const tw = ctx.measureText(e.label).width + 12;
4076
+ ctx.restore();
3497
4077
  ctx.fillStyle = palette.edgeLabelBg;
3498
4078
  ctx.fillRect(mx - tw / 2, my - 8, tw, 15);
3499
- ctx.fillStyle = palette.edgeLabelText;
3500
- ctx.fillText(e.label, mx, my + 3);
3501
- ctx.restore();
4079
+ drawText(ctx, e.label, mx, my + 3, eFontSize, 400, palette.edgeLabelText, 'center', eFont, eLetterSpacing);
3502
4080
  }
3503
4081
  }
3504
4082
  // ── Nodes ─────────────────────────────────────────────────
3505
4083
  for (const n of sg.nodes) {
3506
4084
  renderShape(rc, ctx, n, palette, R);
3507
- const s = n.style ?? {};
3508
- const fontSize = Number(s.fontSize ?? (n.shape === "text" ? 13 : 14));
3509
- const fontWeight = s.fontWeight ?? (n.shape === "text" ? 400 : 500);
3510
- const textColor = String(s.color ??
3511
- (n.shape === "text" ? palette.edgeLabelText : palette.nodeText));
3512
- ctx.save();
3513
- ctx.font = `${fontWeight} ${fontSize}px system-ui, sans-serif`;
3514
- ctx.fillStyle = textColor;
3515
- ctx.textAlign = "center";
3516
- ctx.textBaseline = "middle";
3517
- const lines = n.label.split("\n");
3518
- if (lines.length === 1) {
3519
- ctx.fillText(n.label, n.x + n.w / 2, n.y + n.h / 2);
4085
+ // ── Node / text typography ─────────────────────────
4086
+ // supports: font, font-size, letter-spacing, text-align,
4087
+ // vertical-align, line-height, word-wrap (text shape)
4088
+ const fontSize = Number(n.style?.fontSize ?? (n.shape === 'text' ? 13 : 14));
4089
+ const fontWeight = n.style?.fontWeight ?? (n.shape === 'text' ? 400 : 500);
4090
+ const textColor = String(n.style?.color ??
4091
+ (n.shape === 'text' ? palette.edgeLabelText : palette.nodeText));
4092
+ const nodeFont = resolveStyleFont(n.style ?? {}, diagramFont);
4093
+ const textAlign = String(n.style?.textAlign ?? 'center');
4094
+ const lineHeight = Number(n.style?.lineHeight ?? 1.3) * fontSize;
4095
+ const letterSpacing = n.style?.letterSpacing;
4096
+ const vertAlign = String(n.style?.verticalAlign ?? 'middle');
4097
+ // x shifts for left/right alignment
4098
+ const textX = textAlign === 'left' ? n.x + 8
4099
+ : textAlign === 'right' ? n.x + n.w - 8
4100
+ : n.x + n.w / 2;
4101
+ // word-wrap for text shape; explicit \n for all others
4102
+ const rawLines = n.label.split('\n');
4103
+ const lines = n.shape === 'text' && rawLines.length === 1
4104
+ ? wrapText(n.label, n.w - 16, fontSize)
4105
+ : rawLines;
4106
+ // vertical-align: compute textCY from top/middle/bottom
4107
+ const nodeBodyTop = n.y + 6;
4108
+ const nodeBodyBottom = n.y + n.h - 6;
4109
+ const blockH = (lines.length - 1) * lineHeight;
4110
+ const textCY = vertAlign === 'top' ? nodeBodyTop + blockH / 2
4111
+ : vertAlign === 'bottom' ? nodeBodyBottom - blockH / 2
4112
+ : n.y + n.h / 2; // middle (default)
4113
+ if (lines.length > 1) {
4114
+ drawMultilineText(ctx, lines, textX, textCY, fontSize, fontWeight, textColor, textAlign, lineHeight, nodeFont, letterSpacing);
3520
4115
  }
3521
4116
  else {
3522
- const lineH = fontSize * 1.35;
3523
- const startY = n.y + n.h / 2 - ((lines.length - 1) * lineH) / 2;
3524
- lines.forEach((line, i) => {
3525
- ctx.fillText(line, n.x + n.w / 2, startY + i * lineH);
3526
- });
4117
+ drawText(ctx, lines[0] ?? '', textX, textCY, fontSize, fontWeight, textColor, textAlign, nodeFont, letterSpacing);
3527
4118
  }
3528
- ctx.restore();
3529
4119
  }
3530
4120
  // ── Tables ────────────────────────────────────────────────
3531
4121
  for (const t of sg.tables) {
@@ -3534,65 +4124,53 @@ function renderToCanvas(sg, canvas, options = {}) {
3534
4124
  const strk = String(gs.stroke ?? palette.tableStroke);
3535
4125
  const textCol = String(gs.color ?? palette.tableText);
3536
4126
  const pad = t.labelH;
3537
- // Outer border
4127
+ // ── Table-level font ────────────────────────────────
4128
+ // supports: font, font-size, letter-spacing
4129
+ // cells also support text-align
4130
+ const tFontSize = Number(gs.fontSize ?? 12);
4131
+ const tFont = resolveStyleFont(gs, diagramFont);
4132
+ const tLetterSpacing = gs.letterSpacing;
3538
4133
  rc.rectangle(t.x, t.y, t.w, t.h, {
3539
- ...R,
3540
- seed: hashStr$1(t.id),
3541
- fill,
3542
- fillStyle: "solid",
3543
- stroke: strk,
3544
- strokeWidth: 1.5,
4134
+ ...R, seed: hashStr$1(t.id),
4135
+ fill, fillStyle: 'solid', stroke: strk, strokeWidth: 1.5,
3545
4136
  });
3546
- // Label strip separator
3547
4137
  rc.line(t.x, t.y + pad, t.x + t.w, t.y + pad, {
3548
- roughness: 0.6,
3549
- seed: hashStr$1(t.id + "l"),
3550
- stroke: strk,
3551
- strokeWidth: 1,
4138
+ roughness: 0.6, seed: hashStr$1(t.id + 'l'), stroke: strk, strokeWidth: 1,
3552
4139
  });
3553
- // Label text
3554
- ctx.save();
3555
- ctx.font = "500 12px system-ui, sans-serif";
3556
- ctx.fillStyle = textCol;
3557
- ctx.textAlign = "left";
3558
- ctx.textBaseline = "middle";
3559
- ctx.fillText(t.label, t.x + 10, t.y + pad / 2);
3560
- ctx.restore();
3561
- // Rows
4140
+ // ── Table label: always left-anchored ───────────────
4141
+ drawText(ctx, t.label, t.x + 10, t.y + pad / 2, tFontSize, 500, textCol, 'left', tFont, tLetterSpacing);
3562
4142
  let rowY = t.y + pad;
3563
4143
  for (const row of t.rows) {
3564
- const rh = row.kind === "header" ? t.headerH : t.rowH;
3565
- // Header background
3566
- if (row.kind === "header") {
4144
+ const rh = row.kind === 'header' ? t.headerH : t.rowH;
4145
+ if (row.kind === 'header') {
3567
4146
  ctx.fillStyle = palette.tableHeaderFill;
3568
4147
  ctx.fillRect(t.x + 1, rowY + 1, t.w - 2, rh - 1);
3569
4148
  }
3570
- // Row separator
3571
4149
  rc.line(t.x, rowY + rh, t.x + t.w, rowY + rh, {
3572
- roughness: 0.4,
3573
- seed: hashStr$1(t.id + rowY),
3574
- stroke: row.kind === "header" ? strk : palette.tableDivider,
3575
- strokeWidth: row.kind === "header" ? 1.2 : 0.6,
4150
+ roughness: 0.4, seed: hashStr$1(t.id + rowY),
4151
+ stroke: row.kind === 'header' ? strk : palette.tableDivider,
4152
+ strokeWidth: row.kind === 'header' ? 1.2 : 0.6,
3576
4153
  });
3577
- // Cell text + column separators
4154
+ // ── Cell text ───────────────────────────────────
4155
+ // header always centered; data rows respect gs.textAlign
4156
+ const cellAlignProp = (row.kind === 'header'
4157
+ ? 'center'
4158
+ : String(gs.textAlign ?? 'center'));
4159
+ const cellFw = row.kind === 'header' ? 600 : 400;
4160
+ const cellColor = row.kind === 'header'
4161
+ ? String(gs.color ?? palette.tableHeaderText)
4162
+ : textCol;
3578
4163
  let cx = t.x;
3579
4164
  row.cells.forEach((cell, i) => {
3580
4165
  const cw = t.colWidths[i] ?? 60;
3581
- const fw = row.kind === "header" ? 600 : 400;
3582
- ctx.save();
3583
- ctx.font = `${fw} 12px system-ui, sans-serif`;
3584
- ctx.fillStyle =
3585
- row.kind === "header" ? palette.tableHeaderText : textCol;
3586
- ctx.textAlign = "center";
3587
- ctx.textBaseline = "middle";
3588
- ctx.fillText(cell, cx + cw / 2, rowY + rh / 2);
3589
- ctx.restore();
4166
+ const cellX = cellAlignProp === 'left' ? cx + 6
4167
+ : cellAlignProp === 'right' ? cx + cw - 6
4168
+ : cx + cw / 2;
4169
+ drawText(ctx, cell, cellX, rowY + rh / 2, tFontSize, cellFw, cellColor, cellAlignProp, tFont, tLetterSpacing);
3590
4170
  if (i < row.cells.length - 1) {
3591
4171
  rc.line(cx + cw, t.y + pad, cx + cw, t.y + t.h, {
3592
- roughness: 0.3,
3593
- seed: hashStr$1(t.id + "c" + i),
3594
- stroke: palette.tableDivider,
3595
- strokeWidth: 0.5,
4172
+ roughness: 0.3, seed: hashStr$1(t.id + 'c' + i),
4173
+ stroke: palette.tableDivider, strokeWidth: 0.5,
3596
4174
  });
3597
4175
  }
3598
4176
  cx += cw;
@@ -3607,44 +4185,106 @@ function renderToCanvas(sg, canvas, options = {}) {
3607
4185
  const strk = String(gs.stroke ?? palette.noteStroke);
3608
4186
  const fold = 14;
3609
4187
  const { x, y, w, h } = n;
3610
- // Note body (folded corner polygon)
3611
4188
  rc.polygon([
3612
4189
  [x, y],
3613
4190
  [x + w - fold, y],
3614
4191
  [x + w, y + fold],
3615
4192
  [x + w, y + h],
3616
4193
  [x, y + h],
3617
- ], {
3618
- ...R,
3619
- seed: hashStr$1(n.id),
3620
- fill,
3621
- fillStyle: "solid",
3622
- stroke: strk,
3623
- strokeWidth: 1.2,
3624
- });
3625
- // Folded corner triangle
4194
+ ], { ...R, seed: hashStr$1(n.id), fill, fillStyle: 'solid', stroke: strk, strokeWidth: 1.2 });
3626
4195
  rc.polygon([
3627
4196
  [x + w - fold, y],
3628
4197
  [x + w, y + fold],
3629
4198
  [x + w - fold, y + fold],
3630
- ], {
3631
- roughness: 0.4,
3632
- seed: hashStr$1(n.id + "f"),
3633
- fill: palette.noteFold,
3634
- fillStyle: "solid",
3635
- stroke: strk,
3636
- strokeWidth: 0.8,
3637
- });
3638
- // Text lines
3639
- ctx.save();
3640
- ctx.font = "400 12px system-ui, sans-serif";
3641
- ctx.fillStyle = String(gs.color ?? palette.noteText);
3642
- ctx.textAlign = "left";
3643
- ctx.textBaseline = "middle";
3644
- n.lines.forEach((line, i) => {
3645
- ctx.fillText(line, x + 12, y + 12 + i * 20 + 10);
3646
- });
3647
- ctx.restore();
4199
+ ], { roughness: 0.4, seed: hashStr$1(n.id + 'f'),
4200
+ fill: palette.noteFold, fillStyle: 'solid', stroke: strk, strokeWidth: 0.8 });
4201
+ // ── Note typography ─────────────────────────────────
4202
+ // supports: font, font-size, letter-spacing, text-align,
4203
+ // vertical-align, line-height
4204
+ const nFontSize = Number(gs.fontSize ?? 12);
4205
+ const nFont = resolveStyleFont(gs, diagramFont);
4206
+ const nLetterSpacing = gs.letterSpacing;
4207
+ const nLineHeight = Number(gs.lineHeight ?? 1.4) * nFontSize;
4208
+ const nTextAlign = String(gs.textAlign ?? 'left');
4209
+ const nVertAlign = String(gs.verticalAlign ?? 'top');
4210
+ const nColor = String(gs.color ?? palette.noteText);
4211
+ const nTextX = nTextAlign === 'right' ? x + w - fold - 6
4212
+ : nTextAlign === 'center' ? x + (w - fold) / 2
4213
+ : x + 12;
4214
+ // vertical-align inside note body (below fold)
4215
+ const bodyTop = y + fold + 8;
4216
+ const bodyBottom = y + h - 8;
4217
+ const blockH = (n.lines.length - 1) * nLineHeight;
4218
+ const blockCY = nVertAlign === 'bottom' ? bodyBottom - blockH / 2
4219
+ : nVertAlign === 'middle' ? (bodyTop + bodyBottom) / 2
4220
+ : bodyTop + blockH / 2; // top (default)
4221
+ if (n.lines.length > 1) {
4222
+ drawMultilineText(ctx, n.lines, nTextX, blockCY, nFontSize, 400, nColor, nTextAlign, nLineHeight, nFont, nLetterSpacing);
4223
+ }
4224
+ else {
4225
+ drawText(ctx, n.lines[0] ?? '', nTextX, blockCY, nFontSize, 400, nColor, nTextAlign, nFont, nLetterSpacing);
4226
+ }
4227
+ }
4228
+ // ── Markdown blocks ────────────────────────────────────────
4229
+ // Renders prose with Markdown headings and bold/italic inline spans.
4230
+ // Canvas has no native bold-within-a-run, so each run is drawn
4231
+ // individually with its own ctx.font setting.
4232
+ for (const m of (sg.markdowns ?? [])) {
4233
+ const mFont = resolveStyleFont(m.style, diagramFont);
4234
+ const baseColor = String(m.style?.color ?? palette.nodeText);
4235
+ const textAlign = String(m.style?.textAlign ?? 'left');
4236
+ const PAD = Number(m.style?.padding ?? 16);
4237
+ const anchorX = textAlign === 'right' ? m.x + m.w - PAD
4238
+ : textAlign === 'center' ? m.x + m.w / 2
4239
+ : m.x + PAD;
4240
+ let y = m.y + PAD;
4241
+ for (const line of m.lines) {
4242
+ if (line.kind === 'blank') {
4243
+ y += LINE_SPACING.blank;
4244
+ continue;
4245
+ }
4246
+ const fontSize = LINE_FONT_SIZE[line.kind];
4247
+ const fontWeight = LINE_FONT_WEIGHT[line.kind];
4248
+ const lineY = y + fontSize / 2;
4249
+ // Measure total run width for left-offset when runs mix bold/italic
4250
+ // Simple: draw each run consecutively from a computed start x
4251
+ ctx.save();
4252
+ ctx.textBaseline = 'middle';
4253
+ ctx.fillStyle = baseColor;
4254
+ if (textAlign === 'center' || textAlign === 'right') {
4255
+ // Measure full line width first
4256
+ let totalW = 0;
4257
+ for (const run of line.runs) {
4258
+ const runStyle = run.italic ? 'italic ' : '';
4259
+ const runWeight = run.bold ? 700 : fontWeight;
4260
+ ctx.font = `${runStyle}${runWeight} ${fontSize}px ${mFont}`;
4261
+ totalW += ctx.measureText(run.text).width;
4262
+ }
4263
+ let runX = textAlign === 'center' ? anchorX - totalW / 2 : anchorX - totalW;
4264
+ ctx.textAlign = 'left';
4265
+ for (const run of line.runs) {
4266
+ const runStyle = run.italic ? 'italic ' : '';
4267
+ const runWeight = run.bold ? 700 : fontWeight;
4268
+ ctx.font = `${runStyle}${runWeight} ${fontSize}px ${mFont}`;
4269
+ ctx.fillText(run.text, runX, lineY);
4270
+ runX += ctx.measureText(run.text).width;
4271
+ }
4272
+ }
4273
+ else {
4274
+ // left-aligned — draw runs left to right from anchorX
4275
+ let runX = anchorX;
4276
+ ctx.textAlign = 'left';
4277
+ for (const run of line.runs) {
4278
+ const runStyle = run.italic ? 'italic ' : '';
4279
+ const runWeight = run.bold ? 700 : fontWeight;
4280
+ ctx.font = `${runStyle}${runWeight} ${fontSize}px ${mFont}`;
4281
+ ctx.fillText(run.text, runX, lineY);
4282
+ runX += ctx.measureText(run.text).width;
4283
+ }
4284
+ }
4285
+ ctx.restore();
4286
+ y += LINE_SPACING[line.kind];
4287
+ }
3648
4288
  }
3649
4289
  // ── Charts ────────────────────────────────────────────────
3650
4290
  for (const c of sg.charts) {
@@ -3656,19 +4296,19 @@ function renderToCanvas(sg, canvas, options = {}) {
3656
4296
  }, R);
3657
4297
  }
3658
4298
  }
3659
- // ── Export canvas to PNG blob ─────────────────────────────
4299
+ // ── Export helpers ─────────────────────────────────────────────────────────
3660
4300
  function canvasToPNGBlob(canvas) {
3661
4301
  return new Promise((resolve, reject) => {
3662
- canvas.toBlob((blob) => {
4302
+ canvas.toBlob(blob => {
3663
4303
  if (blob)
3664
4304
  resolve(blob);
3665
4305
  else
3666
- reject(new Error("Canvas toBlob failed"));
3667
- }, "image/png");
4306
+ reject(new Error('Canvas toBlob failed'));
4307
+ }, 'image/png');
3668
4308
  });
3669
4309
  }
3670
4310
  function canvasToPNGDataURL(canvas) {
3671
- return canvas.toDataURL("image/png");
4311
+ return canvas.toDataURL('image/png');
3672
4312
  }
3673
4313
 
3674
4314
  // ============================================================
@@ -4603,6 +5243,89 @@ class EventEmitter {
4603
5243
  }
4604
5244
  }
4605
5245
 
5246
+ // ============================================================
5247
+ // sketchmark — Encrypted sharing
5248
+ // Diagram DSL is encrypted in the browser.
5249
+ // The server stores an opaque blob it cannot read.
5250
+ // The decryption key lives only in the URL fragment (#key=...).
5251
+ // ============================================================
5252
+ const WORKER_URL = 'https://sketchmark.anmism7.workers.dev/';
5253
+ // ── Crypto helpers ────────────────────────────────────────
5254
+ async function generateKey() {
5255
+ return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, // extractable so we can export to URL
5256
+ ['encrypt', 'decrypt']);
5257
+ }
5258
+ async function keyToBase64(key) {
5259
+ const raw = await crypto.subtle.exportKey('raw', key);
5260
+ return btoa(String.fromCharCode(...new Uint8Array(raw)));
5261
+ }
5262
+ async function base64ToKey(b64) {
5263
+ const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
5264
+ return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, // not extractable on the receiving end
5265
+ ['decrypt']);
5266
+ }
5267
+ // ── Encrypt ───────────────────────────────────────────────
5268
+ async function encryptDSL(dsl, key) {
5269
+ const iv = crypto.getRandomValues(new Uint8Array(12));
5270
+ const encoded = new TextEncoder().encode(dsl);
5271
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
5272
+ // prepend iv to the blob: [ iv (12 bytes) | ciphertext ]
5273
+ const result = new Uint8Array(12 + encrypted.byteLength);
5274
+ result.set(iv, 0);
5275
+ result.set(new Uint8Array(encrypted), 12);
5276
+ return result;
5277
+ }
5278
+ // ── Decrypt ───────────────────────────────────────────────
5279
+ async function decryptBlob(blob, key) {
5280
+ const iv = blob.slice(0, 12);
5281
+ const ciphertext = blob.slice(12);
5282
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
5283
+ return new TextDecoder().decode(decrypted);
5284
+ }
5285
+ // ── Public API ────────────────────────────────────────────
5286
+ /**
5287
+ * Encrypt DSL, upload to worker, return shareable URL.
5288
+ * The URL fragment (#key=...) never reaches the server.
5289
+ */
5290
+ async function shareDiagram(dsl) {
5291
+ const key = await generateKey();
5292
+ const blob = await encryptDSL(dsl, key);
5293
+ const keyB64 = await keyToBase64(key);
5294
+ const res = await fetch(`${WORKER_URL}/api/blob`, {
5295
+ method: 'POST',
5296
+ headers: { 'Content-Type': 'application/octet-stream' },
5297
+ body: blob.buffer,
5298
+ });
5299
+ if (!res.ok)
5300
+ throw new Error(`Upload failed: ${res.status}`);
5301
+ const { id } = await res.json();
5302
+ // key goes into the fragment — browser never sends this to any server
5303
+ return `${window.location.origin}/playground.html?s=${id}#key=${keyB64}`;
5304
+ }
5305
+ /**
5306
+ * Read ?s= and #key= from the current URL, fetch + decrypt the diagram.
5307
+ * Returns null if no share params found.
5308
+ */
5309
+ async function loadSharedDiagram() {
5310
+ const params = new URLSearchParams(window.location.search);
5311
+ const id = params.get('s');
5312
+ if (!id)
5313
+ return null;
5314
+ // key is in the fragment — parse manually, not via URLSearchParams
5315
+ // (URLSearchParams on hash strips the #)
5316
+ const fragment = window.location.hash.slice(1);
5317
+ const keyMatch = fragment.match(/key=([^&]+)/);
5318
+ if (!keyMatch)
5319
+ return null;
5320
+ const keyB64 = keyMatch[1];
5321
+ const res = await fetch(`${WORKER_URL}/api/blob/${id}`);
5322
+ if (!res.ok)
5323
+ throw new Error('Diagram not found or expired');
5324
+ const blob = await res.arrayBuffer();
5325
+ const key = await base64ToKey(keyB64);
5326
+ return decryptBlob(blob, key);
5327
+ }
5328
+
4606
5329
  // ============================================================
4607
5330
  // sketchmark — Public API
4608
5331
  // ============================================================
@@ -4669,6 +5392,7 @@ function render(options) {
4669
5392
 
4670
5393
  exports.ANIMATION_CSS = ANIMATION_CSS;
4671
5394
  exports.AnimationController = AnimationController;
5395
+ exports.BUILTIN_FONTS = BUILTIN_FONTS;
4672
5396
  exports.EventEmitter = EventEmitter;
4673
5397
  exports.PALETTES = PALETTES;
4674
5398
  exports.ParseError = ParseError;
@@ -4692,13 +5416,19 @@ exports.hashStr = hashStr;
4692
5416
  exports.layout = layout;
4693
5417
  exports.lerp = lerp;
4694
5418
  exports.listThemes = listThemes;
5419
+ exports.loadFont = loadFont;
5420
+ exports.loadSharedDiagram = loadSharedDiagram;
5421
+ exports.markdownMap = markdownMap;
4695
5422
  exports.nodeMap = nodeMap;
4696
5423
  exports.parse = parse;
4697
5424
  exports.parseHex = parseHex;
5425
+ exports.registerFont = registerFont;
4698
5426
  exports.render = render;
4699
5427
  exports.renderToCanvas = renderToCanvas;
4700
5428
  exports.renderToSVG = renderToSVG;
5429
+ exports.resolveFont = resolveFont;
4701
5430
  exports.resolvePalette = resolvePalette;
5431
+ exports.shareDiagram = shareDiagram;
4702
5432
  exports.sleep = sleep;
4703
5433
  exports.svgToPNGDataURL = svgToPNGDataURL;
4704
5434
  exports.svgToString = svgToString;