sfn-diagram 0.5.1 → 0.6.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/README.md CHANGED
@@ -12,7 +12,7 @@ Generate beautiful, interactive diagrams from AWS Step Functions ASL (Amazon Sta
12
12
 
13
13
  - **Multiple Output Formats**: SVG (D3.js), Mermaid syntax, and PNG
14
14
  - **Automatic Layout**: Smart graph positioning using Dagre layout engine
15
- - **Full ASL Support**: All state types (Pass, Task, Choice, Wait, Succeed, Fail, Parallel, Map)
15
+ - **Full ASL Support**: All state types (Pass, Task, Choice, Wait, Succeed, Fail, Parallel, Map), both JSONPath and JSONata query languages
16
16
  - **Customizable Themes**: AWS light/dark themes plus custom theme support
17
17
  - **Flexible Layouts**: Top-bottom, left-right, right-left, bottom-top
18
18
  - **Type-Safe**: Full TypeScript support with comprehensive type definitions
@@ -26,6 +26,12 @@ Generate beautiful, interactive diagrams from AWS Step Functions ASL (Amazon Sta
26
26
  npm install sfn-diagram
27
27
  ```
28
28
 
29
+ The core package pulls in **no browser engine** — SVG and Mermaid generation stay lightweight. PNG export relies on a headless browser, provided by the optional peer dependency `node-html-to-image`. Install it only if you use `sfn-diagram/png`:
30
+
31
+ ```bash
32
+ npm install sfn-diagram node-html-to-image
33
+ ```
34
+
29
35
  ## Runtime Support
30
36
 
31
37
  The core entry (`sfn-diagram`) builds SVG with a DOM-free string renderer, so
@@ -34,7 +40,9 @@ run in **Node, browsers, and edge runtimes** (Cloudflare Workers, Vercel Edge, D
34
40
  with no DOM polyfill.
35
41
 
36
42
  PNG export (`sfn-diagram/png`) and the CLI are **Node-only** — they rely on a headless
37
- browser (`node-html-to-image`) and Node's filesystem respectively.
43
+ browser (`node-html-to-image`) and Node's filesystem respectively. Because `node-html-to-image`
44
+ is an **optional peer dependency**, install it alongside `sfn-diagram` when you need PNG output;
45
+ `exportPng` throws an actionable error if it is missing.
38
46
 
39
47
  ```ts
40
48
  // Works in Node, browser, and edge:
@@ -363,7 +371,16 @@ All AWS Step Functions state types are fully supported:
363
371
  | **Succeed** | Circle | Terminates successfully |
364
372
  | **Fail** | Circle | Terminates with failure |
365
373
  | **Parallel** | Rectangle | Executes branches in parallel |
366
- | **Map** | Rectangle | Iterates over array items |
374
+ | **Map** | Rectangle | Iterates over array items (legacy `Iterator` and modern `ItemProcessor`/Distributed Map) |
375
+
376
+ ### Error handling & retries
377
+
378
+ - **`Catch`** blocks render as dashed error edges to their handler states.
379
+ - **`Retry`** policies render as a labelled self-loop on the state (e.g. `↻ States.Timeout (4x); States.ALL (2x)`) — a self-transition in Mermaid output.
380
+
381
+ ### Query languages
382
+
383
+ Both JSONPath and JSONata (`QueryLanguage: "JSONata"`) definitions are supported. Choice branch labels are derived from JSONPath comparison operators (`$.score >= 90`, `And`/`Or`/`Not`, `Is*` checks) or from JSONata `Condition` expressions, whichever the state uses.
367
384
 
368
385
  ## Examples
369
386
 
package/dist/cli.js CHANGED
@@ -4,7 +4,6 @@ import { resolve } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import dagre from "@dagrejs/dagre";
6
6
  import { curveBasis, line } from "d3-shape";
7
- import nodeHtmlToImage from "node-html-to-image";
8
7
  //#region src/config/themes.ts
9
8
  /**
10
9
  * AWS Light Theme - matches the AWS Step Functions console light mode
@@ -49,7 +48,8 @@ const AWS_LIGHT_THEME = {
49
48
  choice: "#7b1fa2",
50
49
  default: "#9c27b0",
51
50
  error: "#f44336",
52
- normal: "#546e7a"
51
+ normal: "#546e7a",
52
+ retry: "#f9a825"
53
53
  },
54
54
  textColor: "#212121",
55
55
  fontSize: 14,
@@ -98,7 +98,8 @@ const AWS_DARK_THEME = {
98
98
  choice: "#ce93d8",
99
99
  default: "#ba68c8",
100
100
  error: "#ef5350",
101
- normal: "#90a4ae"
101
+ normal: "#90a4ae",
102
+ retry: "#ffca28"
102
103
  },
103
104
  textColor: "#e0e0e0",
104
105
  fontSize: 14,
@@ -181,8 +182,21 @@ const EDGE_LABELS = {
181
182
  CONDITION_FALLBACK: "Condition",
182
183
  DEFAULT: "Default",
183
184
  ERROR_PREFIX: "Error:",
184
- ITERATOR: "Iterator"
185
+ ITERATOR: "Iterator",
186
+ RETRY_SYMBOL: "↻"
185
187
  };
188
+ /** Default MaxAttempts when a Retry block omits it (per the ASL spec). */
189
+ const DEFAULT_MAX_ATTEMPTS = 3;
190
+ /**
191
+ * Summarize a state's Retry blocks into a compact self-loop label,
192
+ * e.g. "↻ States.TaskFailed (3x)" or "↻ States.ALL (2x); Timeout (5x)".
193
+ */
194
+ function getRetryLabel(retryBlocks) {
195
+ const parts = retryBlocks.map((retry) => {
196
+ return `${retry.ErrorEquals?.join(", ") || "All"} (${retry.MaxAttempts ?? DEFAULT_MAX_ATTEMPTS}x)`;
197
+ });
198
+ return `${EDGE_LABELS.RETRY_SYMBOL} ${parts.join("; ")}`;
199
+ }
186
200
  /**
187
201
  * Generates an error label with error types
188
202
  */
@@ -506,7 +520,7 @@ function createStateNode(params) {
506
520
  const baseNode = {
507
521
  id: name,
508
522
  isContainer,
509
- label: state.Comment || name,
523
+ label: options?.includeComments !== false ? state.Comment || name : name,
510
524
  style: getNodeStyle({
511
525
  stateType: state.Type,
512
526
  stylePreset
@@ -560,6 +574,13 @@ function extractEdgesFromState(params) {
560
574
  });
561
575
  break;
562
576
  }
577
+ if (state.Retry && state.Retry.length > 0) edges.push({
578
+ from: stateName,
579
+ label: getRetryLabel(state.Retry),
580
+ to: stateName,
581
+ type: "retry",
582
+ visualOnly: true
583
+ });
563
584
  if (state.Catch) state.Catch.forEach((catchBlock, index) => {
564
585
  if (catchBlock.Next) edges.push({
565
586
  from: stateName,
@@ -574,13 +595,87 @@ function extractEdgesFromState(params) {
574
595
  });
575
596
  return edges;
576
597
  }
598
+ /** ASL data-type prefixes for typed comparison operators */
599
+ const COMPARISON_TYPE_PREFIXES = [
600
+ "String",
601
+ "Numeric",
602
+ "Boolean",
603
+ "Timestamp"
604
+ ];
605
+ /** Operator suffixes mapped to display symbols (longest suffixes first for matching) */
606
+ const COMPARISON_OPERATORS = [
607
+ ["LessThanEquals", "<="],
608
+ ["GreaterThanEquals", ">="],
609
+ ["LessThan", "<"],
610
+ ["GreaterThan", ">"],
611
+ ["Equals", "=="],
612
+ ["Matches", "matches"]
613
+ ];
614
+ /** Unary presence/type checks mapped to display phrases */
615
+ const IS_CHECKS = {
616
+ IsBoolean: "is boolean",
617
+ IsNull: "is null",
618
+ IsNumeric: "is numeric",
619
+ IsPresent: "is present",
620
+ IsString: "is string",
621
+ IsTimestamp: "is timestamp"
622
+ };
623
+ /** Strip JSONata delimiters (`{% ... %}`) from a condition expression. */
624
+ function cleanJsonataExpression(expression) {
625
+ return expression.replace(/^\{%\s*/, "").replace(/\s*%\}$/, "").trim();
626
+ }
627
+ /**
628
+ * Format a single comparison operator on a Choice rule into a readable label,
629
+ * or return null if the key is not a recognized comparison operator.
630
+ */
631
+ function formatComparison(variable, operatorKey, value) {
632
+ const isCheckPhrase = IS_CHECKS[operatorKey];
633
+ if (isCheckPhrase) return value === false ? `${variable} ${isCheckPhrase.replace("is ", "is not ")}` : `${variable} ${isCheckPhrase}`;
634
+ const isPath = operatorKey.endsWith("Path");
635
+ const baseKey = isPath ? operatorKey.slice(0, -4) : operatorKey;
636
+ const prefix = COMPARISON_TYPE_PREFIXES.find((typePrefix) => baseKey.startsWith(typePrefix));
637
+ if (!prefix) return null;
638
+ const suffix = baseKey.slice(prefix.length);
639
+ const operator = COMPARISON_OPERATORS.find(([name]) => name === suffix);
640
+ if (!operator) return null;
641
+ const formattedValue = !isPath && (prefix === "String" || prefix === "Timestamp") ? JSON.stringify(value) : String(value);
642
+ return `${variable} ${operator[1]} ${formattedValue}`;
643
+ }
644
+ /**
645
+ * Recursively describe a Choice rule, handling And/Or/Not combinators,
646
+ * the full set of typed comparison operators, presence checks, and JSONata conditions.
647
+ */
648
+ function describeChoiceRule(rule) {
649
+ if (rule.Condition !== void 0) return typeof rule.Condition === "string" ? cleanJsonataExpression(rule.Condition) : String(rule.Condition);
650
+ if (Array.isArray(rule.And)) {
651
+ const parts = rule.And.map(describeChoiceRule).filter(Boolean);
652
+ return parts.length > 0 ? parts.join(" AND ") : "";
653
+ }
654
+ if (Array.isArray(rule.Or)) {
655
+ const parts = rule.Or.map(describeChoiceRule).filter(Boolean);
656
+ return parts.length > 0 ? parts.join(" OR ") : "";
657
+ }
658
+ if (rule.Not && typeof rule.Not === "object") {
659
+ const inner = describeChoiceRule(rule.Not);
660
+ return inner ? `NOT (${inner})` : "";
661
+ }
662
+ const variable = rule.Variable || "";
663
+ for (const [operatorKey, value] of Object.entries(rule)) {
664
+ const formatted = formatComparison(variable, operatorKey, value);
665
+ if (formatted) return formatted;
666
+ }
667
+ return "";
668
+ }
577
669
  function extractConditionLabel(choice) {
578
- const conditions = [];
579
- const variable = choice.Variable || "";
580
- if (choice.StringEquals !== void 0) conditions.push(`${variable} == "${choice.StringEquals}"`);
581
- if (choice.NumericEquals !== void 0) conditions.push(`${variable} == ${choice.NumericEquals}`);
582
- if (choice.BooleanEquals !== void 0) conditions.push(`${variable} == ${choice.BooleanEquals}`);
583
- return conditions.join(" AND ") || EDGE_LABELS.CONDITION_FALLBACK;
670
+ return describeChoiceRule(choice) || EDGE_LABELS.CONDITION_FALLBACK;
671
+ }
672
+ /**
673
+ * Resolve a Map state's inline processor definition.
674
+ * Prefers the modern `ItemProcessor` field (used by inline and Distributed Map)
675
+ * and falls back to the legacy `Iterator` field for pre-2022 definitions.
676
+ */
677
+ function getMapProcessor(state) {
678
+ return state.ItemProcessor ?? state.Iterator;
584
679
  }
585
680
  /**
586
681
  * Recursively extract all states including those nested in Parallel branches and Map iterators
@@ -626,8 +721,9 @@ function extractStatesRecursively(params) {
626
721
  nodeIndex
627
722
  });
628
723
  });
629
- if (state.Type === "Map" && state.Iterator) {
630
- const iterator = state.Iterator;
724
+ const mapProcessor = state.Type === "Map" ? getMapProcessor(state) : void 0;
725
+ if (state.Type === "Map" && mapProcessor) {
726
+ const iterator = mapProcessor;
631
727
  extractStatesRecursively({
632
728
  definition: iterator,
633
729
  nodeIndex,
@@ -716,15 +812,16 @@ function extractNestedEdges(params) {
716
812
  options
717
813
  });
718
814
  });
719
- if (state.Type === "Map" && state.Iterator) {
815
+ const mapProcessor = state.Type === "Map" ? getMapProcessor(state) : void 0;
816
+ if (state.Type === "Map" && mapProcessor) {
720
817
  const endNodeId = `${stateName}__iterator__end`;
721
818
  edges.push({
722
819
  from: stateName,
723
- to: state.Iterator.StartAt,
820
+ to: mapProcessor.StartAt,
724
821
  type: "normal",
725
822
  visualOnly: true
726
823
  });
727
- for (const [iteratorStateName, iteratorState] of Object.entries(state.Iterator.States)) {
824
+ for (const [iteratorStateName, iteratorState] of Object.entries(mapProcessor.States)) {
728
825
  const iteratorEdges = extractEdgesFromState({
729
826
  catchLabelStyle: options?.catchLabelStyle,
730
827
  state: iteratorState,
@@ -751,7 +848,7 @@ function extractNestedEdges(params) {
751
848
  });
752
849
  }
753
850
  extractNestedEdges({
754
- definition: state.Iterator,
851
+ definition: mapProcessor,
755
852
  edges,
756
853
  options
757
854
  });
@@ -781,6 +878,14 @@ var DagreLayout = class {
781
878
  ranksep: this.options.rankSeparation || 50
782
879
  });
783
880
  graph.setDefaultEdgeLabel(() => ({}));
881
+ const containerIds = new Set(nodes.filter((node) => node.isContainer).map((node) => node.id));
882
+ const containerChildren = new Map(nodes.filter((node) => node.isContainer).map((node) => [node.id, new Set(node.children || [])]));
883
+ const entryChildrenByContainer = /* @__PURE__ */ new Map();
884
+ for (const edge of edges) if (edge.visualOnly && containerChildren.get(edge.from)?.has(edge.to)) {
885
+ const entries = entryChildrenByContainer.get(edge.from) ?? [];
886
+ entries.push(edge.to);
887
+ entryChildrenByContainer.set(edge.from, entries);
888
+ }
784
889
  const layoutNodes = nodes.filter((node) => !node.isContainer);
785
890
  layoutNodes.forEach((node) => {
786
891
  const dimensions = this.getNodeDimensions(node);
@@ -792,6 +897,16 @@ var DagreLayout = class {
792
897
  });
793
898
  });
794
899
  edges.filter((edge) => !edge.visualOnly).forEach((edge) => {
900
+ const toIsContainer = containerIds.has(edge.to);
901
+ const fromIsContainer = containerIds.has(edge.from);
902
+ if (toIsContainer && !fromIsContainer) {
903
+ for (const child of entryChildrenByContainer.get(edge.to) ?? []) graph.setEdge(edge.from, child, {
904
+ label: edge.label,
905
+ type: edge.type
906
+ });
907
+ return;
908
+ }
909
+ if (fromIsContainer || toIsContainer) return;
795
910
  graph.setEdge(edge.from, edge.to, {
796
911
  label: edge.label,
797
912
  type: edge.type
@@ -816,7 +931,10 @@ var DagreLayout = class {
816
931
  const allPositionedNodes = [...positionedNodes, ...containerNodes];
817
932
  const positionedNodesById = new Map(allPositionedNodes.map((node) => [node.id, node]));
818
933
  const routedEdges = edges.map((edge) => {
819
- if (edge.visualOnly) return {
934
+ const fromNode = positionedNodesById.get(edge.from);
935
+ const toNode = positionedNodesById.get(edge.to);
936
+ const touchesContainer = Boolean(fromNode?.isContainer || toNode?.isContainer);
937
+ if (edge.visualOnly || touchesContainer) return {
820
938
  ...edge,
821
939
  points: this.calculateVisualEdgePoints({
822
940
  edge,
@@ -826,7 +944,7 @@ var DagreLayout = class {
826
944
  const dagEdge = graph.edge(edge.from, edge.to);
827
945
  return {
828
946
  ...edge,
829
- points: dagEdge.points
947
+ points: dagEdge?.points ?? []
830
948
  };
831
949
  });
832
950
  const graphDims = graph.graph();
@@ -894,6 +1012,26 @@ var DagreLayout = class {
894
1012
  const fromNode = positionedNodesById.get(edge.from);
895
1013
  const toNode = positionedNodesById.get(edge.to);
896
1014
  if (!fromNode || !toNode) return [];
1015
+ if (edge.from === edge.to) {
1016
+ const rightX = (fromNode.x || 0) + (fromNode.width || 0) / 2;
1017
+ const centerY = fromNode.y || 0;
1018
+ const loopReach = 40;
1019
+ const loopSpread = 12;
1020
+ return [
1021
+ {
1022
+ x: rightX,
1023
+ y: centerY - loopSpread
1024
+ },
1025
+ {
1026
+ x: rightX + loopReach,
1027
+ y: centerY
1028
+ },
1029
+ {
1030
+ x: rightX,
1031
+ y: centerY + loopSpread
1032
+ }
1033
+ ];
1034
+ }
897
1035
  if (fromNode.isContainer && fromNode.children?.includes(edge.to)) {
898
1036
  const headerHeight = 50;
899
1037
  const fromX = fromNode.x || 0;
@@ -1036,6 +1174,7 @@ var SvgRenderer = class {
1036
1174
  defs.append("marker").attr("id", "arrowhead-error").attr("markerWidth", 10).attr("markerHeight", 10).attr("refX", 9).attr("refY", 3).attr("orient", "auto").append("polygon").attr("points", "0 0, 10 3, 0 6").attr("fill", this.theme.edgeColors.error);
1037
1175
  defs.append("marker").attr("id", "arrowhead-choice").attr("markerWidth", 10).attr("markerHeight", 10).attr("refX", 9).attr("refY", 3).attr("orient", "auto").append("polygon").attr("points", "0 0, 10 3, 0 6").attr("fill", this.theme.edgeColors.choice);
1038
1176
  defs.append("marker").attr("id", "arrowhead-default").attr("markerWidth", 10).attr("markerHeight", 10).attr("refX", 9).attr("refY", 3).attr("orient", "auto").append("polygon").attr("points", "0 0, 10 3, 0 6").attr("fill", this.theme.edgeColors.default);
1177
+ defs.append("marker").attr("id", "arrowhead-retry").attr("markerWidth", 10).attr("markerHeight", 10).attr("refX", 9).attr("refY", 3).attr("orient", "auto").append("polygon").attr("points", "0 0, 10 3, 0 6").attr("fill", this.resolveEdgeColor("retry"));
1039
1178
  const edgesGroup = svg.append("g").attr("class", "edges");
1040
1179
  const containersGroup = svg.append("g").attr("class", "containers");
1041
1180
  const nodesGroup = svg.append("g").attr("class", "nodes");
@@ -1090,13 +1229,14 @@ var SvgRenderer = class {
1090
1229
  });
1091
1230
  layout.edges.forEach((edge) => {
1092
1231
  if (edge.points) edge.points.forEach((point) => {
1232
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
1093
1233
  minX = Math.min(minX, point.x);
1094
1234
  minY = Math.min(minY, point.y);
1095
1235
  maxX = Math.max(maxX, point.x);
1096
1236
  maxY = Math.max(maxY, point.y);
1097
1237
  });
1098
1238
  if (edge.label && edge.points && edge.points.length > 0) {
1099
- const midpoint = this.getPathMidpoint(edge.points);
1239
+ const midpoint = this.edgeLabelCenter(edge);
1100
1240
  const labelDimensions = this.calculateLabelDimensions(edge.label);
1101
1241
  const labelMinX = midpoint.x - labelDimensions.width / 2;
1102
1242
  const labelMaxX = midpoint.x + labelDimensions.width / 2;
@@ -1271,19 +1411,55 @@ var SvgRenderer = class {
1271
1411
  renderEdge(params) {
1272
1412
  const { edge, group } = params;
1273
1413
  if (!edge.points || edge.points.length < 2) return;
1274
- const edgeColor = this.theme.edgeColors[edge.type || "normal"];
1414
+ const edgeColor = this.resolveEdgeColor(edge.type);
1275
1415
  const markerType = edge.type || "normal";
1276
- const pathElement = group.append("path").attr("d", this.pathGenerator(edge.points) ?? "").attr("fill", "none").attr("stroke", edgeColor).attr("stroke-width", edge.type === "error" ? 2 : 1.5).attr("marker-end", `url(#arrowhead-${markerType})`);
1416
+ const pathData = edge.from === edge.to ? this.buildSelfLoopPath(edge.points) : this.pathGenerator(edge.points) ?? "";
1417
+ const pathElement = group.append("path").attr("d", pathData).attr("fill", "none").attr("stroke", edgeColor).attr("stroke-width", edge.type === "error" ? 2 : 1.5).attr("marker-end", `url(#arrowhead-${markerType})`);
1277
1418
  if (edge.type === "error") pathElement.attr("stroke-dasharray", "5,5");
1278
1419
  else if (edge.type === "default") pathElement.attr("stroke-dasharray", "8,4");
1420
+ else if (edge.type === "retry") pathElement.attr("stroke-dasharray", "4,3");
1279
1421
  if (edge.label) {
1280
- const midpoint = this.getPathMidpoint(edge.points);
1422
+ const midpoint = this.edgeLabelCenter(edge);
1281
1423
  const labelDimensions = this.calculateLabelDimensions(edge.label);
1282
1424
  group.append("rect").attr("x", midpoint.x - labelDimensions.width / 2).attr("y", midpoint.y - labelDimensions.height / 2).attr("width", labelDimensions.width).attr("height", labelDimensions.height).attr("fill", this.theme.background || "#ffffff").attr("stroke", edgeColor).attr("stroke-width", .5).attr("rx", 3);
1283
1425
  group.append("text").attr("x", midpoint.x).attr("y", midpoint.y).attr("text-anchor", "middle").attr("dominant-baseline", "middle").attr("fill", edgeColor).attr("font-size", this.theme.fontSize - 2).text(edge.label);
1284
1426
  }
1285
1427
  }
1286
1428
  /**
1429
+ * Resolve the stroke colour for an edge type, falling back to the error colour
1430
+ * (then normal) so themes that predate a given edge type still render.
1431
+ */
1432
+ resolveEdgeColor(type) {
1433
+ const colors = this.theme.edgeColors;
1434
+ return colors[type || "normal"] ?? colors.error ?? colors.normal;
1435
+ }
1436
+ /**
1437
+ * Build an SVG path for a self-loop edge from its [entry, apex, exit] points,
1438
+ * curving out to the apex and back so the arrow re-enters the node.
1439
+ */
1440
+ buildSelfLoopPath(points) {
1441
+ const [entry, apex, exit] = points;
1442
+ return `M ${entry.x},${entry.y} C ${apex.x},${apex.y} ${apex.x},${apex.y} ${exit.x},${exit.y}`;
1443
+ }
1444
+ /**
1445
+ * Compute where an edge's label should be centered. Normal edges center on the
1446
+ * path midpoint; self-loops (Retry) shift the label to the right of the loop apex
1447
+ * so a long policy label never overlaps the node it belongs to.
1448
+ */
1449
+ edgeLabelCenter(edge) {
1450
+ const points = edge.points ?? [];
1451
+ const midpoint = this.getPathMidpoint(points);
1452
+ if (edge.from === edge.to && edge.label) {
1453
+ const gap = 8;
1454
+ const labelWidth = this.calculateLabelDimensions(edge.label).width;
1455
+ return {
1456
+ x: midpoint.x + gap + labelWidth / 2,
1457
+ y: midpoint.y
1458
+ };
1459
+ }
1460
+ return midpoint;
1461
+ }
1462
+ /**
1287
1463
  * Get the midpoint of a path for label placement
1288
1464
  */
1289
1465
  getPathMidpoint(points) {
@@ -1578,6 +1754,20 @@ function generateMermaid(params) {
1578
1754
  //#endregion
1579
1755
  //#region src/exporters/PngExporter.ts
1580
1756
  /**
1757
+ * Lazily load node-html-to-image, which is an optional peer dependency.
1758
+ * Keeping it out of the static import graph means SVG/Mermaid-only consumers
1759
+ * never pull Puppeteer/Chromium, and the runtime error is actionable when it is
1760
+ * genuinely missing.
1761
+ */
1762
+ async function loadRenderer() {
1763
+ try {
1764
+ const mod = await import("node-html-to-image");
1765
+ return typeof mod === "function" ? mod : mod.default;
1766
+ } catch {
1767
+ throw new Error("PNG export requires the optional peer dependency 'node-html-to-image'. Install it with: npm install node-html-to-image");
1768
+ }
1769
+ }
1770
+ /**
1581
1771
  * PngExporter - Converts SVG to PNG using headless rendering
1582
1772
  */
1583
1773
  var PngExporter = class {
@@ -1594,13 +1784,14 @@ var PngExporter = class {
1594
1784
  */
1595
1785
  async convert(params) {
1596
1786
  const { svg, width, height } = params;
1787
+ const html = this.wrapSvgInHtml({
1788
+ svg,
1789
+ width,
1790
+ height
1791
+ });
1597
1792
  return {
1598
- buffer: await nodeHtmlToImage({
1599
- html: this.wrapSvgInHtml({
1600
- svg,
1601
- width,
1602
- height
1603
- }),
1793
+ buffer: await (await loadRenderer())({
1794
+ html,
1604
1795
  puppeteerArgs: { args: ["--no-sandbox", "--disable-setuid-sandbox"] },
1605
1796
  quality: this.options.pngQuality || 90,
1606
1797
  transparent: this.options.backgroundColor === "transparent",