sketchmark 0.1.1 → 0.1.2

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
@@ -250,6 +250,16 @@ function propsToStyle(p) {
250
250
  s.fontSize = parseFloat(p["font-size"]);
251
251
  if (p["font-weight"])
252
252
  s.fontWeight = p["font-weight"];
253
+ if (p['text-align'])
254
+ s.textAlign = p['text-align'];
255
+ if (p['vertical-align'])
256
+ s.verticalAlign = p['vertical-align'];
257
+ if (p['line-height'])
258
+ s.lineHeight = parseFloat(p['line-height']);
259
+ if (p['letter-spacing'])
260
+ s.letterSpacing = parseFloat(p['letter-spacing']);
261
+ if (p.font)
262
+ s.font = p.font;
253
263
  if (p["dash"]) {
254
264
  const parts = p["dash"]
255
265
  .split(",")
@@ -476,6 +486,8 @@ function parse(src) {
476
486
  label: rawLabel.replace(/\\n/g, "\n"),
477
487
  theme: props.theme,
478
488
  style: propsToStyle(props),
489
+ ...(props.width ? { width: parseFloat(props.width) } : {}),
490
+ ...(props.height ? { height: parseFloat(props.height) } : {}),
479
491
  };
480
492
  }
481
493
  // ── parseGroup ───────────────────────────────────────────
@@ -1062,8 +1074,6 @@ function parse(src) {
1062
1074
  node.style = { ...ast.styles[node.id], ...node.style };
1063
1075
  }
1064
1076
  }
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
1077
  return ast;
1068
1078
  }
1069
1079
 
@@ -1139,6 +1149,8 @@ function buildSceneGraph(ast) {
1139
1149
  y: 0,
1140
1150
  w: 0,
1141
1151
  h: 0,
1152
+ width: n.width,
1153
+ height: n.height,
1142
1154
  };
1143
1155
  });
