@zombie-mermaid/ascii-renderer 2.2.1

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.
Files changed (232) hide show
  1. package/LICENSE +22 -0
  2. package/dist/index.cjs +8 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +146 -0
  5. package/dist/index.d.ts +146 -0
  6. package/dist/index.js +5392 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +36 -0
  9. package/src/__tests__/ascii-arrowhead-direction-1083.test.ts +51 -0
  10. package/src/__tests__/ascii-canvas-first-claim-wins-1093.test.ts +89 -0
  11. package/src/__tests__/ascii-canvas-size-offset-1093.test.ts +95 -0
  12. package/src/__tests__/ascii-canvas-write.test.ts +132 -0
  13. package/src/__tests__/ascii-chain-edge-overlap-1067.test.ts +100 -0
  14. package/src/__tests__/ascii-charset-border-junctions.test.ts +63 -0
  15. package/src/__tests__/ascii-cjk-width.test.ts +150 -0
  16. package/src/__tests__/ascii-class-box-occupancy.test.ts +462 -0
  17. package/src/__tests__/ascii-class-column-width-488-489.test.ts +639 -0
  18. package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
  19. package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +114 -0
  20. package/src/__tests__/ascii-class-diagram-compartments.test.ts +70 -0
  21. package/src/__tests__/ascii-class-label-box-collision.test.ts +60 -0
  22. package/src/__tests__/ascii-class-label-row-collision-531.test.ts +135 -0
  23. package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +57 -0
  24. package/src/__tests__/ascii-class-padding.test.ts +104 -0
  25. package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
  26. package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
  27. package/src/__tests__/ascii-class-reciprocal-relationships-448.test.ts +169 -0
  28. package/src/__tests__/ascii-combining-mark-width.test.ts +70 -0
  29. package/src/__tests__/ascii-coords-overlay.test.ts +64 -0
  30. package/src/__tests__/ascii-decision-lr-box-start.test.ts +106 -0
  31. package/src/__tests__/ascii-display-width-unit.test.ts +154 -0
  32. package/src/__tests__/ascii-draw-arrows-coverage.test.ts +224 -0
  33. package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +105 -0
  34. package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +72 -0
  35. package/src/__tests__/ascii-edge-ending-glyphs.test.ts +206 -0
  36. package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +95 -0
  37. package/src/__tests__/ascii-edge-routing-fixes.test.ts +178 -0
  38. package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +103 -0
  39. package/src/__tests__/ascii-edge-style-consistency-1067.test.ts +89 -0
  40. package/src/__tests__/ascii-edge-styles.test.ts +149 -0
  41. package/src/__tests__/ascii-emoji-cluster-width.test.ts +101 -0
  42. package/src/__tests__/ascii-er-box-occupancy.test.ts +251 -0
  43. package/src/__tests__/ascii-er-cardinality.test.ts +40 -0
  44. package/src/__tests__/ascii-er-corner-glyphs.test.ts +249 -0
  45. package/src/__tests__/ascii-er-jog-stray-line.test.ts +111 -0
  46. package/src/__tests__/ascii-er-label-padding.test.ts +118 -0
  47. package/src/__tests__/ascii-er-padding.test.ts +114 -0
  48. package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +458 -0
  49. package/src/__tests__/ascii-er-relationship-overwrite.test.ts +325 -0
  50. package/src/__tests__/ascii-er-stray-connectors.test.ts +344 -0
  51. package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +98 -0
  52. package/src/__tests__/ascii-er-vertical-one-marker.test.ts +110 -0
  53. package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +168 -0
  54. package/src/__tests__/ascii-lane-search.test.ts +207 -0
  55. package/src/__tests__/ascii-multibox-cjk-width.test.ts +184 -0
  56. package/src/__tests__/ascii-multiline.test.ts +288 -0
  57. package/src/__tests__/ascii-padding-edge-cases.test.ts +154 -0
  58. package/src/__tests__/ascii-pathfinder-route-edge.test.ts +184 -0
  59. package/src/__tests__/ascii-sequence-alt-else-label.test.ts +236 -0
  60. package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +217 -0
  61. package/src/__tests__/ascii-sequence-box-group.test.ts +159 -0
  62. package/src/__tests__/ascii-sequence-cjk-width.test.ts +235 -0
  63. package/src/__tests__/ascii-sequence-create-destroy.test.ts +114 -0
  64. package/src/__tests__/ascii-sequence-form-invariants.test.ts +432 -0
  65. package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +219 -0
  66. package/src/__tests__/ascii-sequence-notes.test.ts +61 -0
  67. package/src/__tests__/ascii-sequence-padding.test.ts +119 -0
  68. package/src/__tests__/ascii-sequence-self-arrow.test.ts +206 -0
  69. package/src/__tests__/ascii-shape-diamond.test.ts +38 -0
  70. package/src/__tests__/ascii-shape-rectangle.test.ts +258 -0
  71. package/src/__tests__/ascii-shape-rounded.test.ts +36 -0
  72. package/src/__tests__/ascii-shapes-circle.test.ts +41 -0
  73. package/src/__tests__/ascii-shapes-hexagon.test.ts +42 -0
  74. package/src/__tests__/ascii-shapes-special.test.ts +344 -0
  75. package/src/__tests__/ascii-shapes-stadium.test.ts +217 -0
  76. package/src/__tests__/ascii-shapes-state.test.ts +224 -0
  77. package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +130 -0
  78. package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +90 -0
  79. package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +152 -0
  80. package/src/__tests__/ascii-subgraph-title-padding.test.ts +77 -0
  81. package/src/__tests__/ascii-territory-unit.test.ts +219 -0
  82. package/src/__tests__/ascii-validate.test.ts +220 -0
  83. package/src/__tests__/ascii.test.ts +325 -0
  84. package/src/__tests__/class-arrow-directions.test.ts +505 -0
  85. package/src/__tests__/draw-lines.test.ts +93 -0
  86. package/src/__tests__/edge-cell-styles.test.ts +278 -0
  87. package/src/__tests__/grid-occupancy.test.ts +240 -0
  88. package/src/__tests__/helpers/ascii-form.ts +142 -0
  89. package/src/__tests__/helpers/terminal-display-width.ts +74 -0
  90. package/src/__tests__/pathfinder.test.ts +239 -0
  91. package/src/__tests__/testdata/ascii/ampersand_lhs.txt +18 -0
  92. package/src/__tests__/testdata/ascii/ampersand_lhs_and_rhs.txt +18 -0
  93. package/src/__tests__/testdata/ascii/ampersand_rhs.txt +18 -0
  94. package/src/__tests__/testdata/ascii/ampersand_td_fanin.txt +18 -0
  95. package/src/__tests__/testdata/ascii/ampersand_td_fanout.txt +18 -0
  96. package/src/__tests__/testdata/ascii/ampersand_without_edge.txt +18 -0
  97. package/src/__tests__/testdata/ascii/back_reference_from_child.txt +10 -0
  98. package/src/__tests__/testdata/ascii/backlink_from_bottom.txt +22 -0
  99. package/src/__tests__/testdata/ascii/backlink_from_top.txt +22 -0
  100. package/src/__tests__/testdata/ascii/backlink_with_short_y_padding.txt +20 -0
  101. package/src/__tests__/testdata/ascii/cls_all_relationships.txt +19 -0
  102. package/src/__tests__/testdata/ascii/cls_annotation.txt +29 -0
  103. package/src/__tests__/testdata/ascii/cls_association.txt +14 -0
  104. package/src/__tests__/testdata/ascii/cls_basic.txt +15 -0
  105. package/src/__tests__/testdata/ascii/cls_dependency.txt +14 -0
  106. package/src/__tests__/testdata/ascii/cls_inheritance.txt +20 -0
  107. package/src/__tests__/testdata/ascii/cls_methods.txt +21 -0
  108. package/src/__tests__/testdata/ascii/comments.txt +23 -0
  109. package/src/__tests__/testdata/ascii/custom_padding.txt +10 -0
  110. package/src/__tests__/testdata/ascii/duplicate_labels.txt +19 -0
  111. package/src/__tests__/testdata/ascii/er_attributes.txt +21 -0
  112. package/src/__tests__/testdata/ascii/er_basic.txt +8 -0
  113. package/src/__tests__/testdata/ascii/er_identifying.txt +18 -0
  114. package/src/__tests__/testdata/ascii/flowchart_tb_simple.txt +29 -0
  115. package/src/__tests__/testdata/ascii/graph_bt_direction.txt +28 -0
  116. package/src/__tests__/testdata/ascii/graph_tb_direction.txt +26 -0
  117. package/src/__tests__/testdata/ascii/nested_subgraphs_with_labels.txt +36 -0
  118. package/src/__tests__/testdata/ascii/preserve_order_of_definition.txt +23 -0
  119. package/src/__tests__/testdata/ascii/self_reference.txt +10 -0
  120. package/src/__tests__/testdata/ascii/self_reference_with_edge.txt +10 -0
  121. package/src/__tests__/testdata/ascii/seq_basic.txt +17 -0
  122. package/src/__tests__/testdata/ascii/seq_multiple_messages.txt +25 -0
  123. package/src/__tests__/testdata/ascii/seq_self_message.txt +18 -0
  124. package/src/__tests__/testdata/ascii/single_node.txt +8 -0
  125. package/src/__tests__/testdata/ascii/single_node_longer_name.txt +8 -0
  126. package/src/__tests__/testdata/ascii/subgraph_complex_mixed.txt +38 -0
  127. package/src/__tests__/testdata/ascii/subgraph_complex_nested.txt +49 -0
  128. package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +47 -0
  129. package/src/__tests__/testdata/ascii/subgraph_empty.txt +10 -0
  130. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes.txt +20 -0
  131. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +48 -0
  132. package/src/__tests__/testdata/ascii/subgraph_multiple_edges.txt +32 -0
  133. package/src/__tests__/testdata/ascii/subgraph_multiple_nodes.txt +16 -0
  134. package/src/__tests__/testdata/ascii/subgraph_nested.txt +24 -0
  135. package/src/__tests__/testdata/ascii/subgraph_nested_with_external.txt +30 -0
  136. package/src/__tests__/testdata/ascii/subgraph_node_outside_lr.txt +17 -0
  137. package/src/__tests__/testdata/ascii/subgraph_single_node.txt +16 -0
  138. package/src/__tests__/testdata/ascii/subgraph_td_direction.txt +26 -0
  139. package/src/__tests__/testdata/ascii/subgraph_td_multiple.txt +44 -0
  140. package/src/__tests__/testdata/ascii/subgraph_td_multiple_paddingy.txt +42 -0
  141. package/src/__tests__/testdata/ascii/subgraph_three_levels_nested.txt +32 -0
  142. package/src/__tests__/testdata/ascii/subgraph_three_separate.txt +24 -0
  143. package/src/__tests__/testdata/ascii/subgraph_two_separate.txt +20 -0
  144. package/src/__tests__/testdata/ascii/subgraph_with_labels.txt +20 -0
  145. package/src/__tests__/testdata/ascii/three_nodes.txt +9 -0
  146. package/src/__tests__/testdata/ascii/three_nodes_single_line.txt +8 -0
  147. package/src/__tests__/testdata/ascii/two_layer_single_graph.txt +19 -0
  148. package/src/__tests__/testdata/ascii/two_layer_single_graph_longer_names.txt +19 -0
  149. package/src/__tests__/testdata/ascii/two_nodes_linked.txt +8 -0
  150. package/src/__tests__/testdata/ascii/two_nodes_longer_names.txt +8 -0
  151. package/src/__tests__/testdata/ascii/two_root_nodes.txt +19 -0
  152. package/src/__tests__/testdata/ascii/two_root_nodes_longer_names.txt +19 -0
  153. package/src/__tests__/testdata/ascii/two_single_root_nodes.txt +19 -0
  154. package/src/__tests__/testdata/unicode/ampersand_lhs.txt +18 -0
  155. package/src/__tests__/testdata/unicode/ampersand_lhs_and_rhs.txt +18 -0
  156. package/src/__tests__/testdata/unicode/ampersand_rhs.txt +18 -0
  157. package/src/__tests__/testdata/unicode/ampersand_without_edge.txt +18 -0
  158. package/src/__tests__/testdata/unicode/back_reference_from_child.txt +10 -0
  159. package/src/__tests__/testdata/unicode/backlink_from_bottom.txt +22 -0
  160. package/src/__tests__/testdata/unicode/backlink_from_top.txt +22 -0
  161. package/src/__tests__/testdata/unicode/cls_all_relationships.txt +19 -0
  162. package/src/__tests__/testdata/unicode/cls_annotation.txt +29 -0
  163. package/src/__tests__/testdata/unicode/cls_association.txt +14 -0
  164. package/src/__tests__/testdata/unicode/cls_basic.txt +15 -0
  165. package/src/__tests__/testdata/unicode/cls_dependency.txt +14 -0
  166. package/src/__tests__/testdata/unicode/cls_inheritance.txt +20 -0
  167. package/src/__tests__/testdata/unicode/cls_methods.txt +21 -0
  168. package/src/__tests__/testdata/unicode/comments.txt +23 -0
  169. package/src/__tests__/testdata/unicode/duplicate_labels.txt +19 -0
  170. package/src/__tests__/testdata/unicode/er_attributes.txt +21 -0
  171. package/src/__tests__/testdata/unicode/er_basic.txt +8 -0
  172. package/src/__tests__/testdata/unicode/er_identifying.txt +18 -0
  173. package/src/__tests__/testdata/unicode/graph_bt_direction.txt +28 -0
  174. package/src/__tests__/testdata/unicode/preserve_order_of_definition.txt +23 -0
  175. package/src/__tests__/testdata/unicode/self_reference.txt +10 -0
  176. package/src/__tests__/testdata/unicode/self_reference_with_edge.txt +10 -0
  177. package/src/__tests__/testdata/unicode/seq_basic.txt +17 -0
  178. package/src/__tests__/testdata/unicode/seq_multiple_messages.txt +25 -0
  179. package/src/__tests__/testdata/unicode/seq_self_message.txt +18 -0
  180. package/src/__tests__/testdata/unicode/single_node.txt +8 -0
  181. package/src/__tests__/testdata/unicode/single_node_longer_name.txt +8 -0
  182. package/src/__tests__/testdata/unicode/three_nodes.txt +9 -0
  183. package/src/__tests__/testdata/unicode/three_nodes_single_line.txt +8 -0
  184. package/src/__tests__/testdata/unicode/two_layer_single_graph.txt +19 -0
  185. package/src/__tests__/testdata/unicode/two_layer_single_graph_longer_names.txt +19 -0
  186. package/src/__tests__/testdata/unicode/two_nodes_linked.txt +8 -0
  187. package/src/__tests__/testdata/unicode/two_nodes_longer_names.txt +8 -0
  188. package/src/__tests__/testdata/unicode/two_root_nodes.txt +19 -0
  189. package/src/__tests__/testdata/unicode/two_root_nodes_longer_names.txt +19 -0
  190. package/src/__tests__/testdata/unicode/two_single_root_nodes.txt +19 -0
  191. package/src/__tests__/xychart-ascii.test.ts +376 -0
  192. package/src/ansi.ts +490 -0
  193. package/src/canvas.ts +757 -0
  194. package/src/class-diagram.ts +2001 -0
  195. package/src/converter.ts +446 -0
  196. package/src/coords.ts +58 -0
  197. package/src/display-width.ts +151 -0
  198. package/src/draw-arrows.ts +593 -0
  199. package/src/draw-boxes.ts +267 -0
  200. package/src/draw-bundles.ts +611 -0
  201. package/src/draw-lines.ts +174 -0
  202. package/src/draw-subgraphs.ts +108 -0
  203. package/src/draw.ts +350 -0
  204. package/src/edge-bundling.ts +435 -0
  205. package/src/edge-cell-styles.ts +209 -0
  206. package/src/edge-routing.ts +1070 -0
  207. package/src/er-diagram.ts +1488 -0
  208. package/src/flowchart.ts +94 -0
  209. package/src/grid-occupancy.ts +234 -0
  210. package/src/grid.ts +1309 -0
  211. package/src/hyperlinks.ts +248 -0
  212. package/src/index.ts +163 -0
  213. package/src/lane-search.ts +68 -0
  214. package/src/multiline-utils.ts +82 -0
  215. package/src/pathfinder.ts +448 -0
  216. package/src/registry.ts +88 -0
  217. package/src/sequence.ts +1318 -0
  218. package/src/shapes/circle.ts +31 -0
  219. package/src/shapes/corners.ts +273 -0
  220. package/src/shapes/diamond.ts +31 -0
  221. package/src/shapes/hexagon.ts +35 -0
  222. package/src/shapes/index.ts +123 -0
  223. package/src/shapes/rectangle.ts +199 -0
  224. package/src/shapes/rounded.ts +31 -0
  225. package/src/shapes/special.ts +360 -0
  226. package/src/shapes/stadium.ts +122 -0
  227. package/src/shapes/state.ts +204 -0
  228. package/src/shapes/types.ts +78 -0
  229. package/src/territory.ts +136 -0
  230. package/src/types.ts +454 -0
  231. package/src/validate.ts +189 -0
  232. package/src/xychart.ts +1085 -0
