sketchmark 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -377,6 +377,7 @@ function parse(src, options = {}) {
377
377
  const ast = {
378
378
  kind: "diagram",
379
379
  layout: "column",
380
+ style: {},
380
381
  nodes: [],
381
382
  edges: [],
382
383
  groups: [],
@@ -433,6 +434,54 @@ function parse(src, options = {}) {
433
434
  }
434
435
  return props;
435
436
  }
437
+ function parseConfigValue(value) {
438
+ if (value === "true" || value === "on")
439
+ return true;
440
+ if (value === "false" || value === "off")
441
+ return false;
442
+ const numeric = Number(value);
443
+ return Number.isNaN(numeric) ? value : numeric;
444
+ }
445
+ function applyRootProps(props) {
446
+ const styleProps = propsToStyle(props);
447
+ const styleKeys = new Set([
448
+ "fill",
449
+ "stroke",
450
+ "stroke-width",
451
+ "color",
452
+ "opacity",
453
+ "font-size",
454
+ "font-weight",
455
+ "font",
456
+ "dash",
457
+ "stroke-dash",
458
+ "padding",
459
+ "text-align",
460
+ "vertical-align",
461
+ "line-height",
462
+ "letter-spacing",
463
+ ]);
464
+ ast.style = { ...(ast.style ?? {}), ...styleProps };
465
+ if (props.layout) {
466
+ ast.layout = props.layout;
467
+ }
468
+ if (props.width !== undefined) {
469
+ ast.width = parseFloat(props.width);
470
+ }
471
+ if (props.height !== undefined) {
472
+ ast.height = parseFloat(props.height);
473
+ }
474
+ if (props.font !== undefined) {
475
+ ast.config.font = parseConfigValue(props.font);
476
+ }
477
+ for (const [key, value] of Object.entries(props)) {
478
+ if (key === "layout" || key === "width" || key === "height")
479
+ continue;
480
+ if (styleKeys.has(key))
481
+ continue;
482
+ ast.config[key] = parseConfigValue(value);
483
+ }
484
+ }
436
485
  function parseGroupProps(toks, startIndex) {
437
486
  const props = {};
438
487
  const itemIds = [];
@@ -914,8 +963,12 @@ function parse(src, options = {}) {
914
963
  };
915
964
  }
916
965
  skipNL();
917
- if (cur().value === "diagram")
966
+ if (cur().value === "diagram") {
918
967
  skip();
968
+ const toks = lineTokens();
969
+ const props = parseSimpleProps(toks, 0);
970
+ applyRootProps(props);
971
+ }
919
972
  skipNL();
920
973
  while (cur().type !== "EOF" && cur().value !== "end") {
921
974
  skipNL();
@@ -926,7 +979,7 @@ function parse(src, options = {}) {
926
979
  continue;
927
980
  }
928
981
  if (v === "diagram") {
929
- skip();
982
+ lineTokens();
930
983
  continue;
931
984
  }
932
985
  if (v === "end")
@@ -936,10 +989,7 @@ function parse(src, options = {}) {
936
989
  continue;
937
990
  }
938
991
  if (v === "layout") {
939
- skip();
940
- ast.layout = cur().value ?? "column";
941
- skip();
942
- continue;
992
+ throw new ParseError(`Root layout must be declared on the diagram line, e.g. diagram layout=absolute`, t.line, t.col);
943
993
  }
944
994
  if (v === "title") {
945
995
  skip();
@@ -963,15 +1013,7 @@ function parse(src, options = {}) {
963
1013
  continue;
964
1014
  }
965
1015
  if (v === "config") {
966
- skip();
967
- const key = cur().value;
968
- skip();
969
- if (cur().type === "EQUALS")
970
- skip();
971
- const value = cur().value;
972
- skip();
973
- ast.config[key] = value;
974
- continue;
1016
+ throw new ParseError(`Root config must be declared on the diagram line, e.g. diagram gap=40 margin=0 tts=true`, t.line, t.col);
975
1017
  }
976
1018
  if (v === "style") {
977
1019
  skip();
@@ -3624,6 +3666,7 @@ function buildSceneGraph(ast) {
3624
3666
  title: ast.title,
3625
3667
  description: ast.description,
3626
3668
  layout: ast.layout,
3669
+ style: ast.style ?? {},
3627
3670
  nodes,
3628
3671
  edges,
3629
3672
  groups,
@@ -3636,6 +3679,8 @@ function buildSceneGraph(ast) {
3636
3679
  rootOrder: ast.rootOrder ?? [],
3637
3680
  width: 0,
3638
3681
  height: 0,
3682
+ fixedWidth: ast.width,
3683
+ fixedHeight: ast.height,
3639
3684
  };
3640
3685
  }
3641
3686
  // ── Helpers ───────────────────────────────────────────────
@@ -4297,37 +4342,510 @@ const lineShape = {
4297
4342
  },
4298
4343
  };
4299
4344
 
4345
+ const COMMAND_RE = /^[AaCcHhLlMmQqSsTtVvZz]$/;
4346
+ const TOKEN_RE = /[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?/g;
4347
+ const EPSILON = 1e-6;
4348
+ const PARAM_COUNTS = {
4349
+ A: 7,
4350
+ C: 6,
4351
+ H: 1,
4352
+ L: 2,
4353
+ M: 2,
4354
+ Q: 4,
4355
+ S: 4,
4356
+ T: 2,
4357
+ V: 1,
4358
+ Z: 0,
4359
+ };
4360
+ function isCommandToken(token) {
4361
+ return COMMAND_RE.test(token);
4362
+ }
4363
+ function formatNumber(value) {
4364
+ const rounded = Math.abs(value) < EPSILON ? 0 : Number(value.toFixed(3));
4365
+ return Object.is(rounded, -0) ? "0" : String(rounded);
4366
+ }
4367
+ function parseRawSegments(pathData) {
4368
+ const tokens = pathData.match(TOKEN_RE) ?? [];
4369
+ if (!tokens.length)
4370
+ return [];
4371
+ const segments = [];
4372
+ let index = 0;
4373
+ let currentCommand = null;
4374
+ while (index < tokens.length) {
4375
+ const token = tokens[index];
4376
+ if (isCommandToken(token)) {
4377
+ currentCommand = token;
4378
+ index += 1;
4379
+ if (token === "Z" || token === "z") {
4380
+ segments.push({ command: "Z", values: [] });
4381
+ }
4382
+ continue;
4383
+ }
4384
+ if (!currentCommand)
4385
+ break;
4386
+ const upper = currentCommand.toUpperCase();
4387
+ const paramCount = PARAM_COUNTS[upper];
4388
+ if (!paramCount) {
4389
+ index += 1;
4390
+ continue;
4391
+ }
4392
+ let isFirstMove = upper === "M";
4393
+ while (index < tokens.length && !isCommandToken(tokens[index])) {
4394
+ if (index + paramCount > tokens.length)
4395
+ return segments;
4396
+ const values = tokens
4397
+ .slice(index, index + paramCount)
4398
+ .map((value) => Number(value));
4399
+ if (values.some((value) => Number.isNaN(value))) {
4400
+ return segments;
4401
+ }
4402
+ if (upper === "M") {
4403
+ const moveCommand = isFirstMove
4404
+ ? currentCommand
4405
+ : currentCommand === "m"
4406
+ ? "l"
4407
+ : "L";
4408
+ segments.push({ command: moveCommand, values });
4409
+ isFirstMove = false;
4410
+ }
4411
+ else {
4412
+ segments.push({ command: currentCommand, values });
4413
+ }
4414
+ index += paramCount;
4415
+ }
4416
+ }
4417
+ return segments;
4418
+ }
4419
+ function reflect(control, around) {
4420
+ return {
4421
+ x: around.x * 2 - control.x,
4422
+ y: around.y * 2 - control.y,
4423
+ };
4424
+ }
4425
+ function toAbsoluteSegments(rawSegments) {
4426
+ const segments = [];
4427
+ let current = { x: 0, y: 0 };
4428
+ let subpathStart = { x: 0, y: 0 };
4429
+ let previousCubicControl = null;
4430
+ let previousQuadraticControl = null;
4431
+ for (const segment of rawSegments) {
4432
+ const isRelative = segment.command === segment.command.toLowerCase();
4433
+ const command = segment.command.toUpperCase();
4434
+ const values = segment.values;
4435
+ switch (command) {
4436
+ case "M": {
4437
+ const x = isRelative ? current.x + values[0] : values[0];
4438
+ const y = isRelative ? current.y + values[1] : values[1];
4439
+ current = { x, y };
4440
+ subpathStart = { x, y };
4441
+ previousCubicControl = null;
4442
+ previousQuadraticControl = null;
4443
+ segments.push({ command: "M", values: [x, y] });
4444
+ break;
4445
+ }
4446
+ case "L": {
4447
+ const x = isRelative ? current.x + values[0] : values[0];
4448
+ const y = isRelative ? current.y + values[1] : values[1];
4449
+ current = { x, y };
4450
+ previousCubicControl = null;
4451
+ previousQuadraticControl = null;
4452
+ segments.push({ command: "L", values: [x, y] });
4453
+ break;
4454
+ }
4455
+ case "H": {
4456
+ const x = isRelative ? current.x + values[0] : values[0];
4457
+ current = { x, y: current.y };
4458
+ previousCubicControl = null;
4459
+ previousQuadraticControl = null;
4460
+ segments.push({ command: "L", values: [x, current.y] });
4461
+ break;
4462
+ }
4463
+ case "V": {
4464
+ const y = isRelative ? current.y + values[0] : values[0];
4465
+ current = { x: current.x, y };
4466
+ previousCubicControl = null;
4467
+ previousQuadraticControl = null;
4468
+ segments.push({ command: "L", values: [current.x, y] });
4469
+ break;
4470
+ }
4471
+ case "C": {
4472
+ const x1 = isRelative ? current.x + values[0] : values[0];
4473
+ const y1 = isRelative ? current.y + values[1] : values[1];
4474
+ const x2 = isRelative ? current.x + values[2] : values[2];
4475
+ const y2 = isRelative ? current.y + values[3] : values[3];
4476
+ const x = isRelative ? current.x + values[4] : values[4];
4477
+ const y = isRelative ? current.y + values[5] : values[5];
4478
+ current = { x, y };
4479
+ previousCubicControl = { x: x2, y: y2 };
4480
+ previousQuadraticControl = null;
4481
+ segments.push({ command: "C", values: [x1, y1, x2, y2, x, y] });
4482
+ break;
4483
+ }
4484
+ case "S": {
4485
+ const control1 = previousCubicControl
4486
+ ? reflect(previousCubicControl, current)
4487
+ : { ...current };
4488
+ const x2 = isRelative ? current.x + values[0] : values[0];
4489
+ const y2 = isRelative ? current.y + values[1] : values[1];
4490
+ const x = isRelative ? current.x + values[2] : values[2];
4491
+ const y = isRelative ? current.y + values[3] : values[3];
4492
+ current = { x, y };
4493
+ previousCubicControl = { x: x2, y: y2 };
4494
+ previousQuadraticControl = null;
4495
+ segments.push({
4496
+ command: "C",
4497
+ values: [control1.x, control1.y, x2, y2, x, y],
4498
+ });
4499
+ break;
4500
+ }
4501
+ case "Q": {
4502
+ const x1 = isRelative ? current.x + values[0] : values[0];
4503
+ const y1 = isRelative ? current.y + values[1] : values[1];
4504
+ const x = isRelative ? current.x + values[2] : values[2];
4505
+ const y = isRelative ? current.y + values[3] : values[3];
4506
+ current = { x, y };
4507
+ previousCubicControl = null;
4508
+ previousQuadraticControl = { x: x1, y: y1 };
4509
+ segments.push({ command: "Q", values: [x1, y1, x, y] });
4510
+ break;
4511
+ }
4512
+ case "T": {
4513
+ const control = previousQuadraticControl
4514
+ ? reflect(previousQuadraticControl, current)
4515
+ : { ...current };
4516
+ const x = isRelative ? current.x + values[0] : values[0];
4517
+ const y = isRelative ? current.y + values[1] : values[1];
4518
+ current = { x, y };
4519
+ previousCubicControl = null;
4520
+ previousQuadraticControl = control;
4521
+ segments.push({ command: "Q", values: [control.x, control.y, x, y] });
4522
+ break;
4523
+ }
4524
+ case "A": {
4525
+ const rx = Math.abs(values[0]);
4526
+ const ry = Math.abs(values[1]);
4527
+ const rotation = values[2];
4528
+ const largeArc = values[3];
4529
+ const sweep = values[4];
4530
+ const x = isRelative ? current.x + values[5] : values[5];
4531
+ const y = isRelative ? current.y + values[6] : values[6];
4532
+ current = { x, y };
4533
+ previousCubicControl = null;
4534
+ previousQuadraticControl = null;
4535
+ segments.push({
4536
+ command: "A",
4537
+ values: [rx, ry, rotation, largeArc, sweep, x, y],
4538
+ });
4539
+ break;
4540
+ }
4541
+ case "Z": {
4542
+ current = { ...subpathStart };
4543
+ previousCubicControl = null;
4544
+ previousQuadraticControl = null;
4545
+ segments.push({ command: "Z", values: [] });
4546
+ break;
4547
+ }
4548
+ }
4549
+ }
4550
+ return segments;
4551
+ }
4552
+ function cubicAt(p0, p1, p2, p3, t) {
4553
+ const mt = 1 - t;
4554
+ return (mt * mt * mt * p0 +
4555
+ 3 * mt * mt * t * p1 +
4556
+ 3 * mt * t * t * p2 +
4557
+ t * t * t * p3);
4558
+ }
4559
+ function quadraticAt(p0, p1, p2, t) {
4560
+ const mt = 1 - t;
4561
+ return mt * mt * p0 + 2 * mt * t * p1 + t * t * p2;
4562
+ }
4563
+ function cubicExtrema(p0, p1, p2, p3) {
4564
+ const a = -p0 + 3 * p1 - 3 * p2 + p3;
4565
+ const b = 3 * p0 - 6 * p1 + 3 * p2;
4566
+ const c = -3 * p0 + 3 * p1;
4567
+ if (Math.abs(a) < EPSILON) {
4568
+ if (Math.abs(b) < EPSILON)
4569
+ return [];
4570
+ return [-c / (2 * b)].filter((t) => t > 0 && t < 1);
4571
+ }
4572
+ const discriminant = 4 * b * b - 12 * a * c;
4573
+ if (discriminant < 0)
4574
+ return [];
4575
+ const sqrtDiscriminant = Math.sqrt(discriminant);
4576
+ return [
4577
+ (-2 * b + sqrtDiscriminant) / (6 * a),
4578
+ (-2 * b - sqrtDiscriminant) / (6 * a),
4579
+ ].filter((t) => t > 0 && t < 1);
4580
+ }
4581
+ function quadraticExtrema(p0, p1, p2) {
4582
+ const denominator = p0 - 2 * p1 + p2;
4583
+ if (Math.abs(denominator) < EPSILON)
4584
+ return [];
4585
+ const t = (p0 - p1) / denominator;
4586
+ return t > 0 && t < 1 ? [t] : [];
4587
+ }
4588
+ function angleBetween(u, v) {
4589
+ const magnitude = Math.hypot(u.x, u.y) * Math.hypot(v.x, v.y);
4590
+ if (magnitude < EPSILON)
4591
+ return 0;
4592
+ const sign = u.x * v.y - u.y * v.x < 0 ? -1 : 1;
4593
+ const cosine = Math.min(1, Math.max(-1, (u.x * v.x + u.y * v.y) / magnitude));
4594
+ return sign * Math.acos(cosine);
4595
+ }
4596
+ function sampleArc(start, values) {
4597
+ let [rx, ry, rotation, largeArcFlag, sweepFlag, endX, endY] = values;
4598
+ if ((Math.abs(start.x - endX) < EPSILON && Math.abs(start.y - endY) < EPSILON) || rx < EPSILON || ry < EPSILON) {
4599
+ return [start, { x: endX, y: endY }];
4600
+ }
4601
+ rx = Math.abs(rx);
4602
+ ry = Math.abs(ry);
4603
+ const phi = (rotation * Math.PI) / 180;
4604
+ const cosPhi = Math.cos(phi);
4605
+ const sinPhi = Math.sin(phi);
4606
+ const dx2 = (start.x - endX) / 2;
4607
+ const dy2 = (start.y - endY) / 2;
4608
+ const x1p = cosPhi * dx2 + sinPhi * dy2;
4609
+ const y1p = -sinPhi * dx2 + cosPhi * dy2;
4610
+ const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
4611
+ if (lambda > 1) {
4612
+ const scale = Math.sqrt(lambda);
4613
+ rx *= scale;
4614
+ ry *= scale;
4615
+ }
4616
+ const rx2 = rx * rx;
4617
+ const ry2 = ry * ry;
4618
+ const x1p2 = x1p * x1p;
4619
+ const y1p2 = y1p * y1p;
4620
+ const numerator = rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2;
4621
+ const denominator = rx2 * y1p2 + ry2 * x1p2;
4622
+ const factor = denominator < EPSILON ? 0 : Math.sqrt(Math.max(0, numerator / denominator));
4623
+ const sign = largeArcFlag === sweepFlag ? -1 : 1;
4624
+ const cxp = sign * factor * ((rx * y1p) / ry);
4625
+ const cyp = sign * factor * (-(ry * x1p) / rx);
4626
+ const cx = cosPhi * cxp - sinPhi * cyp + (start.x + endX) / 2;
4627
+ const cy = sinPhi * cxp + cosPhi * cyp + (start.y + endY) / 2;
4628
+ const startVector = {
4629
+ x: (x1p - cxp) / rx,
4630
+ y: (y1p - cyp) / ry,
4631
+ };
4632
+ const endVector = {
4633
+ x: (-x1p - cxp) / rx,
4634
+ y: (-y1p - cyp) / ry,
4635
+ };
4636
+ let deltaTheta = angleBetween(startVector, endVector);
4637
+ if (!sweepFlag && deltaTheta > 0)
4638
+ deltaTheta -= Math.PI * 2;
4639
+ if (sweepFlag && deltaTheta < 0)
4640
+ deltaTheta += Math.PI * 2;
4641
+ const theta1 = angleBetween({ x: 1, y: 0 }, startVector);
4642
+ const steps = Math.max(12, Math.ceil(Math.abs(deltaTheta) / (Math.PI / 8)));
4643
+ const points = [];
4644
+ for (let index = 0; index <= steps; index += 1) {
4645
+ const theta = theta1 + (deltaTheta * index) / steps;
4646
+ const cosTheta = Math.cos(theta);
4647
+ const sinTheta = Math.sin(theta);
4648
+ points.push({
4649
+ x: cx + rx * cosPhi * cosTheta - ry * sinPhi * sinTheta,
4650
+ y: cy + rx * sinPhi * cosTheta + ry * cosPhi * sinTheta,
4651
+ });
4652
+ }
4653
+ return points;
4654
+ }
4655
+ function boundsFromAbsoluteSegments(segments) {
4656
+ if (!segments.length)
4657
+ return null;
4658
+ let minX = Infinity;
4659
+ let minY = Infinity;
4660
+ let maxX = -Infinity;
4661
+ let maxY = -Infinity;
4662
+ const include = (point) => {
4663
+ minX = Math.min(minX, point.x);
4664
+ minY = Math.min(minY, point.y);
4665
+ maxX = Math.max(maxX, point.x);
4666
+ maxY = Math.max(maxY, point.y);
4667
+ };
4668
+ let current = { x: 0, y: 0 };
4669
+ let subpathStart = { x: 0, y: 0 };
4670
+ for (const segment of segments) {
4671
+ switch (segment.command) {
4672
+ case "M": {
4673
+ current = { x: segment.values[0], y: segment.values[1] };
4674
+ subpathStart = { ...current };
4675
+ include(current);
4676
+ break;
4677
+ }
4678
+ case "L": {
4679
+ include(current);
4680
+ current = { x: segment.values[0], y: segment.values[1] };
4681
+ include(current);
4682
+ break;
4683
+ }
4684
+ case "C": {
4685
+ const [x1, y1, x2, y2, x, y] = segment.values;
4686
+ const ts = new Set([0, 1]);
4687
+ cubicExtrema(current.x, x1, x2, x).forEach((value) => ts.add(value));
4688
+ cubicExtrema(current.y, y1, y2, y).forEach((value) => ts.add(value));
4689
+ for (const t of ts) {
4690
+ include({
4691
+ x: cubicAt(current.x, x1, x2, x, t),
4692
+ y: cubicAt(current.y, y1, y2, y, t),
4693
+ });
4694
+ }
4695
+ current = { x, y };
4696
+ break;
4697
+ }
4698
+ case "Q": {
4699
+ const [x1, y1, x, y] = segment.values;
4700
+ const ts = new Set([0, 1]);
4701
+ quadraticExtrema(current.x, x1, x).forEach((value) => ts.add(value));
4702
+ quadraticExtrema(current.y, y1, y).forEach((value) => ts.add(value));
4703
+ for (const t of ts) {
4704
+ include({
4705
+ x: quadraticAt(current.x, x1, x, t),
4706
+ y: quadraticAt(current.y, y1, y, t),
4707
+ });
4708
+ }
4709
+ current = { x, y };
4710
+ break;
4711
+ }
4712
+ case "A": {
4713
+ for (const point of sampleArc(current, segment.values)) {
4714
+ include(point);
4715
+ }
4716
+ current = { x: segment.values[5], y: segment.values[6] };
4717
+ break;
4718
+ }
4719
+ case "Z": {
4720
+ include(current);
4721
+ include(subpathStart);
4722
+ current = { ...subpathStart };
4723
+ break;
4724
+ }
4725
+ }
4726
+ }
4727
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
4728
+ return null;
4729
+ }
4730
+ return { minX, minY, maxX, maxY };
4731
+ }
4732
+ function transformX(x, bounds, scaleX) {
4733
+ return (x - bounds.minX) * scaleX;
4734
+ }
4735
+ function transformY(y, bounds, scaleY) {
4736
+ return (y - bounds.minY) * scaleY;
4737
+ }
4738
+ function buildScaledPathData(segments, bounds, width, height) {
4739
+ const sourceWidth = Math.max(bounds.maxX - bounds.minX, EPSILON);
4740
+ const sourceHeight = Math.max(bounds.maxY - bounds.minY, EPSILON);
4741
+ const scaleX = width / sourceWidth;
4742
+ const scaleY = height / sourceHeight;
4743
+ return segments
4744
+ .map((segment) => {
4745
+ switch (segment.command) {
4746
+ case "M":
4747
+ case "L":
4748
+ return [
4749
+ segment.command,
4750
+ formatNumber(transformX(segment.values[0], bounds, scaleX)),
4751
+ formatNumber(transformY(segment.values[1], bounds, scaleY)),
4752
+ ].join(" ");
4753
+ case "C":
4754
+ return [
4755
+ "C",
4756
+ formatNumber(transformX(segment.values[0], bounds, scaleX)),
4757
+ formatNumber(transformY(segment.values[1], bounds, scaleY)),
4758
+ formatNumber(transformX(segment.values[2], bounds, scaleX)),
4759
+ formatNumber(transformY(segment.values[3], bounds, scaleY)),
4760
+ formatNumber(transformX(segment.values[4], bounds, scaleX)),
4761
+ formatNumber(transformY(segment.values[5], bounds, scaleY)),
4762
+ ].join(" ");
4763
+ case "Q":
4764
+ return [
4765
+ "Q",
4766
+ formatNumber(transformX(segment.values[0], bounds, scaleX)),
4767
+ formatNumber(transformY(segment.values[1], bounds, scaleY)),
4768
+ formatNumber(transformX(segment.values[2], bounds, scaleX)),
4769
+ formatNumber(transformY(segment.values[3], bounds, scaleY)),
4770
+ ].join(" ");
4771
+ case "A":
4772
+ return [
4773
+ "A",
4774
+ formatNumber(segment.values[0] * scaleX),
4775
+ formatNumber(segment.values[1] * scaleY),
4776
+ formatNumber(segment.values[2]),
4777
+ formatNumber(segment.values[3]),
4778
+ formatNumber(segment.values[4]),
4779
+ formatNumber(transformX(segment.values[5], bounds, scaleX)),
4780
+ formatNumber(transformY(segment.values[6], bounds, scaleY)),
4781
+ ].join(" ");
4782
+ case "Z":
4783
+ return "Z";
4784
+ }
4785
+ })
4786
+ .join(" ");
4787
+ }
4788
+ function intrinsicSizeFromBounds(bounds) {
4789
+ if (!bounds)
4790
+ return { width: 100, height: 100 };
4791
+ return {
4792
+ width: Math.max(1, Math.ceil(bounds.maxX - bounds.minX)),
4793
+ height: Math.max(1, Math.ceil(bounds.maxY - bounds.minY)),
4794
+ };
4795
+ }
4796
+ function parsePathGeometry(pathData) {
4797
+ const segments = toAbsoluteSegments(parseRawSegments(pathData));
4798
+ return {
4799
+ segments,
4800
+ bounds: boundsFromAbsoluteSegments(segments),
4801
+ };
4802
+ }
4803
+ function getPathIntrinsicSize(pathData) {
4804
+ if (!pathData)
4805
+ return { width: 100, height: 100 };
4806
+ return intrinsicSizeFromBounds(parsePathGeometry(pathData).bounds);
4807
+ }
4808
+ function getRenderablePathData(pathData, width, height) {
4809
+ if (!pathData)
4810
+ return null;
4811
+ const { segments, bounds } = parsePathGeometry(pathData);
4812
+ if (!segments.length || !bounds)
4813
+ return pathData;
4814
+ return buildScaledPathData(segments, bounds, Math.max(1, width), Math.max(1, height));
4815
+ }
4816
+ function getRenderableNodePathData(node) {
4817
+ return getRenderablePathData(node.pathData, node.w, node.h);
4818
+ }
4819
+
4300
4820
  const pathShape = {
4301
4821
  size(n, labelW) {
4302
- // User should provide width/height; defaults to 100x100
4303
- const w = n.width ?? Math.max(100, Math.min(300, labelW + 20));
4822
+ const intrinsic = getPathIntrinsicSize(n.pathData);
4823
+ const w = n.width ?? Math.max(intrinsic.width, Math.min(300, labelW + 20));
4304
4824
  n.w = w;
4305
4825
  if (!n.h) {
4306
- if (!n.width && labelW + 20 > w) {
4826
+ if (!n.width && !n.height && labelW + 20 > w) {
4307
4827
  const fontSize = Number(n.style?.fontSize ?? 14);
4308
4828
  const lines = Math.ceil(labelW / (w - 20));
4309
- n.h = Math.max(100, lines * fontSize * 1.5 + 20);
4829
+ n.h = Math.max(intrinsic.height, lines * fontSize * 1.5 + 20);
4310
4830
  }
4311
4831
  else {
4312
- n.h = n.height ?? 100;
4832
+ n.h = n.height ?? intrinsic.height;
4313
4833
  }
4314
4834
  }
4315
4835
  },
4316
4836
  renderSVG(rc, n, _palette, opts) {
4317
- const d = n.pathData;
4837
+ const d = getRenderableNodePathData(n);
4318
4838
  if (!d) {
4319
- // No path data — render placeholder box
4320
4839
  return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts)];
4321
4840
  }
4322
4841
  const el = rc.path(d, opts);
4323
- // Wrap in a group to translate the user's path to the node position
4324
4842
  const g = document.createElementNS(SVG_NS, "g");
4325
4843
  g.setAttribute("transform", `translate(${n.x},${n.y})`);
4326
4844
  g.appendChild(el);
4327
4845
  return [g];
4328
4846
  },
4329
4847
  renderCanvas(rc, ctx, n, _palette, opts) {
4330
- const d = n.pathData;
4848
+ const d = getRenderableNodePathData(n);
4331
4849
  if (!d) {
4332
4850
  rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts);
4333
4851
  return;
@@ -4739,8 +5257,10 @@ function computeBounds(sg, margin) {
4739
5257
  ...sg.charts.map((c) => c.y + c.h),
4740
5258
  ...sg.markdowns.map((m) => m.y + m.h),
4741
5259
  ];
4742
- sg.width = (allX.length ? Math.max(...allX) : 400) + margin;
4743
- sg.height = (allY.length ? Math.max(...allY) : 300) + margin;
5260
+ const autoWidth = (allX.length ? Math.max(...allX) : 400) + margin;
5261
+ const autoHeight = (allY.length ? Math.max(...allY) : 300) + margin;
5262
+ sg.width = sg.fixedWidth ?? autoWidth;
5263
+ sg.height = sg.fixedHeight ?? autoHeight;
4744
5264
  }
4745
5265
  // ── Public entry point ────────────────────────────────────
4746
5266
  function layout(sg) {
@@ -7776,7 +8296,7 @@ function renderToSVG(sg, container, options = {}) {
7776
8296
  const palette = resolvePalette(themeName);
7777
8297
  // ── Diagram-level font ──────────────────────────────────
7778
8298
  const diagramFont = (() => {
7779
- const raw = String(sg.config["font"] ?? "");
8299
+ const raw = String(sg.style?.font ?? sg.config["font"] ?? "");
7780
8300
  if (raw) {
7781
8301
  loadFont(raw);
7782
8302
  return resolveFont(raw);
@@ -7806,8 +8326,22 @@ function renderToSVG(sg, container, options = {}) {
7806
8326
  bgRect.setAttribute("y", "0");
7807
8327
  bgRect.setAttribute("width", String(sg.width));
7808
8328
  bgRect.setAttribute("height", String(sg.height));
7809
- bgRect.setAttribute("fill", palette.background);
8329
+ bgRect.setAttribute("fill", String(sg.style?.fill ?? palette.background));
7810
8330
  svg.appendChild(bgRect);
8331
+ const rootStroke = sg.style?.stroke;
8332
+ const rootStrokeWidth = Number(sg.style?.strokeWidth ?? 0);
8333
+ if (rootStroke && rootStroke !== "none" && rootStrokeWidth > 0) {
8334
+ const frame = se("rect");
8335
+ const inset = rootStrokeWidth / 2;
8336
+ frame.setAttribute("x", String(inset));
8337
+ frame.setAttribute("y", String(inset));
8338
+ frame.setAttribute("width", String(Math.max(0, sg.width - rootStrokeWidth)));
8339
+ frame.setAttribute("height", String(Math.max(0, sg.height - rootStrokeWidth)));
8340
+ frame.setAttribute("fill", "none");
8341
+ frame.setAttribute("stroke", String(rootStroke));
8342
+ frame.setAttribute("stroke-width", String(rootStrokeWidth));
8343
+ svg.appendChild(frame);
8344
+ }
7811
8345
  }
7812
8346
  const rc = rough.svg(svg);
7813
8347
  // ── Title ────────────────────────────────────────────────
@@ -7939,7 +8473,7 @@ function renderToSVG(sg, container, options = {}) {
7939
8473
  ng.dataset.w = String(n.w);
7940
8474
  ng.dataset.h = String(n.h);
7941
8475
  if (n.pathData)
7942
- ng.dataset.pathData = n.pathData;
8476
+ ng.dataset.pathData = getRenderableNodePathData(n) ?? n.pathData;
7943
8477
  if (n.meta?.animationParent)
7944
8478
  ng.dataset.animationParent = n.meta.animationParent;
7945
8479
  if (n.style?.opacity != null)
@@ -8535,7 +9069,7 @@ function renderToCanvas(sg, canvas, options = {}) {
8535
9069
  const palette = resolvePalette(themeName);
8536
9070
  // ── Diagram-level font ───────────────────────────────────
8537
9071
  const diagramFont = (() => {
8538
- const raw = String(sg.config['font'] ?? '');
9072
+ const raw = String(sg.style?.font ?? sg.config['font'] ?? '');
8539
9073
  if (raw) {
8540
9074
  loadFont(raw);
8541
9075
  return resolveFont(raw);
@@ -8544,8 +9078,18 @@ function renderToCanvas(sg, canvas, options = {}) {
8544
9078
  })();
8545
9079
  // ── Background ───────────────────────────────────────────
8546
9080
  if (!options.transparent) {
8547
- ctx.fillStyle = options.background ?? palette.background;
9081
+ ctx.fillStyle = options.background ?? String(sg.style?.fill ?? palette.background);
8548
9082
  ctx.fillRect(0, 0, sg.width, sg.height);
9083
+ const rootStroke = sg.style?.stroke;
9084
+ const rootStrokeWidth = Number(sg.style?.strokeWidth ?? 0);
9085
+ if (rootStroke && rootStroke !== 'none' && rootStrokeWidth > 0) {
9086
+ const inset = rootStrokeWidth / 2;
9087
+ ctx.save();
9088
+ ctx.strokeStyle = String(rootStroke);
9089
+ ctx.lineWidth = rootStrokeWidth;
9090
+ ctx.strokeRect(inset, inset, Math.max(0, sg.width - rootStrokeWidth), Math.max(0, sg.height - rootStrokeWidth));
9091
+ ctx.restore();
9092
+ }
8549
9093
  }
8550
9094
  else {
8551
9095
  ctx.clearRect(0, 0, sg.width, sg.height);
@@ -9595,6 +10139,10 @@ class AnimationController {
9595
10139
  if (!el.id.startsWith("group-")) {
9596
10140
  const ids = new Set([el.id]);
9597
10141
  this._relatedElementIdsByPrimaryId.get(el.id)?.forEach((id) => ids.add(id));
10142
+ this.svg.querySelectorAll(POSITIONABLE_SELECTOR).forEach((candidate) => {
10143
+ if (candidate.dataset.animationParent === target)
10144
+ ids.add(candidate.id);
10145
+ });
9598
10146
  return Array.from(ids)
9599
10147
  .map((id) => getEl(this.svg, id))
9600
10148
  .filter((candidate) => candidate != null);
@@ -9795,6 +10343,7 @@ class AnimationController {
9795
10343
  el.classList.remove("hl", "faded", "hidden");
9796
10344
  el.style.opacity = el.style.filter = "";
9797
10345
  if (this.drawTargetNodes.has(el.id)) {
10346
+ hideDrawEl(el);
9798
10347
  prepareNodeForDraw(el);
9799
10348
  }
9800
10349
  else {
@@ -10293,6 +10842,15 @@ class AnimationController {
10293
10842
  _doColor(target, color) {
10294
10843
  if (!color)
10295
10844
  return;
10845
+ const applyTextColor = (root) => {
10846
+ root.querySelectorAll("text").forEach((t) => {
10847
+ t.style.fill = color;
10848
+ const existingStyle = t.getAttribute("style") ?? "";
10849
+ const nextStyle = `${existingStyle.replace(/(?:^|;)\s*fill\s*:[^;]*/g, "").trim().replace(/;?$/, ";")}fill:${color};`;
10850
+ t.setAttribute("style", nextStyle);
10851
+ t.setAttribute("fill", color);
10852
+ });
10853
+ };
10296
10854
  for (const el of this._resolveCascadeTargets(target)) {
10297
10855
  if (parseEdgeTarget(target)) {
10298
10856
  el.querySelectorAll("path, line, polyline").forEach((p) => {
@@ -10315,11 +10873,14 @@ class AnimationController {
10315
10873
  hit = true;
10316
10874
  });
10317
10875
  if (!hit) {
10318
- el.querySelectorAll("text").forEach((t) => {
10319
- t.style.fill = color;
10320
- });
10876
+ applyTextColor(el);
10321
10877
  }
10322
10878
  }
10879
+ this.svg.querySelectorAll(`${POSITIONABLE_SELECTOR}[data-animation-parent]`).forEach((el) => {
10880
+ if (el.dataset.animationParent === target) {
10881
+ applyTextColor(el);
10882
+ }
10883
+ });
10323
10884
  }
10324
10885
  // ── narration ───────────────────────────────────────────
10325
10886
  _initCaption() {