1144
1156
  const charts = ast.charts.map((c) => {
@@ -1283,6 +1295,10 @@ function sizeNote(n) {
1283
1295
  const maxChars = Math.max(...n.lines.map(l => l.length));
1284
1296
  n.w = Math.max(120, Math.ceil(maxChars * NOTE_FONT) + NOTE_PAD_X * 2);
1285
1297
  n.h = n.lines.length * NOTE_LINE_H + NOTE_PAD_Y * 2;
1298
+ if (n.width && n.w < n.width)
1299
+ n.w = n.width; // ← add
1300
+ if (n.height && n.h < n.height)
1301
+ n.h = n.height; // ← add
1286
1302
  }
1287
1303
  // ── Table auto-sizing ─────────────────────────────────────
1288
1304
  function sizeTable(t) {
@@ -2411,6 +2427,83 @@ function listThemes() {
2411
2427
  }
2412
2428
  const THEME_NAMES = Object.keys(PALETTES);
2413
2429
 
2430
+ // ============================================================
2431
+ // sketchmark — Font Registry
2432
+ // ============================================================
2433
+ // built-in named fonts — user can reference these by short name
2434
+ const BUILTIN_FONTS = {
2435
+ // hand-drawn
2436
+ caveat: {
2437
+ family: "'Caveat', cursive",
2438
+ url: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600&display=swap',
2439
+ },
2440
+ handlee: {
2441
+ family: "'Handlee', cursive",
2442
+ url: 'https://fonts.googleapis.com/css2?family=Handlee&display=swap',
2443
+ },
2444
+ 'indie-flower': {
2445
+ family: "'Indie Flower', cursive",
2446
+ url: 'https://fonts.googleapis.com/css2?family=Indie+Flower&display=swap',
2447
+ },
2448
+ 'patrick-hand': {
2449
+ family: "'Patrick Hand', cursive",
2450
+ url: 'https://fonts.googleapis.com/css2?family=Patrick+Hand&display=swap',
2451
+ },
2452
+ // clean / readable
2453
+ 'dm-mono': {
2454
+ family: "'DM Mono', monospace",
2455
+ url: 'https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&display=swap',
2456
+ },
2457
+ 'jetbrains': {
2458
+ family: "'JetBrains Mono', monospace",
2459
+ url: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap',
2460
+ },
2461
+ 'instrument': {
2462
+ family: "'Instrument Serif', serif",
2463
+ url: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap',
2464
+ },
2465
+ 'playfair': {
2466
+ family: "'Playfair Display', serif",
2467
+ url: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap',
2468
+ },
2469
+ // system fallbacks (no URL needed)
2470
+ system: { family: 'system-ui, sans-serif' },
2471
+ mono: { family: "'Courier New', monospace" },
2472
+ serif: { family: 'Georgia, serif' },
2473
+ };
2474
+ // default — what renders when no font is specified
2475
+ const DEFAULT_FONT = 'system-ui, sans-serif';
2476
+ // resolve a short name or pass-through a quoted CSS family
2477
+ function resolveFont(nameOrFamily) {
2478
+ const key = nameOrFamily.toLowerCase().trim();
2479
+ if (BUILTIN_FONTS[key])
2480
+ return BUILTIN_FONTS[key].family;
2481
+ return nameOrFamily; // treat as raw CSS font-family
2482
+ }
2483
+ // inject a <link> into <head> for a built-in font (browser only)
2484
+ function loadFont(name) {
2485
+ if (typeof document === 'undefined')
2486
+ return;
2487
+ const key = name.toLowerCase().trim();
2488
+ const def = BUILTIN_FONTS[key];
2489
+ if (!def?.url || def.loaded)
2490
+ return;
2491
+ if (document.querySelector(`link[data-sketchmark-font="${key}"]`))
2492
+ return;
2493
+ const link = document.createElement('link');
2494
+ link.rel = 'stylesheet';
2495
+ link.href = def.url;
2496
+ link.setAttribute('data-sketchmark-font', key);
2497
+ document.head.appendChild(link);
2498
+ def.loaded = true;
2499
+ }
2500
+ // user registers their own font (already loaded via CSS/link)
2501
+ function registerFont(name, family, url) {
2502
+ BUILTIN_FONTS[name.toLowerCase()] = { family, url };
2503
+ if (url)
2504
+ loadFont(name);
2505
+ }
2506
+
2414
2507
  // ============================================================
2415
2508
  // sketchmark — SVG Renderer (rough.js hand-drawn)
2416
2509
  // ============================================================
@@ -2423,17 +2516,73 @@ function hashStr$3(s) {
2423
2516
  return h;
2424
2517
  }
2425
2518
  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) {
2519
+ // ── Small helper: load + resolve font from style or fall back ─────────────
2520
+ function resolveStyleFont$1(style, fallback) {
2521
+ const raw = String(style["font"] ?? "");
2522
+ if (!raw)
2523
+ return fallback;
2524
+ loadFont(raw);
2525
+ return resolveFont(raw);
2526
+ }
2527
+ // ── SVG text helpers ──────────────────────────────────────────────────────
2528
+ /**
2529
+ * Single-line SVG text element.
2530
+ *
2531
+ * | param | maps to SVG attr |
2532
+ * |---------------|--------------------------|
2533
+ * txt | textContent |
2534
+ * x, y | x, y |
2535
+ * sz | font-size |
2536
+ * wt | font-weight |
2537
+ * col | fill |
2538
+ * anchor | text-anchor |
2539
+ * font | font-family |
2540
+ * letterSpacing | letter-spacing |
2541
+ */
2542
+ function mkText(txt, x, y, sz = 14, wt = 500, col = "#1a1208", anchor = "middle", font, letterSpacing) {
2543
+ const t = se("text");
2544
+ t.setAttribute("x", String(x));
2545
+ t.setAttribute("y", String(y));
2546
+ t.setAttribute("text-anchor", anchor);
2547
+ t.setAttribute("dominant-baseline", "middle");
2548
+ t.setAttribute("font-family", font ?? "var(--font-sans, system-ui, sans-serif)");
2549
+ t.setAttribute("font-size", String(sz));
2550
+ t.setAttribute("font-weight", String(wt));
2551
+ t.setAttribute("fill", col);
2552
+ t.setAttribute("pointer-events", "none");
2553
+ t.setAttribute("user-select", "none");
2554
+ if (letterSpacing != null)
2555
+ t.setAttribute("letter-spacing", String(letterSpacing));
2556
+ t.textContent = txt;
2557
+ return t;
2558
+ }
2559
+ /**
2560
+ * Multi-line SVG text element using <tspan> per line.
2561
+ *
2562
+ * | param | maps to SVG attr |
2563
+ * |---------------|--------------------------|
2564
+ * lines | one <tspan> each |
2565
+ * x | tspan x |
2566
+ * cy | vertical centre of block |
2567
+ * sz | font-size |
2568
+ * wt | font-weight |
2569
+ * col | fill |
2570
+ * anchor | text-anchor |
2571
+ * lineH | dy between tspans (px) |
2572
+ * font | font-family |
2573
+ * letterSpacing | letter-spacing |
2574
+ */
2575
+ function mkMultilineText(lines, x, cy, sz = 14, wt = 500, col = "#1a1208", anchor = "middle", lineH = 18, font, letterSpacing) {
2429
2576
  const t = se("text");
2430
2577
  t.setAttribute("text-anchor", anchor);
2431
- t.setAttribute("font-family", "var(--font-sans, system-ui, sans-serif)");
2578
+ t.setAttribute("font-family", font ?? "var(--font-sans, system-ui, sans-serif)");
2432
2579
  t.setAttribute("font-size", String(sz));
2433
2580
  t.setAttribute("font-weight", String(wt));
2434
2581
  t.setAttribute("fill", col);
2435
2582
  t.setAttribute("pointer-events", "none");
2436
2583
  t.setAttribute("user-select", "none");
2584
+ if (letterSpacing != null)
2585
+ t.setAttribute("letter-spacing", String(letterSpacing));
2437
2586
  // vertically centre the whole block
2438
2587
  const totalH = (lines.length - 1) * lineH;
2439
2588
  const startY = cy - totalH / 2;
@@ -2447,21 +2596,6 @@ sz = 14, wt = 500, col = "#1a1208", anchor = "middle", lineH = 18) {
2447
2596
  });
2448
2597
  return t;
2449
2598
  }
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
2599
  function mkGroup(id, cls) {
2466
2600
  const g = se("g");
2467
2601
  if (id)
@@ -2470,7 +2604,7 @@ function mkGroup(id, cls) {
2470
2604
  g.setAttribute("class", cls);
2471
2605
  return g;
2472
2606
  }
2473
- // ── Arrow direction from connector ────────────────────────
2607
+ // ── Arrow direction from connector ────────────────────────────────────────
2474
2608
  function connMeta$1(connector) {
2475
2609
  if (connector === "--")
2476
2610
  return { arrowAt: "none", dashed: false };
@@ -2485,7 +2619,7 @@ function connMeta$1(connector) {
2485
2619
  return { arrowAt: "start", dashed };
2486
2620
  return { arrowAt: "end", dashed };
2487
2621
  }
2488
- // ── Generic rect connection point ─────────────────────────
2622
+ // ── Generic rect connection point ─────────────────────────────────────────
2489
2623
  function rectConnPoint$1(rx, ry, rw, rh, ox, oy) {
2490
2624
  const cx = rx + rw / 2, cy = ry + rh / 2;
2491
2625
  const dx = ox - cx, dy = oy - cy;
@@ -2510,7 +2644,7 @@ function getConnPoint$1(src, dstCX, dstCY) {
2510
2644
  }
2511
2645
  return rectConnPoint$1(src.x, src.y, src.w, src.h, dstCX, dstCY);
2512
2646
  }
2513
- // ── Group depth (for paint order) ─────────────────────────
2647
+ // ── Group depth (for paint order) ─────────────────────────────────────────
2514
2648
  function groupDepth$1(g, gm) {
2515
2649
  let d = 0;
2516
2650
  let cur = g;
@@ -2520,7 +2654,7 @@ function groupDepth$1(g, gm) {
2520
2654
  }
2521
2655
  return d;
2522
2656
  }
2523
- // ── Node shapes ───────────────────────────────────────────
2657
+ // ── Node shapes ───────────────────────────────────────────────────────────
2524
2658
  function renderShape$1(rc, n, palette) {
2525
2659
  const s = n.style ?? {};
2526
2660
  const fill = String(s.fill ?? palette.nodeFill);
@@ -2593,19 +2727,18 @@ function renderShape$1(rc, n, palette) {
2593
2727
  return [];
2594
2728
  case "image": {
2595
2729
  if (n.imageUrl) {
2596
- const img = document.createElementNS("http://www.w3.org/2000/svg", "image");
2730
+ const img = document.createElementNS(NS, "image");
2597
2731
  img.setAttribute("href", n.imageUrl);
2598
2732
  img.setAttribute("x", String(n.x + 1));
2599
2733
  img.setAttribute("y", String(n.y + 1));
2600
2734
  img.setAttribute("width", String(n.w - 2));
2601
2735
  img.setAttribute("height", String(n.h - 2));
2602
2736
  img.setAttribute("preserveAspectRatio", "xMidYMid meet");
2603
- // optional: clip to rounded rect
2604
2737
  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");
2738
+ const defs = document.createElementNS(NS, "defs");
2739
+ const clip = document.createElementNS(NS, "clipPath");
2607
2740
  clip.setAttribute("id", clipId);
2608
- const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
2741
+ const rect = document.createElementNS(NS, "rect");
2609
2742
  rect.setAttribute("x", String(n.x + 1));
2610
2743
  rect.setAttribute("y", String(n.y + 1));
2611
2744
  rect.setAttribute("width", String(n.w - 2));
@@ -2614,15 +2747,12 @@ function renderShape$1(rc, n, palette) {
2614
2747
  clip.appendChild(rect);
2615
2748
  defs.appendChild(clip);
2616
2749
  img.setAttribute("clip-path", `url(#${clipId})`);
2617
- // border box drawn on top
2618
2750
  const border = rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
2619
2751
  ...opts,
2620
2752
  fill: "none",
2621
- fillStyle: "solid",
2622
2753
  });
2623
2754
  return [defs, img, border];
2624
2755
  }
2625
- // fallback: no URL → grey placeholder box
2626
2756
  return [
2627
2757
  rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
2628
2758
  ...opts,
@@ -2635,7 +2765,7 @@ function renderShape$1(rc, n, palette) {
2635
2765
  return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts)];
2636
2766
  }
2637
2767
  }
2638
- // ── Arrowhead ─────────────────────────────────────────────
2768
+ // ── Arrowhead ─────────────────────────────────────────────────────────────
2639
2769
  function arrowHead(rc, x, y, angle, col, seed) {
2640
2770
  const as = 12;
2641
2771
  return rc.polygon([
@@ -2659,14 +2789,22 @@ function arrowHead(rc, x, y, angle, col, seed) {
2659
2789
  }
2660
2790
  function renderToSVG(sg, container, options = {}) {
2661
2791
  if (typeof rough === "undefined") {
2662
- throw new Error('rough.js is not loaded. Add <script src="https://unpkg.com/roughjs/bundled/rough.js"></script>');
2792
+ throw new Error("rough.js is not loaded.");
2663
2793
  }
2664
2794
  const isDark = options.theme === "dark" ||
2665
2795
  (options.theme === "auto" &&
2666
2796
  window.matchMedia?.("(prefers-color-scheme:dark)").matches);
2667
- // Resolve palette: DSL config takes priority, then options.theme, then light
2668
2797
  const themeName = String(sg.config[THEME_CONFIG_KEY] ?? (isDark ? "dark" : "light"));
2669
2798
  const palette = resolvePalette(themeName);
2799
+ // ── Diagram-level font ──────────────────────────────────
2800
+ const diagramFont = (() => {
2801
+ const raw = String(sg.config["font"] ?? "");
2802
+ if (raw) {
2803
+ loadFont(raw);
2804
+ return resolveFont(raw);
2805
+ }
2806
+ return DEFAULT_FONT;
2807
+ })();
2670
2808
  BASE_ROUGH.roughness = options.roughness ?? 1.3;
2671
2809
  BASE_ROUGH.bowing = options.bowing ?? 0.7;
2672
2810
  let svg;
@@ -2683,14 +2821,7 @@ function renderToSVG(sg, container, options = {}) {
2683
2821
  svg.setAttribute("height", String(sg.height));
2684
2822
  svg.setAttribute("viewBox", `0 0 ${sg.width} ${sg.height}`);
2685
2823
  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);
2824
+ // ── Background ─────────────────────────────────────────
2694
2825
  if (!options.transparent) {
2695
2826
  const bgRect = se("rect");
2696
2827
  bgRect.setAttribute("x", "0");
@@ -2706,9 +2837,9 @@ function renderToSVG(sg, container, options = {}) {
2706
2837
  const titleColor = String(sg.config["title-color"] ?? palette.titleText);
2707
2838
  const titleSize = Number(sg.config["title-size"] ?? 18);
2708
2839
  const titleWeight = Number(sg.config["title-weight"] ?? 600);
2709
- svg.appendChild(mkText(sg.title, sg.width / 2, 26, titleSize, titleWeight, titleColor));
2840
+ svg.appendChild(mkText(sg.title, sg.width / 2, 26, titleSize, titleWeight, titleColor, "middle", diagramFont));
2710
2841
  }
2711
- // ── Groups (depth-sorted: outermost first) ────────────────
2842
+ // ── Groups ───────────────────────────────────────────────
2712
2843
  const gmMap = new Map(sg.groups.map((g) => [g.id, g]));
2713
2844
  const sortedGroups = [...sg.groups].sort((a, b) => groupDepth$1(a, gmMap) - groupDepth$1(b, gmMap));
2714
2845
  const GL = mkGroup("grp-layer");
@@ -2728,8 +2859,14 @@ function renderToSVG(sg, container, options = {}) {
2728
2859
  strokeWidth: Number(gs.strokeWidth ?? 1.2),
2729
2860
  strokeLineDash: gs.strokeDash ?? palette.groupDash,
2730
2861
  }));
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"));
2862
+ // ── Group label typography ──────────────────────────
2863
+ // supports: font, font-size, letter-spacing
2864
+ // always left-anchored (single line)
2865
+ const gLabelColor = gs.color ? String(gs.color) : palette.groupLabel;
2866
+ const gFontSize = Number(gs.fontSize ?? 12);
2867
+ const gFont = resolveStyleFont$1(gs, diagramFont);
2868
+ const gLetterSpacing = gs.letterSpacing;
2869
+ gg.appendChild(mkText(g.label, g.x + 14, g.y + 14, gFontSize, 500, gLabelColor, "start", gFont, gLetterSpacing));
2733
2870
  GL.appendChild(gg);
2734
2871
  }
2735
2872
  svg.appendChild(GL);
@@ -2783,7 +2920,13 @@ function renderToSVG(sg, container, options = {}) {
2783
2920
  bg.setAttribute("rx", "3");
2784
2921
  bg.setAttribute("opacity", "0.9");
2785
2922
  eg.appendChild(bg);
2786
- eg.appendChild(mkText(e.label, mx, my, 11, 400, palette.edgeLabelText));
2923
+ // ── Edge label typography ───────────────────────
2924
+ // supports: font, font-size, letter-spacing
2925
+ // always center-anchored (single line floating on edge)
2926
+ const eFontSize = Number(e.style?.fontSize ?? 11);
2927
+ const eFont = resolveStyleFont$1(e.style ?? {}, diagramFont);
2928
+ const eLetterSpacing = e.style?.letterSpacing;
2929
+ eg.appendChild(mkText(e.label, mx, my, eFontSize, 400, palette.edgeLabelText, "middle", eFont, eLetterSpacing));
2787
2930
  }
2788
2931
  EL.appendChild(eg);
2789
2932
  }
@@ -2793,14 +2936,43 @@ function renderToSVG(sg, container, options = {}) {
2793
2936
  for (const n of sg.nodes) {
2794
2937
  const ng = mkGroup(`node-${n.id}`, "ng");
2795
2938
  renderShape$1(rc, n, palette).forEach((s) => ng.appendChild(s));
2939
+ // ── Node / text typography ─────────────────────────
2940
+ // supports: font, font-size, letter-spacing, text-align, line-height
2796
2941
  const fontSize = Number(n.style?.fontSize ?? (n.shape === "text" ? 13 : 14));
2797
2942
  const fontWeight = n.style?.fontWeight ?? (n.shape === "text" ? 400 : 500);
2943
+ const textColor = String(n.style?.color ??
2944
+ (n.shape === "text" ? palette.edgeLabelText : palette.nodeText));
2945
+ const nodeFont = resolveStyleFont$1(n.style ?? {}, diagramFont);
2946
+ const textAlign = String(n.style?.textAlign ?? "center");
2947
+ const anchorMap = {
2948
+ left: "start",
2949
+ center: "middle",
2950
+ right: "end",
2951
+ };
2952
+ const textAnchor = anchorMap[textAlign] ?? "middle";
2953
+ // line-height is a multiplier (e.g. 1.4 = 140% of font-size)
2954
+ const lineHeight = Number(n.style?.lineHeight ?? 1.3) * fontSize;
2955
+ const letterSpacing = n.style?.letterSpacing;
2956
+ // x shifts for left / right alignment
2957
+ const textX = textAlign === "left"
2958
+ ? n.x + 8
2959
+ : textAlign === "right"
2960
+ ? n.x + n.w - 8
2961
+ : n.x + n.w / 2;
2798
2962
  const lines = n.label.split("\n");
2963
+ const verticalAlign = String(n.style?.verticalAlign ?? "middle");
2964
+ const nodeBodyTop = n.y + 6;
2965
+ const nodeBodyBottom = n.y + n.h - 6;
2966
+ const nodeBodyMid = n.y + n.h / 2;
2967
+ const blockH = (lines.length - 1) * lineHeight;
2968
+ const textCY = verticalAlign === "top"
2969
+ ? nodeBodyTop + blockH / 2
2970
+ : verticalAlign === "bottom"
2971
+ ? nodeBodyBottom - blockH / 2
2972
+ : nodeBodyMid;
2799
2973
  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))));
2974
+ ? mkMultilineText(lines, textX, textCY, fontSize, fontWeight, textColor, textAnchor, lineHeight, nodeFont, letterSpacing)
2975
+ : mkText(n.label, textX, textCY, fontSize, fontWeight, textColor, textAnchor, nodeFont, letterSpacing));
2804
2976
  if (options.interactive) {
2805
2977
  ng.style.cursor = "pointer";
2806
2978
  ng.addEventListener("click", () => options.onNodeClick?.(n.id));
@@ -2826,7 +2998,12 @@ function renderToSVG(sg, container, options = {}) {
2826
2998
  const hdrText = String(gs.color ?? palette.tableHeaderText);
2827
2999
  const divCol = palette.tableDivider;
2828
3000
  const pad = t.labelH;
2829
- // Outer border
3001
+ // ── Table-level font (applies to label + all cells) ─
3002
+ // supports: font, font-size, letter-spacing
3003
+ const tFontSize = Number(gs.fontSize ?? 12);
3004
+ const tFont = resolveStyleFont$1(gs, diagramFont);
3005
+ const tLetterSpacing = gs.letterSpacing;
3006
+ // outer border
2830
3007
  tg.appendChild(rc.rectangle(t.x, t.y, t.w, t.h, {
2831
3008
  ...BASE_ROUGH,
2832
3009
  seed: hashStr$3(t.id),
@@ -2835,20 +3012,19 @@ function renderToSVG(sg, container, options = {}) {
2835
3012
  stroke: strk,
2836
3013
  strokeWidth: 1.5,
2837
3014
  }));
2838
- // Label strip separator
3015
+ // label strip separator
2839
3016
  tg.appendChild(rc.line(t.x, t.y + pad, t.x + t.w, t.y + pad, {
2840
3017
  roughness: 0.6,
2841
3018
  seed: hashStr$3(t.id + "l"),
2842
3019
  stroke: strk,
2843
3020
  strokeWidth: 1,
2844
3021
  }));
2845
- // Label text
2846
- tg.appendChild(mkText(t.label, t.x + 10, t.y + pad / 2, 12, 500, textCol, "start"));
2847
- // Rows
3022
+ // ── Table label: font, font-size, letter-spacing (always left) ──
3023
+ tg.appendChild(mkText(t.label, t.x + 10, t.y + pad / 2, tFontSize, 500, textCol, "start", tFont, tLetterSpacing));
3024
+ // rows
2848
3025
  let rowY = t.y + pad;
2849
3026
  for (const row of t.rows) {
2850
3027
  const rh = row.kind === "header" ? t.headerH : t.rowH;
2851
- // Header background fill
2852
3028
  if (row.kind === "header") {
2853
3029
  const hdrBg = se("rect");
2854
3030
  hdrBg.setAttribute("x", String(t.x + 1));
@@ -2858,19 +3034,34 @@ function renderToSVG(sg, container, options = {}) {
2858
3034
  hdrBg.setAttribute("fill", hdrFill);
2859
3035
  tg.appendChild(hdrBg);
2860
3036
  }
2861
- // Row separator
2862
3037
  tg.appendChild(rc.line(t.x, rowY + rh, t.x + t.w, rowY + rh, {
2863
3038
  roughness: 0.4,
2864
3039
  seed: hashStr$3(t.id + rowY),
2865
3040
  stroke: row.kind === "header" ? strk : divCol,
2866
3041
  strokeWidth: row.kind === "header" ? 1.2 : 0.6,
2867
3042
  }));
2868
- // Cell text + col separators
3043
+ // ── Cell text: font, font-size, letter-spacing, text-align ──
3044
+ // text-align applies to data rows; header is always centered
3045
+ const cellAlignProp = row.kind === "header" ? "center" : String(gs.textAlign ?? "center");
3046
+ const cellAnchorMap = {
3047
+ left: "start",
3048
+ center: "middle",
3049
+ right: "end",
3050
+ };
3051
+ const cellAnchor = cellAnchorMap[cellAlignProp] ?? "middle";
3052
+ const cellFw = row.kind === "header" ? 600 : 400;
3053
+ const cellColor = row.kind === "header" ? hdrText : textCol;
2869
3054
  let cx = t.x;
2870
3055
  row.cells.forEach((cell, i) => {
2871
3056
  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));
3057
+ // x position shifts with alignment
3058
+ const cellX = cellAnchor === "start"
3059
+ ? cx + 6
3060
+ : cellAnchor === "end"
3061
+ ? cx + cw - 6
3062
+ : cx + cw / 2;
3063
+ // ← was missing tg.appendChild — cells were invisible before
3064
+ tg.appendChild(mkText(cell, cellX, rowY + rh / 2, tFontSize, cellFw, cellColor, cellAnchor, tFont, tLetterSpacing));
2874
3065
  if (i < row.cells.length - 1) {
2875
3066
  tg.appendChild(rc.line(cx + cw, t.y + pad, cx + cw, t.y + t.h, {
2876
3067
  roughness: 0.3,
@@ -2899,6 +3090,25 @@ function renderToSVG(sg, container, options = {}) {
2899
3090
  const strk = String(gs.stroke ?? palette.noteStroke);
2900
3091
  const fold = 14;
2901
3092
  const { x, y, w, h } = n;
3093
+ // ── Note typography ─────────────────────────────────
3094
+ // supports: font, font-size, letter-spacing, text-align, line-height
3095
+ const nFontSize = Number(gs.fontSize ?? 12);
3096
+ const nFont = resolveStyleFont$1(gs, diagramFont);
3097
+ const nLetterSpacing = gs.letterSpacing;
3098
+ const nLineHeight = Number(gs.lineHeight ?? 1.4) * nFontSize;
3099
+ const nTextAlign = String(gs.textAlign ?? "left");
3100
+ const nAnchorMap = {
3101
+ left: "start",
3102
+ center: "middle",
3103
+ right: "end",
3104
+ };
3105
+ const nAnchor = nAnchorMap[nTextAlign] ?? "start";
3106
+ // x position for the text block (pad from left, with alignment)
3107
+ const nTextX = nTextAlign === "right"
3108
+ ? x + w - fold - 6
3109
+ : nTextAlign === "center"
3110
+ ? x + (w - fold) / 2
3111
+ : x + 12;
2902
3112
  ng.appendChild(rc.polygon([
2903
3113
  [x, y],
2904
3114
  [x + w - fold, y],
@@ -2925,9 +3135,24 @@ function renderToSVG(sg, container, options = {}) {
2925
3135
  stroke: strk,
2926
3136
  strokeWidth: 0.8,
2927
3137
  }));
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
- });
3138
+ const nVerticalAlign = String(gs.verticalAlign ?? "top");
3139
+ const bodyTop = y + fold + 8; // below the fold triangle
3140
+ const bodyBottom = y + h - 8; // above bottom edge
3141
+ const bodyMid = (bodyTop + bodyBottom) / 2;
3142
+ const blockH = (n.lines.length - 1) * nLineHeight;
3143
+ const blockCY = nVerticalAlign === "bottom"
3144
+ ? bodyBottom - blockH / 2
3145
+ : nVerticalAlign === "middle"
3146
+ ? bodyMid
3147
+ : bodyTop + blockH / 2;
3148
+ // multiline: use mkMultilineText so line-height is respected
3149
+ if (n.lines.length > 1) {
3150
+ // vertical centre of the text block inside the note
3151
+ ng.appendChild(mkMultilineText(n.lines, nTextX, blockCY, nFontSize, 400, String(gs.color ?? palette.noteText), nAnchor, nLineHeight, nFont, nLetterSpacing));
3152
+ }
3153
+ else {
3154
+ ng.appendChild(mkText(n.lines[0] ?? "", nTextX, blockCY, nFontSize, 400, String(gs.color ?? palette.noteText), nAnchor, nFont, nLetterSpacing));
3155
+ }
2931
3156
  NoteL.appendChild(ng);
2932
3157
  }
2933
3158
  svg.appendChild(NoteL);
@@ -3204,22 +3429,69 @@ function hashStr$1(s) {
3204
3429
  h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
3205
3430
  return h;
3206
3431
  }
3207
- // ── Arrow direction from connector (mirrors svg/index.ts)
3432
+ // ── Small helper: load + resolve font from a style map ────────────────────
3433
+ function resolveStyleFont(style, fallback) {
3434
+ const raw = String(style['font'] ?? '');
3435
+ if (!raw)
3436
+ return fallback;
3437
+ loadFont(raw);
3438
+ return resolveFont(raw);
3439
+ }
3440
+ // ── Canvas text helpers ────────────────────────────────────────────────────
3441
+ /**
3442
+ * Draw a single line of text.
3443
+ * align: 'left' | 'center' | 'right' (maps to ctx.textAlign)
3444
+ */
3445
+ function drawText(ctx, txt, x, y, sz = 14, wt = 500, col = '#1a1208', align = 'center', font = 'system-ui, sans-serif', letterSpacing) {
3446
+ ctx.save();
3447
+ ctx.font = `${wt} ${sz}px ${font}`;
3448
+ ctx.fillStyle = col;
3449
+ ctx.textAlign = align;
3450
+ ctx.textBaseline = 'middle';
3451
+ if (letterSpacing) {
3452
+ // Canvas has no native letter-spacing — draw char by char
3453
+ const chars = txt.split('');
3454
+ const totalW = ctx.measureText(txt).width + letterSpacing * (chars.length - 1);
3455
+ let startX = align === 'center' ? x - totalW / 2
3456
+ : align === 'right' ? x - totalW
3457
+ : x;
3458
+ ctx.textAlign = 'left';
3459
+ for (const ch of chars) {
3460
+ ctx.fillText(ch, startX, y);
3461
+ startX += ctx.measureText(ch).width + letterSpacing;
3462
+ }
3463
+ }
3464
+ else {
3465
+ ctx.fillText(txt, x, y);
3466
+ }
3467
+ ctx.restore();
3468
+ }
3469
+ /**
3470
+ * Draw multiple lines of text, vertically centred around cy.
3471
+ */
3472
+ function drawMultilineText(ctx, lines, x, cy, sz = 14, wt = 500, col = '#1a1208', align = 'center', lineH = 18, font = 'system-ui, sans-serif', letterSpacing) {
3473
+ const totalH = (lines.length - 1) * lineH;
3474
+ const startY = cy - totalH / 2;
3475
+ lines.forEach((line, i) => {
3476
+ drawText(ctx, line, x, startY + i * lineH, sz, wt, col, align, font, letterSpacing);
3477
+ });
3478
+ }
3479
+ // ── Arrow direction ────────────────────────────────────────────────────────
3208
3480
  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(">");
3481
+ if (connector === '--')
3482
+ return { arrowAt: 'none', dashed: false };
3483
+ if (connector === '---')
3484
+ return { arrowAt: 'none', dashed: true };
3485
+ const bidir = connector.includes('<') && connector.includes('>');
3214
3486
  if (bidir)
3215
- return { arrowAt: "both", dashed: connector.includes("--") };
3216
- const back = connector.startsWith("<");
3217
- const dashed = connector.includes("--");
3487
+ return { arrowAt: 'both', dashed: connector.includes('--') };
3488
+ const back = connector.startsWith('<');
3489
+ const dashed = connector.includes('--');
3218
3490
  if (back)
3219
- return { arrowAt: "start", dashed };
3220
- return { arrowAt: "end", dashed };
3491
+ return { arrowAt: 'start', dashed };
3492
+ return { arrowAt: 'end', dashed };
3221
3493
  }
3222
- // ── Generic rect connection point ─────────────────────────
3494
+ // ── Rect connection point ──────────────────────────────────────────────────
3223
3495
  function rectConnPoint(rx, ry, rw, rh, ox, oy) {
3224
3496
  const cx = rx + rw / 2, cy = ry + rh / 2;
3225
3497
  const dx = ox - cx, dy = oy - cy;
@@ -3232,19 +3504,16 @@ function rectConnPoint(rx, ry, rw, rh, ox, oy) {
3232
3504
  return [cx + t * dx, cy + t * dy];
3233
3505
  }
3234
3506
  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);
3507
+ return nm.get(id) ?? tm.get(id) ?? gm.get(id) ?? cm.get(id) ?? ntm.get(id) ?? null;
3236
3508
  }
3237
3509
  function getConnPoint(src, dstCX, dstCY) {
3238
- if ("shape" in src && src.shape) {
3510
+ if ('shape' in src && src.shape) {
3239
3511
  return connPoint(src, {
3240
- x: dstCX - 1,
3241
- y: dstCY - 1,
3242
- w: 2,
3243
- h: 2});
3512
+ x: dstCX - 1, y: dstCY - 1, w: 2, h: 2});
3244
3513
  }
3245
3514
  return rectConnPoint(src.x, src.y, src.w, src.h, dstCX, dstCY);
3246
3515
  }
3247
- // ── Group depth (for paint order, outermost first) ────────
3516
+ // ── Group depth ────────────────────────────────────────────────────────────
3248
3517
  function groupDepth(g, gm) {
3249
3518
  let d = 0;
3250
3519
  let cur = g;
@@ -3254,80 +3523,57 @@ function groupDepth(g, gm) {
3254
3523
  }
3255
3524
  return d;
3256
3525
  }
3257
- // ── Node shapes ───────────────────────────────────────────
3526
+ // ── Node shapes ────────────────────────────────────────────────────────────
3258
3527
  function renderShape(rc, ctx, n, palette, R) {
3259
3528
  const s = n.style ?? {};
3260
3529
  const fill = String(s.fill ?? palette.nodeFill);
3261
3530
  const stroke = String(s.stroke ?? palette.nodeStroke);
3262
3531
  const opts = {
3263
- ...R,
3264
- seed: hashStr$1(n.id),
3265
- fill,
3266
- fillStyle: "solid",
3267
- stroke,
3268
- strokeWidth: Number(s.strokeWidth ?? 1.9),
3532
+ ...R, seed: hashStr$1(n.id),
3533
+ fill, fillStyle: 'solid',
3534
+ stroke, strokeWidth: Number(s.strokeWidth ?? 1.9),
3269
3535
  };
3270
3536
  const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
3271
3537
  const hw = n.w / 2 - 2;
3272
3538
  switch (n.shape) {
3273
- case "circle":
3539
+ case 'circle':
3274
3540
  rc.ellipse(cx, cy, n.w * 0.88, n.h * 0.88, opts);
3275
3541
  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);
3542
+ case 'diamond':
3543
+ rc.polygon([[cx, n.y + 2], [cx + hw, cy], [cx, n.y + n.h - 2], [cx - hw, cy]], opts);
3283
3544
  break;
3284
- case "hexagon": {
3545
+ case 'hexagon': {
3285
3546
  const hw2 = hw * 0.56;
3286
3547
  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],
3548
+ [cx - hw2, n.y + 3], [cx + hw2, n.y + 3], [cx + hw, cy],
3549
+ [cx + hw2, n.y + n.h - 3], [cx - hw2, n.y + n.h - 3], [cx - hw, cy],
3293
3550
  ], opts);
3294
3551
  break;
3295
3552
  }
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);
3553
+ case 'triangle':
3554
+ 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
3555
  break;
3303
- case "cylinder": {
3556
+ case 'cylinder': {
3304
3557
  const eH = 18;
3305
3558
  rc.rectangle(n.x + 3, n.y + eH / 2, n.w - 6, n.h - eH, opts);
3306
3559
  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
- });
3560
+ rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6, fill: 'none' });
3312
3561
  break;
3313
3562
  }
3314
- case "parallelogram":
3563
+ case 'parallelogram':
3315
3564
  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],