@@ -0,0 +1,446 @@
1
+ // ============================================================================
2
+ // ASCII renderer — MermaidGraph → AsciiGraph converter
3
+ //
4
+ // Bridges the existing TypeScript parser output to the ASCII renderer's
5
+ // internal graph structure. This avoids maintaining a separate parser
6
+ // for ASCII rendering — we reuse parseMermaid() and convert its output.
7
+ // ============================================================================
8
+
9
+ import type { MermaidGraph, MermaidSubgraph } from '@zombie-mermaid/core'
10
+ import type {
11
+ AsciiGraph,
12
+ AsciiNode,
13
+ AsciiEdge,
14
+ AsciiSubgraph,
15
+ AsciiConfig,
16
+ } from './types.ts'
17
+ import { EMPTY_STYLE } from './types.ts'
18
+ import { mkCanvas, mkRoleCanvas } from './canvas.ts'
19
+ import { createGrid } from './grid-occupancy.ts'
20
+ import { stripFormattingTags } from '@zombie-mermaid/core'
21
+
22
+ /**
23
+ * Convert a parsed MermaidGraph into an AsciiGraph ready for grid layout.
24
+ *
25
+ * Key mappings:
26
+ * - MermaidGraph.nodes (Map) → ordered AsciiNode[] preserving insertion order
27
+ * - MermaidGraph.edges → AsciiEdge[] with resolved node references
28
+ * - MermaidGraph.subgraphs → AsciiSubgraph[] with parent/child tree
29
+ * - Node labels are used as display names (not raw IDs)
30
+ */
31
+ export function convertToAsciiGraph(
32
+ parsed: MermaidGraph,
33
+ config: AsciiConfig,
34
+ ): AsciiGraph {
35
+ // Collect subgraph ids (including nested) up front. When a flowchart edge
36
+ // references a subgraph id directly (e.g. `ONE --> TWO` where ONE/TWO are
37
+ // subgraph ids, not nodes — see issue #65), the parser has no way to know
38
+ // that at parse time and registers a phantom node with that id instead.
39
+ // The ELK/SVG path resolves this by never materializing a top-level node
40
+ // for a subgraph id and letting the edge reference the compound subgraph
41
+ // node directly (see `mermaidToElk` in to-elk.ts). The ASCII grid has no
42
+ // equivalent "compound node as edge endpoint" concept, so instead we
43
+ // suppress the phantom node here and, below, redirect any edge that
44
+ // targets a subgraph id to a real member node at that subgraph's
45
+ // boundary — see `resolveSubgraphEndpoint`.
46
+ const subgraphIds = new Set<string>()
47
+ const subgraphById = new Map<string, MermaidSubgraph>()
48
+ for (const mSg of parsed.subgraphs) {
49
+ indexSubgraph(mSg, subgraphIds, subgraphById)
50
+ }
51
+
52
+ // Build node list preserving Map insertion order
53
+ const nodeMap = new Map<string, AsciiNode>()
54
+ let index = 0
55
+
56
+ for (const [id, mNode] of parsed.nodes) {
57
+ // Skip phantom nodes that only exist because a subgraph id was
58
+ // referenced as an edge endpoint — see comment above.
59
+ if (subgraphIds.has(id)) continue
60
+
61
+ const asciiNode: AsciiNode = {
62
+ // Use the parser ID as the unique identity key to avoid collisions
63
+ // when multiple nodes share the same label (e.g. A[Web Server], C[Web Server]).
64
+ name: id,
65
+ // The label is used for rendering inside the box. Inline formatting
66
+ // tags (<b>, <i>, <em>, <strong>, ...) are meaningful to the SVG
67
+ // renderer (rendered as styled tspans) but the ASCII renderer has no
68
+ // equivalent concept, so they're stripped here rather than shown
69
+ // literally — see issue #65.
70
+ displayLabel: stripFormattingTags(mNode.label),
71
+ // Preserve shape from parser for shape-aware rendering
72
+ shape: mNode.shape,
73
+ index,
74
+ gridCoord: null,
75
+ drawingCoord: null,
76
+ drawing: null,
77
+ drawn: false,
78
+ styleClassName: '',
79
+ styleClass: EMPTY_STYLE,
80
+ }
81
+ nodeMap.set(id, asciiNode)
82
+ index++
83
+ }
84
+
85
+ const nodes = [...nodeMap.values()]
86
+
87
+ // Build edges with resolved node references
88
+ const edges: AsciiEdge[] = []
89
+ for (const mEdge of parsed.edges) {
90
+ let sourceId = mEdge.source
91
+ let targetId = mEdge.target
92
+
93
+ if (subgraphIds.has(sourceId)) {
94
+ const mSg = subgraphById.get(sourceId)
95
+ const resolved = mSg
96
+ ? resolveSubgraphEndpoint(mSg, parsed, 'exit')
97
+ : undefined
98
+ if (resolved) sourceId = resolved
99
+ }
100
+ if (subgraphIds.has(targetId)) {
101
+ const mSg = subgraphById.get(targetId)
102
+ const resolved = mSg
103
+ ? resolveSubgraphEndpoint(mSg, parsed, 'entry')
104
+ : undefined
105
+ if (resolved) targetId = resolved
106
+ }
107
+
108
+ const from = nodeMap.get(sourceId)
109
+ const to = nodeMap.get(targetId)
110
+ if (!from || !to) continue
111
+
112
+ edges.push({
113
+ from,
114
+ to,
115
+ text: mEdge.label ? stripFormattingTags(mEdge.label) : '',
116
+ path: [],
117
+ labelLine: [],
118
+ startDir: { x: 0, y: 0 },
119
+ endDir: { x: 0, y: 0 },
120
+ style: mEdge.style,
121
+ hasArrowStart: mEdge.hasArrowStart,
122
+ hasArrowEnd: mEdge.hasArrowEnd,
123
+ ...(mEdge.startMarker !== undefined
124
+ ? { startMarker: mEdge.startMarker }
125
+ : {}),
126
+ ...(mEdge.endMarker !== undefined ? { endMarker: mEdge.endMarker } : {}),
127
+ })
128
+ }
129
+
130
+ // Convert subgraphs recursively
131
+ const subgraphs: AsciiSubgraph[] = []
132
+ for (const mSg of parsed.subgraphs) {
133
+ convertSubgraph(mSg, null, nodeMap, subgraphs, parsed)
134
+ }
135
+
136
+ // Deduplicate subgraph node membership to match Go parser behavior.
137
+ // In Go, a node belongs only to the subgraph where it was FIRST DEFINED.
138
+ // The TS parser adds referenced nodes to all subgraphs they appear in,
139
+ // which causes incorrect bounding boxes when nodes span subgraph boundaries.
140
+ deduplicateSubgraphNodes(parsed.subgraphs, subgraphs, nodeMap)
141
+
142
+ /*
143
+ * `classDef default` is Mermaid's implicit base style for every node, not
144
+ * just for nodes that name it. Apply it to all nodes first so an explicit
145
+ * assignment below can override it property by property.
146
+ */
147
+ const defaultDef = parsed.classDefs.get('default')
148
+ if (defaultDef) {
149
+ for (const node of nodeMap.values()) {
150
+ node.styleClassName = 'default'
151
+ node.styleClass = { name: 'default', styles: defaultDef }
152
+ }
153
+ }
154
+
155
+ // Apply class definitions
156
+ for (const [nodeId, className] of parsed.classAssignments) {
157
+ const node = nodeMap.get(nodeId)
158
+ const classDef = parsed.classDefs.get(className)
159
+ if (node && classDef) {
160
+ node.styleClassName = className
161
+ node.styleClass = {
162
+ name: className,
163
+ styles: defaultDef ? { ...defaultDef, ...classDef } : classDef,
164
+ }
165
+ }
166
+ }
167
+
168
+ return {
169
+ nodes,
170
+ edges,
171
+ canvas: mkCanvas(0, 0),
172
+ roleCanvas: mkRoleCanvas(0, 0),
173
+ grid: createGrid(),
174
+ columnWidth: new Map(),
175
+ rowHeight: new Map(),
176
+ subgraphs,
177
+ config,
178
+ offsetX: 0,
179
+ offsetY: 0,
180
+ bundles: [], // Populated by analyzeEdgeBundles() during layout
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Recursively index a subgraph (and its nested children) by id, for
186
+ * resolving subgraph-id edge endpoints — see issue #65 and the comment in
187
+ * `convertToAsciiGraph`.
188
+ */
189
+ function indexSubgraph(
190
+ mSg: MermaidSubgraph,
191
+ subgraphIds: Set<string>,
192
+ subgraphById: Map<string, MermaidSubgraph>,
193
+ ): void {
194
+ subgraphIds.add(mSg.id)
195
+ subgraphById.set(mSg.id, mSg)
196
+ for (const child of mSg.children) {
197
+ indexSubgraph(child, subgraphIds, subgraphById)
198
+ }
199
+ }
200
+
201
+ /** Recursively collect every node id that's a member of `mSg` or its nested children. */
202
+ function collectAllMemberNodeIds(mSg: MermaidSubgraph, out: Set<string>): void {
203
+ for (const id of mSg.nodeIds) out.add(id)
204
+ for (const child of mSg.children) collectAllMemberNodeIds(child, out)
205
+ }
206
+
207
+ /**
208
+ * Real mermaid.js ignores a subgraph's own `direction` override once any of
209
+ * its member nodes (including nested descendants) has an edge to something
210
+ * outside the subgraph — the subgraph then inherits the parent graph's
211
+ * direction instead. Per the docs: "If any of a subgraph's nodes are linked
212
+ * to the outside, subgraph direction will be ignored. Instead the subgraph
213
+ * will inherit the direction of the parent graph."
214
+ * https://mermaid.js.org/syntax/flowchart.html
215
+ *
216
+ * Verified against real mermaid.js output for issue #445 (`graph TD` +
217
+ * `direction LR` subgraph with `E --> A` / `D --> F` edges crossing the
218
+ * boundary): all member nodes render at the same x-coordinate with
219
+ * increasing y, i.e. stacked top-down per the outer `TD` direction, despite
220
+ * the subgraph's own `direction LR`.
221
+ */
222
+ function subgraphDirectionIsHonored(
223
+ mSg: MermaidSubgraph,
224
+ parsed: MermaidGraph,
225
+ ): boolean {
226
+ const memberIds = new Set<string>()
227
+ collectAllMemberNodeIds(mSg, memberIds)
228
+ if (memberIds.size === 0) return true
229
+
230
+ const isInside = (id: string): boolean => memberIds.has(id) || id === mSg.id
231
+
232
+ return !parsed.edges.some((e) => isInside(e.source) !== isInside(e.target))
233
+ }
234
+
235
+ /**
236
+ * Pick a real member node to stand in for a subgraph when it's used as an
237
+ * edge endpoint (e.g. `ONE --> TWO` where ONE/TWO are subgraph ids — issue
238
+ * #65). The ASCII grid can only route edges between real nodes, so an edge
239
+ * "into" the subgraph is anchored to the subgraph's entry (root) node — a
240
+ * member with no incoming edge from another member of the same subgraph —
241
+ * and an edge "out of" the subgraph is anchored to its exit (leaf) node —
242
+ * a member with no outgoing edge to another member. That mirrors how the
243
+ * edge would visually enter/exit the subgraph's frame, reusing the
244
+ * existing (already-working) cross-subgraph edge-routing path instead of
245
+ * inventing a new "compound node" concept in the grid layout.
246
+ */
247
+ function resolveSubgraphEndpoint(
248
+ mSg: MermaidSubgraph,
249
+ parsed: MermaidGraph,
250
+ end: 'entry' | 'exit',
251
+ ): string | undefined {
252
+ const memberIds = new Set<string>()
253
+ collectAllMemberNodeIds(mSg, memberIds)
254
+ if (memberIds.size === 0) return undefined
255
+
256
+ // Preserve overall node declaration order for deterministic selection.
257
+ const orderedIds = [...parsed.nodes.keys()].filter((id) => memberIds.has(id))
258
+ if (orderedIds.length === 0) return undefined
259
+
260
+ const candidates = orderedIds.filter((id) =>
261
+ end === 'entry'
262
+ ? !parsed.edges.some((e) => e.target === id && memberIds.has(e.source))
263
+ : !parsed.edges.some((e) => e.source === id && memberIds.has(e.target)),
264
+ )
265
+
266
+ const pool = candidates.length > 0 ? candidates : orderedIds
267
+ return end === 'entry' ? pool[0] : pool[pool.length - 1]
268
+ }
269
+
270
+ /**
271
+ * Recursively convert a MermaidSubgraph to AsciiSubgraph.
272
+ * Flattens the tree into the subgraphs array while maintaining parent/child references.
273
+ * This matches the Go implementation where all subgraphs are in a flat list
274
+ * but linked via parent/children pointers.
275
+ */
276
+ function convertSubgraph(
277
+ mSg: MermaidSubgraph,
278
+ parent: AsciiSubgraph | null,
279
+ nodeMap: Map<string, AsciiNode>,
280
+ allSubgraphs: AsciiSubgraph[],
281
+ parsed: MermaidGraph,
282
+ ): AsciiSubgraph {
283
+ // Normalize subgraph direction: BT→TD, RL→LR (same as root graph normalization).
284
+ // A subgraph with an edge crossing its own boundary loses its direction
285
+ // override in real mermaid.js (see subgraphDirectionIsHonored) — skip
286
+ // normalizing it in that case so it falls through to the parent's
287
+ // direction like everything else.
288
+ let normalizedDirection: 'LR' | 'TD' | undefined
289
+ if (mSg.direction && subgraphDirectionIsHonored(mSg, parsed)) {
290
+ normalizedDirection =
291
+ mSg.direction === 'LR' || mSg.direction === 'RL' ? 'LR' : 'TD'
292
+ }
293
+
294
+ const sg: AsciiSubgraph = {
295
+ name: mSg.label,
296
+ nodes: [],
297
+ parent,
298
+ children: [],
299
+ minX: 0,
300
+ minY: 0,
301
+ maxX: 0,
302
+ maxY: 0,
303
+ direction: normalizedDirection,
304
+ }
305
+
306
+ // Resolve node references
307
+ for (const nodeId of mSg.nodeIds) {
308
+ const node = nodeMap.get(nodeId)
309
+ if (node) sg.nodes.push(node)
310
+ }
311
+
312
+ allSubgraphs.push(sg)
313
+
314
+ // Recurse into children
315
+ for (const childMSg of mSg.children) {
316
+ const child = convertSubgraph(childMSg, sg, nodeMap, allSubgraphs, parsed)
317
+ sg.children.push(child)
318
+
319
+ // Child nodes are also part of parent subgraphs (Go behavior).
320
+ // The Go parser adds nodes to ALL subgraphs in the stack, so a nested
321
+ // node belongs to both the inner and outer subgraph.
322
+ for (const childNode of child.nodes) {
323
+ if (!sg.nodes.includes(childNode)) {
324
+ sg.nodes.push(childNode)
325
+ }
326
+ }
327
+ }
328
+
329
+ return sg
330
+ }
331
+
332
+ /**
333
+ * Deduplicate subgraph node membership to match Go parser behavior.
334
+ *
335
+ * The Go parser only adds a node to the subgraph that was active when the node
336
+ * was FIRST CREATED. If a node is later referenced inside a different subgraph,
337
+ * it is NOT added to that subgraph. The TS parser is more permissive — it adds
338
+ * referenced nodes to whichever subgraph they appear in.
339
+ *
340
+ * This function fixes the discrepancy by:
341
+ * 1. Walking the edges to determine which nodes were first created inside each subgraph
342
+ * 2. Removing nodes from subgraphs where they weren't first created
343
+ */
344
+ function deduplicateSubgraphNodes(
345
+ mermaidSubgraphs: MermaidSubgraph[],
346
+ asciiSubgraphs: AsciiSubgraph[],
347
+ nodeMap: Map<string, AsciiNode>,
348
+ ): void {
349
+ // Build a map from MermaidSubgraph to its corresponding AsciiSubgraph.
350
+ // The ordering matches since we convert them in the same order.
351
+ const sgMap = new Map<MermaidSubgraph, AsciiSubgraph>()
352
+ buildSgMap(mermaidSubgraphs, asciiSubgraphs, sgMap)
353
+
354
+ // Determine which subgraph each node was "first defined" in.
355
+ // A node is first defined in the subgraph where it first appears as a NEW node
356
+ // in the ordered edge/node list. We approximate this by checking the global
357
+ // node insertion order against subgraph membership.
358
+ const nodeOwner = new Map<string, AsciiSubgraph>() // nodeId → owning subgraph
359
+
360
+ // Walk all mermaid subgraphs in document order. For each subgraph,
361
+ // claim nodes that haven't been claimed yet by any previous subgraph.
362
+ function claimNodes(mSg: MermaidSubgraph): void {
363
+ const asciiSg = sgMap.get(mSg)
364
+ if (!asciiSg) return
365
+
366
+ // Recurse into children first (they appear before parent in the Go parser stack,
367
+ // but nodes defined in children are added to parent too — this is handled by
368
+ // the convertSubgraph function which propagates child nodes to parents).
369
+ // For dedup, we process children first so their claims propagate up correctly.
370
+ for (const child of mSg.children) {
371
+ claimNodes(child)
372
+ }
373
+
374
+ // Claim unclaimed nodes in this subgraph
375
+ for (const nodeId of mSg.nodeIds) {
376
+ if (!nodeOwner.has(nodeId)) {
377
+ nodeOwner.set(nodeId, asciiSg)
378
+ }
379
+ }
380
+ }
381
+
382
+ for (const mSg of mermaidSubgraphs) {
383
+ claimNodes(mSg)
384
+ }
385
+
386
+ // Now remove nodes from subgraphs that don't own them.
387
+ // A node should remain in: its owner subgraph + all ancestors of the owner.
388
+ for (const asciiSg of asciiSubgraphs) {
389
+ asciiSg.nodes = asciiSg.nodes.filter((node) => {
390
+ // Find this node's ID in the nodeMap
391
+ let nodeId: string | undefined
392
+ for (const [id, n] of nodeMap) {
393
+ if (n === node) {
394
+ nodeId = id
395
+ break
396
+ }
397
+ }
398
+ if (!nodeId) return false
399
+
400
+ const owner = nodeOwner.get(nodeId)
401
+ if (!owner) return true // not in any subgraph claim — keep as-is
402
+
403
+ // Keep the node if this subgraph is the owner or an ancestor of the owner
404
+ return isAncestorOrSelf(asciiSg, owner)
405
+ })
406
+ }
407
+ }
408
+
409
+ /** Check if `candidate` is the same as or an ancestor of `target`. */
410
+ function isAncestorOrSelf(
411
+ candidate: AsciiSubgraph,
412
+ target: AsciiSubgraph,
413
+ ): boolean {
414
+ let current: AsciiSubgraph | null = target
415
+ while (current !== null) {
416
+ if (current === candidate) return true
417
+ current = current.parent
418
+ }
419
+ return false
420
+ }
421
+
422
+ /** Build a mapping from MermaidSubgraph → AsciiSubgraph (matching by position). */
423
+ function buildSgMap(
424
+ mSgs: MermaidSubgraph[],
425
+ aSgs: AsciiSubgraph[],
426
+ result: Map<MermaidSubgraph, AsciiSubgraph>,
427
+ ): void {
428
+ // The asciiSubgraphs array is flat (all subgraphs including nested ones),
429
+ // while mermaidSubgraphs is hierarchical. We need to flatten the mermaid tree
430
+ // in the same order the converter processes them (pre-order DFS).
431
+ const flatMermaid: MermaidSubgraph[] = []
432
+ function flatten(sgs: MermaidSubgraph[]): void {
433
+ for (const sg of sgs) {
434
+ flatMermaid.push(sg)
435
+ flatten(sg.children)
436
+ }
437
+ }
438
+ flatten(mSgs)
439
+
440
+ for (let i = 0; i < flatMermaid.length && i < aSgs.length; i++) {
441
+ const mSg = flatMermaid[i]
442
+ const aSg = aSgs[i]
443
+ if (!mSg || !aSg) continue
444
+ result.set(mSg, aSg)
445
+ }
446
+ }
package/src/coords.ts ADDED
@@ -0,0 +1,58 @@
1
+ // ============================================================================
2
+ // ASCII renderer — debug coordinate overlay (`--coords`)
3
+ //
4
+ // Annotates a finished, rendered ASCII/Unicode string with spreadsheet-style
5
+ // row/column indices — numbered column headers across the top, row numbers
6
+ // down the left side — to help debug layout spacing (padding, box sizes,
7
+ // edge routing) without having to count characters by hand.
8
+ //
9
+ // This deliberately overlays the *character* grid of the final rendered
10
+ // output, not the internal logical grid used by grid.ts (AsciiGraph's
11
+ // node-placement lattice, where each node occupies a 3x3 block on a 4-unit
12
+ // step). The internal grid has no 1:1 relationship with rendered character
13
+ // columns/rows (column widths and row heights vary per node), so annotating
14
+ // it directly wouldn't produce a marker a user looking at the printed
15
+ // diagram could actually line up against. The character grid is what's
16
+ // visible on screen, so its indices are what's useful to overlay.
17
+ // ============================================================================
18
+
19
+ import { stripOsc8 } from './hyperlinks.ts'
20
+
21
+ /**
22
+ * Add a column-index ruler (two rows: tens digit, ones digit) above the
23
+ * diagram and a row-index gutter to the left of each line.
24
+ *
25
+ * Purely a string transform over the already-rendered output — safe to
26
+ * apply regardless of diagram type, color mode, or Unicode/ASCII mode.
27
+ */
28
+ // Matches SGR color escape sequences (`\x1b[...m`) produced by ansi.ts's
29
+ // ansi16/ansi256/truecolor modes. Stripped — along with any OSC 8
30
+ // hyperlink sequences (hyperlinks.ts) — only for width MEASUREMENT below;
31
+ // the original lines (with codes intact) are still what gets printed.
32
+ const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g
33
+
34
+ export function addCoordsOverlay(rendered: string): string {
35
+ const lines = rendered.split('\n')
36
+ const width = lines.reduce(
37
+ (max, line) =>
38
+ Math.max(max, stripOsc8(line.replace(ANSI_ESCAPE, '')).length),
39
+ 0,
40
+ )
41
+ const rowGutterWidth = String(Math.max(0, lines.length - 1)).length
42
+
43
+ const gutter = ' '.repeat(rowGutterWidth + 1)
44
+ const tensRow =
45
+ gutter +
46
+ Array.from({ length: width }, (_, x) =>
47
+ String(Math.floor(x / 10) % 10),
48
+ ).join('')
49
+ const onesRow =
50
+ gutter + Array.from({ length: width }, (_, x) => String(x % 10)).join('')
51
+
52
+ const bodyLines = lines.map((line, y) => {
53
+ const rowLabel = String(y).padStart(rowGutterWidth, ' ')
54
+ return `${rowLabel} ${line}`
55
+ })
56
+
57
+ return [tensRow, onesRow, ...bodyLines].join('\n')
58
+ }
@@ -0,0 +1,151 @@
1
+ // ============================================================================
2
+ // ASCII renderer — display width helpers
3
+ //
4
+ // The ASCII grid is column-major with one grid cell reserved per *grapheme
5
+ // cluster* — the Unicode notion of a single user-perceived character, which
6
+ // may span several JS code points. Measuring by code point instead breaks in
7
+ // two opposite directions:
8
+ //
9
+ // - A combining mark (e.g. decomposed "e" + U+0301 COMBINING ACUTE ACCENT)
10
+ // is its own code point but claims no terminal column of its own — it
11
+ // attaches to the preceding base character. Counting it as a full column
12
+ // overcounts (issue #205).
13
+ // - A composed emoji sequence (ZWJ family emoji, flag via regional
14
+ // indicators, skin-tone modifier) is several code points that render as
15
+ // ONE glyph occupying at most 2 terminal columns. Counting each code
16
+ // point separately overcounts even more badly (issue #214).
17
+ //
18
+ // Segmenting by grapheme cluster (`Intl.Segmenter`) and measuring each
19
+ // cluster as a unit fixes both: a cluster is 2 columns if any code point
20
+ // within it is "wide" (CJK etc, via the same `isWideChar` the SVG
21
+ // text-measurement path uses), else 1 column — except a cluster made
22
+ // entirely of combining marks with no base character, which is 0 columns
23
+ // (the degenerate case the general Unicode categories Mn/Me describe;
24
+ // ordinarily unreachable since a mark attaches to a preceding base within
25
+ // the same cluster, but handled explicitly for a lone/leading mark).
26
+ //
27
+ // This module is the single source of truth for "how many terminal columns
28
+ // does this text occupy" and "how many grid cells does writing it need"
29
+ // across the ASCII renderer.
30
+ // ============================================================================
31
+
32
+ import { isWideChar } from '@zombie-mermaid/core'
33
+
34
+ /**
35
+ * Placeholder written into the grid cell immediately following a wide
36
+ * grapheme cluster. It reserves a grid column — keeping every subsequent
37
+ * cell's x-index aligned with the same column in other rows (borders, other
38
+ * labels, etc.) — but contributes no text when a canvas row is joined into
39
+ * a string, because the wide glyph itself already renders across two
40
+ * terminal columns.
41
+ */
42
+ export const WIDE_CHAR_PLACEHOLDER = ''
43
+
44
+ /** Unicode general categories Mn (Nonspacing_Mark) and Me (Enclosing_Mark). */
45
+ const COMBINING_MARK_REGEX = /\p{Mn}|\p{Me}/u
46
+
47
+ function isCombiningMark(char: string): boolean {
48
+ return COMBINING_MARK_REGEX.test(char)
49
+ }
50
+
51
+ const graphemeSegmenter = new Intl.Segmenter(undefined, {
52
+ granularity: 'grapheme',
53
+ })
54
+
55
+ /**
56
+ * U+FE0F VARIATION SELECTOR-16 explicitly requests the emoji (wide)
57
+ * presentation for the preceding base character, overriding whatever that
58
+ * character's own default presentation is. `isWideChar` only ever sees one
59
+ * isolated code point at a time (it's shared with the SVG text-measurement
60
+ * path, which has no grapheme-cluster concept), so it has no way to look
61
+ * ahead for a following VS16 — that requires cluster-level context, which
62
+ * only exists here. A base character normally classified narrow (e.g. ▶
63
+ * U+25B6, excluded from `isWideChar` as a Geometric Shapes glyph — see
64
+ * `isGeometricShapesTextDefault` in text-metrics.ts) still renders as a
65
+ * double-width emoji glyph in ▶️ once VS16 forces emoji presentation.
66
+ */
67
+ const VARIATION_SELECTOR_16 = '\u{FE0F}'
68
+
69
+ /**
70
+ * Split text into grapheme clusters — user-perceived characters. A cluster
71
+ * may span multiple JS code points: a combining mark attaches to its base
72
+ * character, and a ZWJ emoji sequence / flag / skin-tone modifier sequence
73
+ * forms a single rendered glyph. Naive code-point iteration (`for...of`)
74
+ * tears these apart, which is the root cause of both #205 and #214.
75
+ */
76
+ function graphemeClusters(text: string): string[] {
77
+ const clusters: string[] = []
78
+ for (const { segment } of graphemeSegmenter.segment(text)) {
79
+ clusters.push(segment)
80
+ }
81
+ return clusters
82
+ }
83
+
84
+ /**
85
+ * Number of terminal display columns a single grapheme cluster occupies.
86
+ * Always 0, 1, or 2 for the ASCII grid (unlike the fractional SVG metrics in
87
+ * `text-metrics.ts`, which model proportional-font rendering).
88
+ *
89
+ * - 2 if any code point in the cluster is "wide" (`isWideChar`) — covers
90
+ * both plain CJK/fullwidth characters and composed emoji sequences, since
91
+ * the sequence's base emoji code point is itself flagged wide.
92
+ * - 0 if the cluster consists entirely of combining marks (Mn/Me) with no
93
+ * base character — a degenerate case in practice, since a mark normally
94
+ * attaches to a preceding base within the same cluster instead of forming
95
+ * a standalone one.
96
+ * - 1 otherwise, including a base character followed by zero-width
97
+ * combining marks (e.g. decomposed "é"), since the marks contribute no
98
+ * additional column within their cluster.
99
+ *
100
+ * A cluster containing VS16 (see `VARIATION_SELECTOR_16` above) is always
101
+ * width 2, regardless of what `isWideChar` reports for its base character —
102
+ * VS16 is an explicit, cluster-level request for emoji presentation that a
103
+ * single isolated code point can't express.
104
+ */
105
+ export function charDisplayWidth(cluster: string): number {
106
+ if (cluster.includes(VARIATION_SELECTOR_16)) return 2
107
+ let sawWide = false
108
+ let sawNonMark = false
109
+ for (const ch of cluster) {
110
+ if (isWideChar(ch)) sawWide = true
111
+ if (!isCombiningMark(ch)) sawNonMark = true
112
+ }
113
+ if (sawWide) return 2
114
+ return sawNonMark ? 1 : 0
115
+ }
116
+
117
+ /**
118
+ * Total terminal display width of a string, measured per grapheme cluster
119
+ * (not per code point or UTF-16 code unit) so combining marks and composed
120
+ * emoji sequences are each counted once, correctly.
121
+ */
122
+ export function displayWidth(text: string): number {
123
+ let width = 0
124
+ for (const cluster of graphemeClusters(text)) {
125
+ width += charDisplayWidth(cluster)
126
+ }
127
+ return width
128
+ }
129
+
130
+ /**
131
+ * Split a string into grid cells for canvas writing.
132
+ *
133
+ * Each grapheme cluster produces one cell containing the full cluster text
134
+ * (so a combining mark shares its base character's cell instead of
135
+ * claiming one of its own). A wide cluster (display width 2) additionally
136
+ * produces a trailing `WIDE_CHAR_PLACEHOLDER` cell. This keeps grid-cell
137
+ * count in sync with display-column count so that box-width math (computed
138
+ * via `displayWidth`) and the actual character-writing loop agree — a
139
+ * zero-width cluster (a lone combining mark with no base) contributes no
140
+ * cell at all, since it claims no column.
141
+ */
142
+ export function toDisplayCells(text: string): string[] {
143
+ const cells: string[] = []
144
+ for (const cluster of graphemeClusters(text)) {
145
+ const width = charDisplayWidth(cluster)
146
+ if (width === 0) continue
147
+ cells.push(cluster)
148
+ if (width === 2) cells.push(WIDE_CHAR_PLACEHOLDER)
149
+ }
150
+ return cells
151
+ }