3565
+ [n.x + 18, n.y + 1], [n.x + n.w - 1, n.y + 1],
3566
+ [n.x + n.w - 18, n.y + n.h - 1], [n.x + 1, n.y + n.h - 1],
3320
3567
  ], opts);
3321
3568
  break;
3322
- case "text":
3323
- break; // text nodes: no background shape
3324
- case "image": {
3569
+ case 'text':
3570
+ break;
3571
+ case 'image': {
3325
3572
  if (n.imageUrl) {
3326
3573
  const img = new Image();
3327
- img.crossOrigin = "anonymous";
3574
+ img.crossOrigin = 'anonymous';
3328
3575
  img.onload = () => {
3329
3576
  ctx.save();
3330
- // rounded clip
3331
3577
  ctx.beginPath();
3332
3578
  const r = 6;
3333
3579
  ctx.moveTo(n.x + r, n.y);
@@ -3343,21 +3589,12 @@ function renderShape(rc, ctx, n, palette, R) {
3343
3589
  ctx.clip();
3344
3590
  ctx.drawImage(img, n.x + 1, n.y + 1, n.w - 2, n.h - 2);
3345
3591
  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
- });
3592
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: 'none' });
3351
3593
  };
3352
3594
  img.src = n.imageUrl;
3353
3595
  }
3354
3596
  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
- });
3597
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: '#e0e0e0', stroke: '#999999' });
3361
3598
  }
3362
3599
  return;
3363
3600
  }
@@ -3366,57 +3603,52 @@ function renderShape(rc, ctx, n, palette, R) {
3366
3603
  break;
3367
3604
  }
3368
3605
  }
3369
- // ── Arrowhead ─────────────────────────────────────────────
3606
+ // ── Arrowhead ─────────────────────────────────────────────────────────────
3370
3607
  function drawArrowHead(rc, x, y, angle, col, seed, R) {
3371
3608
  const as = 12;
3372
3609
  rc.polygon([
3373
3610
  [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
- });
3611
+ [x - as * Math.cos(angle - Math.PI / 6.5), y - as * Math.sin(angle - Math.PI / 6.5)],
3612
+ [x - as * Math.cos(angle + Math.PI / 6.5), y - as * Math.sin(angle + Math.PI / 6.5)],
3613
+ ], { roughness: 0.3, seed, fill: col, fillStyle: 'solid', stroke: col, strokeWidth: 0.8 });
3390
3614
  }
3615
+ // ── Public API ─────────────────────────────────────────────────────────────
3391
3616
  function renderToCanvas(sg, canvas, options = {}) {
3392
- if (typeof rough === "undefined")
3393
- throw new Error("rough.js not loaded");
3617
+ if (typeof rough === 'undefined')
3618
+ throw new Error('rough.js not loaded');
3394
3619
  const scale = options.scale ?? window.devicePixelRatio ?? 1;
3395
3620
  canvas.width = sg.width * scale;
3396
3621
  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");
3622
+ canvas.style.width = sg.width + 'px';
3623
+ canvas.style.height = sg.height + 'px';
3624
+ const ctx = canvas.getContext('2d');
3400
3625
  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"));
3626
+ // ── Palette ──────────────────────────────────────────────
3627
+ const isDark = options.theme === 'dark' ||
3628
+ (options.theme === 'auto' &&
3629
+ typeof window !== 'undefined' &&
3630
+ window.matchMedia?.('(prefers-color-scheme:dark)').matches);
3631
+ const themeName = String(sg.config[THEME_CONFIG_KEY] ?? (isDark ? 'dark' : 'light'));
3409
3632
  const palette = resolvePalette(themeName);
3633
+ // ── Diagram-level font ───────────────────────────────────
3634
+ const diagramFont = (() => {
3635
+ const raw = String(sg.config['font'] ?? '');
3636
+ if (raw) {
3637
+ loadFont(raw);
3638
+ return resolveFont(raw);
3639
+ }
3640
+ return DEFAULT_FONT;
3641
+ })();
3642
+ // ── Background ───────────────────────────────────────────
3410
3643
  if (!options.transparent) {
3411
3644
  ctx.fillStyle = options.background ?? palette.background;
3412
3645
  ctx.fillRect(0, 0, sg.width, sg.height);
3413
3646
  }
3647
+ else {
3648
+ ctx.clearRect(0, 0, sg.width, sg.height);
3649
+ }
3414
3650
  const rc = rough.canvas(canvas);
3415
- const R = {
3416
- roughness: options.roughness ?? 1.3,
3417
- bowing: options.bowing ?? 0.7,
3418
- };
3419
- // ── Lookup maps ──────────────────────────────────────────
3651
+ const R = { roughness: options.roughness ?? 1.3, bowing: options.bowing ?? 0.7 };
3420
3652
  const nm = nodeMap(sg);
3421
3653
  const tm = tableMap(sg);
3422
3654
  const gm = groupMap(sg);
@@ -3424,36 +3656,30 @@ function renderToCanvas(sg, canvas, options = {}) {
3424
3656
  const ntm = noteMap(sg);
3425
3657
  // ── Title ────────────────────────────────────────────────
3426
3658
  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();
3659
+ const titleSize = Number(sg.config['title-size'] ?? 18);
3660
+ drawText(ctx, sg.title, sg.width / 2, 28, titleSize, 600, palette.titleText, 'center', diagramFont);
3433
3661
  }
3434
- // ── Groups (depth-sorted: outermost first) ────────────────
3662
+ // ── Groups (outermost first) ─────────────────────────────
3435
3663
  const sortedGroups = [...sg.groups].sort((a, b) => groupDepth(a, gm) - groupDepth(b, gm));
3436
3664
  for (const g of sortedGroups) {
3437
3665
  if (!g.w)
3438
3666
  continue;
3439
3667
  const gs = g.style ?? {};
3440
3668
  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),
3669
+ ...R, roughness: 1.7, bowing: 0.4, seed: hashStr$1(g.id),
3445
3670
  fill: String(gs.fill ?? palette.groupFill),
3446
- fillStyle: "solid",
3671
+ fillStyle: 'solid',
3447
3672
  stroke: String(gs.stroke ?? palette.groupStroke),
3448
3673
  strokeWidth: Number(gs.strokeWidth ?? 1.2),
3449
3674
  strokeLineDash: gs.strokeDash ?? palette.groupDash,
3450
3675
  });
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();
3676
+ // ── Group label: font, font-size, letter-spacing ─────
3677
+ // always left-anchored (single line)
3678
+ const gFontSize = Number(gs.fontSize ?? 12);
3679
+ const gFont = resolveStyleFont(gs, diagramFont);
3680
+ const gLetterSpacing = gs.letterSpacing;
3681
+ const gLabelColor = gs.color ? String(gs.color) : palette.groupLabel;
3682
+ drawText(ctx, g.label, g.x + 14, g.y + 16, gFontSize, 500, gLabelColor, 'left', gFont, gLetterSpacing);
3457
3683
  }
3458
3684
  // ── Edges ─────────────────────────────────────────────────
3459
3685
  for (const e of sg.edges) {
@@ -3470,62 +3696,61 @@ function renderToCanvas(sg, canvas, options = {}) {
3470
3696
  const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) || 1;
3471
3697
  const nx = (x2 - x1) / len, ny = (y2 - y1) / len;
3472
3698
  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;
3699
+ const sx1 = arrowAt === 'start' || arrowAt === 'both' ? x1 + nx * HEAD : x1;
3700
+ const sy1 = arrowAt === 'start' || arrowAt === 'both' ? y1 + ny * HEAD : y1;
3701
+ const sx2 = arrowAt === 'end' || arrowAt === 'both' ? x2 - nx * HEAD : x2;
3702
+ const sy2 = arrowAt === 'end' || arrowAt === 'both' ? y2 - ny * HEAD : y2;
3477
3703
  rc.line(sx1, sy1, sx2, sy2, {
3478
- ...R,
3479
- roughness: 0.9,
3480
- seed: hashStr$1(e.from + e.to),
3704
+ ...R, roughness: 0.9, seed: hashStr$1(e.from + e.to),
3481
3705
  stroke: ecol,
3482
3706
  strokeWidth: Number(e.style?.strokeWidth ?? 1.6),
3483
3707
  ...(dashed ? { strokeLineDash: [6, 5] } : {}),
3484
3708
  });
3485
3709
  const ang = Math.atan2(y2 - y1, x2 - x1);
3486
- if (arrowAt === "end" || arrowAt === "both")
3710
+ if (arrowAt === 'end' || arrowAt === 'both')
3487
3711
  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"));
3712
+ if (arrowAt === 'start' || arrowAt === 'both')
3713
+ drawArrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$1(e.from + 'back'));
3490
3714
  if (e.label) {
3491
3715
  const mx = (x1 + x2) / 2 - ny * 14;
3492
3716
  const my = (y1 + y2) / 2 + nx * 14;
3717
+ // ── Edge label: font, font-size, letter-spacing ──
3718
+ // always center-anchored (single line)
3719
+ const eFontSize = Number(e.style?.fontSize ?? 11);
3720
+ const eFont = resolveStyleFont(e.style ?? {}, diagramFont);
3721
+ const eLetterSpacing = e.style?.letterSpacing;
3493
3722
  ctx.save();
3494
- ctx.font = "400 11px system-ui, sans-serif";
3495
- ctx.textAlign = "center";
3723
+ ctx.font = `400 ${eFontSize}px ${eFont}`;
3496
3724
  const tw = ctx.measureText(e.label).width + 12;
3725
+ ctx.restore();
3497
3726
  ctx.fillStyle = palette.edgeLabelBg;
3498
3727
  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();
3728
+ drawText(ctx, e.label, mx, my + 3, eFontSize, 400, palette.edgeLabelText, 'center', eFont, eLetterSpacing);
3502
3729
  }
3503
3730
  }
3504
3731
  // ── Nodes ─────────────────────────────────────────────────
3505
3732
  for (const n of sg.nodes) {
3506
3733
  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);
3734
+ // ── Node / text typography ─────────────────────────
3735
+ // supports: font, font-size, letter-spacing, text-align, line-height
3736
+ const fontSize = Number(n.style?.fontSize ?? (n.shape === 'text' ? 13 : 14));
3737
+ const fontWeight = n.style?.fontWeight ?? (n.shape === 'text' ? 400 : 500);
3738
+ const textColor = String(n.style?.color ??
3739
+ (n.shape === 'text' ? palette.edgeLabelText : palette.nodeText));
3740
+ const nodeFont = resolveStyleFont(n.style ?? {}, diagramFont);
3741
+ const textAlign = String(n.style?.textAlign ?? 'center');
3742
+ const lineHeight = Number(n.style?.lineHeight ?? 1.3) * fontSize;
3743
+ const letterSpacing = n.style?.letterSpacing;
3744
+ const textX = textAlign === 'left' ? n.x + 8
3745
+ : textAlign === 'right' ? n.x + n.w - 8
3746
+ : n.x + n.w / 2;
3747
+ const lines = n.label.split('\n');
3748
+ if (lines.length > 1) {
3749
+ drawMultilineText(ctx, lines, textX, n.y + n.h / 2, fontSize, fontWeight, textColor, textAlign, lineHeight, nodeFont, letterSpacing);
3520
3750
  }
3521
3751
  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
- });
3752
+ drawText(ctx, n.label, textX, n.y + n.h / 2, fontSize, fontWeight, textColor, textAlign, nodeFont, letterSpacing);
3527
3753
  }
3528
- ctx.restore();
3529
3754
  }
3530
3755
  // ── Tables ────────────────────────────────────────────────
3531
3756
  for (const t of sg.tables) {
@@ -3534,65 +3759,53 @@ function renderToCanvas(sg, canvas, options = {}) {
3534
3759
  const strk = String(gs.stroke ?? palette.tableStroke);
3535
3760
  const textCol = String(gs.color ?? palette.tableText);
3536
3761
  const pad = t.labelH;
3537
- // Outer border
3762
+ // ── Table-level font ────────────────────────────────
3763
+ // supports: font, font-size, letter-spacing (cells also support text-align)
3764
+ const tFontSize = Number(gs.fontSize ?? 12);
3765
+ const tFont = resolveStyleFont(gs, diagramFont);
3766
+ const tLetterSpacing = gs.letterSpacing;
3538
3767
  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,
3768
+ ...R, seed: hashStr$1(t.id),
3769
+ fill, fillStyle: 'solid', stroke: strk, strokeWidth: 1.5,
3545
3770
  });
3546
- // Label strip separator
3547
3771
  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,
3772
+ roughness: 0.6, seed: hashStr$1(t.id + 'l'), stroke: strk, strokeWidth: 1,
3552
3773
  });
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
3774
+ // ── Table label: font, font-size, letter-spacing ────
3775
+ // always left-anchored
3776
+ drawText(ctx, t.label, t.x + 10, t.y + pad / 2, tFontSize, 500, textCol, 'left', tFont, tLetterSpacing);
3562
3777
  let rowY = t.y + pad;
3563
3778
  for (const row of t.rows) {
3564
- const rh = row.kind === "header" ? t.headerH : t.rowH;
3565
- // Header background
3566
- if (row.kind === "header") {
3779
+ const rh = row.kind === 'header' ? t.headerH : t.rowH;
3780
+ if (row.kind === 'header') {
3567
3781
  ctx.fillStyle = palette.tableHeaderFill;
3568
3782
  ctx.fillRect(t.x + 1, rowY + 1, t.w - 2, rh - 1);
3569
3783
  }
3570
- // Row separator
3571
3784
  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,
3785
+ roughness: 0.4, seed: hashStr$1(t.id + rowY),
3786
+ stroke: row.kind === 'header' ? strk : palette.tableDivider,
3787
+ strokeWidth: row.kind === 'header' ? 1.2 : 0.6,
3576
3788
  });
3577
- // Cell text + column separators
3789
+ // ── Cell text: font, font-size, letter-spacing, text-align ──
3790
+ // header always centered; data rows respect gs.textAlign
3791
+ const cellAlignProp = (row.kind === 'header'
3792
+ ? 'center'
3793
+ : String(gs.textAlign ?? 'center'));
3794
+ const cellFw = row.kind === 'header' ? 600 : 400;
3795
+ const cellColor = row.kind === 'header'
3796
+ ? String(gs.color ?? palette.tableHeaderText)
3797
+ : textCol;
3578
3798
  let cx = t.x;
3579
3799
  row.cells.forEach((cell, i) => {
3580
3800
  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();
3801
+ const cellX = cellAlignProp === 'left' ? cx + 6
3802
+ : cellAlignProp === 'right' ? cx + cw - 6
3803
+ : cx + cw / 2;
3804
+ drawText(ctx, cell, cellX, rowY + rh / 2, tFontSize, cellFw, cellColor, cellAlignProp, tFont, tLetterSpacing);
3590
3805
  if (i < row.cells.length - 1) {
3591
3806
  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,
3807
+ roughness: 0.3, seed: hashStr$1(t.id + 'c' + i),
3808
+ stroke: palette.tableDivider, strokeWidth: 0.5,
3596
3809
  });
3597
3810
  }
3598
3811
  cx += cw;
@@ -3607,44 +3820,37 @@ function renderToCanvas(sg, canvas, options = {}) {
3607
3820
  const strk = String(gs.stroke ?? palette.noteStroke);
3608
3821
  const fold = 14;
3609
3822
  const { x, y, w, h } = n;
3610
- // Note body (folded corner polygon)
3611
3823
  rc.polygon([
3612
3824
  [x, y],
3613
3825
  [x + w - fold, y],
3614
3826
  [x + w, y + fold],
3615
3827
  [x + w, y + h],
3616
3828
  [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
3829
+ ], { ...R, seed: hashStr$1(n.id), fill, fillStyle: 'solid', stroke: strk, strokeWidth: 1.2 });
3626
3830
  rc.polygon([
3627
3831
  [x + w - fold, y],
3628
3832
  [x + w, y + fold],
3629
3833
  [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();
3834
+ ], { roughness: 0.4, seed: hashStr$1(n.id + 'f'),
3835
+ fill: palette.noteFold, fillStyle: 'solid', stroke: strk, strokeWidth: 0.8 });
3836
+ // ── Note typography ─────────────────────────────────
3837
+ // supports: font, font-size, letter-spacing, text-align, line-height
3838
+ const nFontSize = Number(gs.fontSize ?? 12);
3839
+ const nFont = resolveStyleFont(gs, diagramFont);
3840
+ const nLetterSpacing = gs.letterSpacing;
3841
+ const nLineHeight = Number(gs.lineHeight ?? 1.4) * nFontSize;
3842
+ const nTextAlign = String(gs.textAlign ?? 'left');
3843
+ const nColor = String(gs.color ?? palette.noteText);
3844
+ const nTextX = nTextAlign === 'right' ? x + w - fold - 6
3845
+ : nTextAlign === 'center' ? x + (w - fold) / 2
3846
+ : x + 12;
3847
+ if (n.lines.length > 1) {
3848
+ const blockCY = y + fold / 2 + (h - fold) / 2;
3849
+ drawMultilineText(ctx, n.lines, nTextX, blockCY, nFontSize, 400, nColor, nTextAlign, nLineHeight, nFont, nLetterSpacing);
3850
+ }
3851
+ else {
3852
+ drawText(ctx, n.lines[0] ?? '', nTextX, y + h / 2, nFontSize, 400, nColor, nTextAlign, nFont, nLetterSpacing);
3853
+ }
3648
3854
  }
3649
3855
  // ── Charts ────────────────────────────────────────────────
3650
3856
  for (const c of sg.charts) {
@@ -3656,19 +3862,19 @@ function renderToCanvas(sg, canvas, options = {}) {
3656
3862
  }, R);
3657
3863
  }
3658
3864
  }
3659
- // ── Export canvas to PNG blob ─────────────────────────────
3865
+ // ── Export helpers ─────────────────────────────────────────────────────────
3660
3866
  function canvasToPNGBlob(canvas) {
3661
3867
  return new Promise((resolve, reject) => {
3662
- canvas.toBlob((blob) => {
3868
+ canvas.toBlob(blob => {
3663
3869
  if (blob)
3664
3870
  resolve(blob);
3665
3871
  else
3666
- reject(new Error("Canvas toBlob failed"));
3667
- }, "image/png");
3872
+ reject(new Error('Canvas toBlob failed'));
3873
+ }, 'image/png');
3668
3874
  });
3669
3875
  }
3670
3876
  function canvasToPNGDataURL(canvas) {
3671
- return canvas.toDataURL("image/png");
3877
+ return canvas.toDataURL('image/png');
3672
3878
  }
3673
3879
 
3674
3880
  // ============================================================
@@ -4669,6 +4875,7 @@ function render(options) {
4669
4875
 
4670
4876
  exports.ANIMATION_CSS = ANIMATION_CSS;
4671
4877
  exports.AnimationController = AnimationController;
4878
+ exports.BUILTIN_FONTS = BUILTIN_FONTS;
4672
4879
  exports.EventEmitter = EventEmitter;
4673
4880
  exports.PALETTES = PALETTES;
4674
4881
  exports.ParseError = ParseError;
@@ -4692,12 +4899,15 @@ exports.hashStr = hashStr;
4692
4899
  exports.layout = layout;
4693
4900
  exports.lerp = lerp;
4694
4901
  exports.listThemes = listThemes;
4902
+ exports.loadFont = loadFont;
4695
4903
  exports.nodeMap = nodeMap;
4696
4904
  exports.parse = parse;
4697
4905
  exports.parseHex = parseHex;
4906
+ exports.registerFont = registerFont;
4698
4907
  exports.render = render;
4699
4908
  exports.renderToCanvas = renderToCanvas;
4700
4909
  exports.renderToSVG = renderToSVG;
4910
+ exports.resolveFont = resolveFont;
4701
4911
  exports.resolvePalette = resolvePalette;
4702
4912
  exports.sleep = sleep;
4703
4913
  exports.svgToPNGDataURL = svgToPNGDataURL;