@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
package/src/canvas.ts ADDED
@@ -0,0 +1,757 @@
1
+ // ============================================================================
2
+ // ASCII renderer — 2D text canvas
3
+ //
4
+ // Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go.
5
+ // The canvas is a column-major 2D array of single-character strings.
6
+ // canvas[x][y] gives the character at column x, row y.
7
+ // ============================================================================
8
+
9
+ import type {
10
+ Canvas,
11
+ DrawingCoord,
12
+ RoleCanvas,
13
+ CharRole,
14
+ AsciiTheme,
15
+ ColorMode,
16
+ } from './types.ts'
17
+ import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi.ts'
18
+ import { toDisplayCells } from './display-width.ts'
19
+ import { joinWithLinks } from './hyperlinks.ts'
20
+ import type { LinkCanvas } from './hyperlinks.ts'
21
+
22
+ /**
23
+ * Create a blank canvas filled with spaces.
24
+ * Dimensions are inclusive: mkCanvas(3, 2) creates a 4x3 grid (indices 0..3, 0..2).
25
+ */
26
+ export function mkCanvas(x: number, y: number): Canvas {
27
+ const canvas: Canvas = []
28
+ for (let i = 0; i <= x; i++) {
29
+ const col: string[] = []
30
+ for (let j = 0; j <= y; j++) {
31
+ col.push(' ')
32
+ }
33
+ canvas.push(col)
34
+ }
35
+ return canvas
36
+ }
37
+
38
+ /** Create a blank canvas with the same dimensions as the given canvas. */
39
+ export function copyCanvas(source: Canvas): Canvas {
40
+ const [maxX, maxY] = getCanvasSize(source)
41
+ return mkCanvas(maxX, maxY)
42
+ }
43
+
44
+ // ============================================================================
45
+ // Role canvas creation and management
46
+ // ============================================================================
47
+
48
+ /**
49
+ * Create a blank role canvas filled with nulls.
50
+ * Same dimensions as mkCanvas — column-major, roleCanvas[x][y].
51
+ */
52
+ export function mkRoleCanvas(x: number, y: number): RoleCanvas {
53
+ const roleCanvas: RoleCanvas = []
54
+ for (let i = 0; i <= x; i++) {
55
+ const col: (CharRole | null)[] = []
56
+ for (let j = 0; j <= y; j++) {
57
+ col.push(null)
58
+ }
59
+ roleCanvas.push(col)
60
+ }
61
+ return roleCanvas
62
+ }
63
+
64
+ /** Create a blank role canvas with the same dimensions as the given role canvas. */
65
+ export function copyRoleCanvas(source: RoleCanvas): RoleCanvas {
66
+ const maxX = source.length - 1
67
+ const maxY = (source[0]?.length ?? 1) - 1
68
+ return mkRoleCanvas(maxX, maxY)
69
+ }
70
+
71
+ /**
72
+ * Grow the role canvas to fit at least (newX, newY), preserving existing roles.
73
+ * Mutates the role canvas in place and returns it.
74
+ */
75
+ export function increaseRoleCanvasSize(
76
+ roleCanvas: RoleCanvas,
77
+ newX: number,
78
+ newY: number,
79
+ ): RoleCanvas {
80
+ const currX = roleCanvas.length - 1
81
+ const currY = (roleCanvas[0]?.length ?? 1) - 1
82
+ const targetX = Math.max(newX, currX)
83
+ const targetY = Math.max(newY, currY)
84
+ const grown = mkRoleCanvas(targetX, targetY)
85
+ for (let x = 0; x < grown.length; x++) {
86
+ for (let y = 0; y < (grown[0]?.length ?? 0); y++) {
87
+ if (x < roleCanvas.length && y < (roleCanvas[0]?.length ?? 0)) {
88
+ grown[x]![y] = roleCanvas[x]![y]!
89
+ }
90
+ }
91
+ }
92
+ roleCanvas.length = 0
93
+ roleCanvas.push(...grown)
94
+ return roleCanvas
95
+ }
96
+
97
+ /**
98
+ * Set a role at a specific coordinate.
99
+ * Expands the role canvas if necessary.
100
+ */
101
+ export function setRole(
102
+ roleCanvas: RoleCanvas,
103
+ x: number,
104
+ y: number,
105
+ role: CharRole,
106
+ ): void {
107
+ if (x >= roleCanvas.length || y >= (roleCanvas[0]?.length ?? 0)) {
108
+ increaseRoleCanvasSize(roleCanvas, x, y)
109
+ }
110
+ roleCanvas[x]![y] = role
111
+ }
112
+
113
+ /**
114
+ * Merge role canvases — same logic as mergeCanvases but for roles.
115
+ * Non-null roles in overlays overwrite null roles in base.
116
+ */
117
+ export function mergeRoleCanvases(
118
+ base: RoleCanvas,
119
+ offset: DrawingCoord,
120
+ ...overlays: RoleCanvas[]
121
+ ): RoleCanvas {
122
+ let maxX = base.length - 1
123
+ let maxY = (base[0]?.length ?? 1) - 1
124
+
125
+ for (const overlay of overlays) {
126
+ const oX = overlay.length - 1
127
+ const oY = (overlay[0]?.length ?? 1) - 1
128
+ maxX = Math.max(maxX, oX + offset.x)
129
+ maxY = Math.max(maxY, oY + offset.y)
130
+ }
131
+
132
+ const merged = mkRoleCanvas(maxX, maxY)
133
+
134
+ // Copy base
135
+ for (let x = 0; x <= maxX; x++) {
136
+ for (let y = 0; y <= maxY; y++) {
137
+ if (x < base.length && y < (base[0]?.length ?? 0)) {
138
+ merged[x]![y] = base[x]![y]!
139
+ }
140
+ }
141
+ }
142
+
143
+ // Apply overlays
144
+ for (const overlay of overlays) {
145
+ for (let x = 0; x < overlay.length; x++) {
146
+ for (let y = 0; y < (overlay[0]?.length ?? 0); y++) {
147
+ const role = overlay[x]?.[y]
148
+ if (role !== null && role !== undefined) {
149
+ const mx = x + offset.x
150
+ const my = y + offset.y
151
+ merged[mx]![my] = role
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ return merged
158
+ }
159
+
160
+ /** Returns [maxX, maxY] — the highest valid indices in each dimension. */
161
+ export function getCanvasSize(canvas: Canvas): [number, number] {
162
+ return [canvas.length - 1, (canvas[0]?.length ?? 1) - 1]
163
+ }
164
+
165
+ /**
166
+ * Grow the canvas to fit at least (newX, newY), preserving existing content.
167
+ * Mutates the canvas in place and returns it.
168
+ */
169
+ export function increaseSize(
170
+ canvas: Canvas,
171
+ newX: number,
172
+ newY: number,
173
+ ): Canvas {
174
+ const [currX, currY] = getCanvasSize(canvas)
175
+ const targetX = Math.max(newX, currX)
176
+ const targetY = Math.max(newY, currY)
177
+ const grown = mkCanvas(targetX, targetY)
178
+ for (let x = 0; x < grown.length; x++) {
179
+ for (let y = 0; y < (grown[0]?.length ?? 0); y++) {
180
+ if (x < canvas.length && y < (canvas[0]?.length ?? 0)) {
181
+ grown[x]![y] = canvas[x]![y]!
182
+ }
183
+ }
184
+ }
185
+ // Mutate in place: splice old contents and replace with grown
186
+ canvas.length = 0
187
+ canvas.push(...grown)
188
+ return canvas
189
+ }
190
+
191
+ /**
192
+ * Bounds-checked write to a single canvas cell.
193
+ *
194
+ * Sets the character at (x, y) if the coordinate falls within the canvas;
195
+ * out-of-range coordinates are silently clipped (a no-op) instead of
196
+ * mutating the canvas. This is the single write path drawing modules should
197
+ * use instead of indexing `canvas[x]![y] = ch` directly, which had three
198
+ * different out-of-range behaviors depending on axis — none of them a clean
199
+ * bounds check:
200
+ * - x out of range: threw, via the `canvas[x]!` non-null assertion.
201
+ * - y too large (but x in range): did *not* throw — JS silently
202
+ * ragged-extends that column's array. This was observable downstream:
203
+ * `mergeCanvases`/`canvasToString` would read the extended slot back as
204
+ * `undefined`, which `canvasToString` then concatenates into the output
205
+ * as the literal string `"undefined"`.
206
+ * - y negative (but x in range): silently set an own property keyed by
207
+ * the negative index (e.g. `arr[-1]`) rather than a real array element;
208
+ * rendering code never reads it back, so this was a silent no-op.
209
+ * `write()` normalizes all three cases to the same silent no-op. That is a
210
+ * behavior *change*, not a preservation of the prior status quo — callers
211
+ * migrating to `write()` need their own bounds check if they relied on the
212
+ * old per-axis behavior (see `drawSubgraphLabel` in `draw-subgraphs.ts`,
213
+ * which needs a tighter, exclusive bound than `write()`'s own clip to avoid
214
+ * overwriting a subgraph's border with an oversized title).
215
+ *
216
+ * When `roleTracking` is provided, the role is recorded via `setRole`
217
+ * alongside the character write (used by callers, like the sequence-diagram
218
+ * and xychart renderers, that track character roles inline rather than
219
+ * deriving them from the finished canvas afterward). `role` and
220
+ * `roleCanvas` are bundled into one parameter so a call can't pass one
221
+ * without the other — passing just a role with nowhere to record it would
222
+ * otherwise type-check while silently dropping the role, an error only
223
+ * visible in colorized/ANSI output.
224
+ */
225
+ export function write(
226
+ canvas: Canvas,
227
+ x: number,
228
+ y: number,
229
+ ch: string,
230
+ roleTracking?: { role: CharRole; roleCanvas: RoleCanvas },
231
+ ): void {
232
+ const [maxX, maxY] = getCanvasSize(canvas)
233
+ if (x < 0 || x > maxX || y < 0 || y > maxY) return
234
+ canvas[x]![y] = ch
235
+ if (roleTracking) {
236
+ setRole(roleTracking.roleCanvas, x, y, roleTracking.role)
237
+ }
238
+ }
239
+
240
+ // ============================================================================
241
+ // Junction merging — Unicode box-drawing character compositing
242
+ // ============================================================================
243
+
244
+ /** All Unicode box-drawing characters that participate in junction merging. */
245
+ const JUNCTION_CHARS = new Set([
246
+ '─',
247
+ '│',
248
+ '┌',
249
+ '┐',
250
+ '└',
251
+ '┘',
252
+ '├',
253
+ '┤',
254
+ '┬',
255
+ '┴',
256
+ '┼',
257
+ '╴',
258
+ '╵',
259
+ '╶',
260
+ '╷',
261
+ ])
262
+
263
+ export function isJunctionChar(c: string): boolean {
264
+ return JUNCTION_CHARS.has(c)
265
+ }
266
+
267
+ /** Check if a character is alphanumeric (part of a label). */
268
+ function isAlphanumeric(c: string): boolean {
269
+ return /^[a-zA-Z0-9]$/.test(c)
270
+ }
271
+
272
+ /**
273
+ * When two junction characters overlap during canvas merging,
274
+ * resolve them to the correct combined junction.
275
+ * E.g., '─' overlapping '│' becomes '┼'.
276
+ */
277
+ const JUNCTION_MAP: Record<string, Record<string, string>> = {
278
+ '─': {
279
+ '│': '┼',
280
+ '┌': '┬',
281
+ '┐': '┬',
282
+ '└': '┴',
283
+ '┘': '┴',
284
+ '├': '┼',
285
+ '┤': '┼',
286
+ '┬': '┬',
287
+ '┴': '┴',
288
+ },
289
+ '│': {
290
+ '─': '┼',
291
+ '┌': '├',
292
+ '┐': '┤',
293
+ '└': '├',
294
+ '┘': '┤',
295
+ '├': '├',
296
+ '┤': '┤',
297
+ '┬': '┼',
298
+ '┴': '┼',
299
+ },
300
+ '┌': {
301
+ '─': '┬',
302
+ '│': '├',
303
+ '┐': '┬',
304
+ '└': '├',
305
+ '┘': '┼',
306
+ '├': '├',
307
+ '┤': '┼',
308
+ '┬': '┬',
309
+ '┴': '┼',
310
+ },
311
+ '┐': {
312
+ '─': '┬',
313
+ '│': '┤',
314
+ '┌': '┬',
315
+ '└': '┼',
316
+ '┘': '┤',
317
+ '├': '┼',
318
+ '┤': '┤',
319
+ '┬': '┬',
320
+ '┴': '┼',
321
+ },
322
+ '└': {
323
+ '─': '┴',
324
+ '│': '├',
325
+ '┌': '├',
326
+ '┐': '┼',
327
+ '┘': '┴',
328
+ '├': '├',
329
+ '┤': '┼',
330
+ '┬': '┼',
331
+ '┴': '┴',
332
+ },
333
+ '┘': {
334
+ '─': '┴',
335
+ '│': '┤',
336
+ '┌': '┼',
337
+ '┐': '┤',
338
+ '└': '┴',
339
+ '├': '┼',
340
+ '┤': '┤',
341
+ '┬': '┼',
342
+ '┴': '┴',
343
+ },
344
+ '├': {
345
+ '─': '┼',
346
+ '│': '├',
347
+ '┌': '├',
348
+ '┐': '┼',
349
+ '└': '├',
350
+ '┘': '┼',
351
+ '┤': '┼',
352
+ '┬': '┼',
353
+ '┴': '┼',
354
+ },
355
+ '┤': {
356
+ '─': '┼',
357
+ '│': '┤',
358
+ '┌': '┼',
359
+ '┐': '┤',
360
+ '└': '┼',
361
+ '┘': '┤',
362
+ '├': '┼',
363
+ '┬': '┼',
364
+ '┴': '┼',
365
+ },
366
+ '┬': {
367
+ '─': '┬',
368
+ '│': '┼',
369
+ '┌': '┬',
370
+ '┐': '┬',
371
+ '└': '┼',
372
+ '┘': '┼',
373
+ '├': '┼',
374
+ '┤': '┼',
375
+ '┴': '┼',
376
+ },
377
+ '┴': {
378
+ '─': '┴',
379
+ '│': '┼',
380
+ '┌': '┼',
381
+ '┐': '┼',
382
+ '└': '┴',
383
+ '┘': '┴',
384
+ '├': '┼',
385
+ '┤': '┼',
386
+ '┬': '┼',
387
+ },
388
+ }
389
+
390
+ export function mergeJunctions(c1: string, c2: string): string {
391
+ return JUNCTION_MAP[c1]?.[c2] ?? c1
392
+ }
393
+
394
+ // ============================================================================
395
+ // Canvas merging — composite multiple canvases with offset
396
+ // ============================================================================
397
+
398
+ /**
399
+ * Blank out (replace with a space) any cell in a *later* canvas that a
400
+ * *earlier* one in `canvases` already painted a non-space character into.
401
+ * Canvases are otherwise untouched — same length/shape, same content at
402
+ * every cell no earlier canvas has already claimed.
403
+ *
404
+ * `mergeCanvases` itself always lets the *last* non-space overlay win a
405
+ * shared cell (see its own doc), which is the right default for most of
406
+ * `draw.ts`'s layers (an arrowhead correctly overwrites the corner beneath
407
+ * it, a later label wins a real collision, etc.) — but for the *line*
408
+ * layer specifically, "last edge processed wins" is arbitrary and can pick
409
+ * the wrong one: `edge-cell-styles.ts`'s module doc already documents
410
+ * "first claim wins" as this renderer's intended rule for a cell two
411
+ * differently-styled, unrelated edges both route through (that module
412
+ * tracks exactly this to *detect* the conflict and trigger a reroute
413
+ * around it — see grid.ts's `rerouteAroundStyleConflicts`), but drawing
414
+ * itself never consulted that rule: every edge's line canvas painted in
415
+ * `graph.edges` order and `mergeCanvases` let whichever one came *last*
416
+ * silently overwrite an earlier edge's own, differently-styled glyph. Most
417
+ * of the time rerouting already prevents the two from sharing a cell at
418
+ * all, so this is a no-op; when it can't (e.g. a same-source fan-out with
419
+ * 3+ distinct styles has no fully conflict-free route among the routing
420
+ * candidates available — see #1067, "All Edge Styles"), applying "first
421
+ * claim wins" here at the character level, immediately before the line
422
+ * layer is merged, keeps whichever edge got there first rendered
423
+ * consistently in its own style instead of visibly switching styles
424
+ * mid-route where a later sibling's paint won by sheer draw order.
425
+ *
426
+ * Scoped to the line layer only (`draw.ts`'s `lineCanvases`) — corners,
427
+ * arrowheads, box-start connectors and labels are composited in their own
428
+ * later `mergeCanvases` passes with their existing (correct, layer-specific)
429
+ * merge semantics, untouched by this function.
430
+ *
431
+ * A claimed cell only suppresses a *later* canvas's character when the two
432
+ * wouldn't otherwise combine into something meaningful — i.e. when they
433
+ * aren't both plain Unicode junction characters (`isJunctionChar`). Two
434
+ * *different* junction characters at the same cell are usually a genuine
435
+ * perpendicular crossing (one edge's `─`, another's `│`), which `drawLine`
436
+ * relies on `mergeCanvases`'s own junction-merge logic to combine into `┼`
437
+ * — blanket-suppressing by coordinate alone would silently turn that
438
+ * crossing into whichever edge happened to draw first. Mixed-style
439
+ * characters (dashed `┄`/`┆`, heavy `━`/`┃`) are never junction chars, so
440
+ * they still fall through to plain first-claim suppression — the exact
441
+ * #1067 "All Edge Styles" scenario this function exists for.
442
+ */
443
+ export function firstClaimWins(canvases: readonly Canvas[]): Canvas[] {
444
+ const claimed = new Map<string, string>()
445
+ const result: Canvas[] = []
446
+ for (const canvas of canvases) {
447
+ const [maxX, maxY] = getCanvasSize(canvas)
448
+ // `copyCanvas` (despite its name) returns a *blank* canvas of the same
449
+ // size, not a clone of `canvas`'s content — see its own doc. Every cell
450
+ // must be written explicitly below, not just the ones this function
451
+ // blanks out.
452
+ const out = copyCanvas(canvas)
453
+ for (let x = 0; x <= maxX; x++) {
454
+ for (let y = 0; y <= maxY; y++) {
455
+ const c = canvas[x]?.[y]
456
+ if (c === undefined || c === ' ') continue
457
+ const key = `${x},${y}`
458
+ const existing = claimed.get(key)
459
+ if (existing !== undefined) {
460
+ // A repeat of the *same* character (a collinear duplicate, e.g.
461
+ // two edges both drawing '─' through a shared trunk cell) is
462
+ // still a first-claim suppression, not a crossing — `mergeJunctions`
463
+ // has no entry for a character merged with itself and would just
464
+ // fall back to it, so letting it through would be a harmless but
465
+ // pointless no-op; treating it as "still claimed" keeps the rule
466
+ // simple and matches "collinear overlap" from the case that
467
+ // actually needs a merge (two *different* junction characters).
468
+ const isCrossing =
469
+ existing !== c && isJunctionChar(existing) && isJunctionChar(c)
470
+ if (!isCrossing) continue
471
+ } else {
472
+ claimed.set(key, c)
473
+ }
474
+ out[x]![y] = c
475
+ }
476
+ }
477
+ result.push(out)
478
+ }
479
+ return result
480
+ }
481
+
482
+ /**
483
+ * Merge overlay canvases onto a base canvas at the given offset.
484
+ * Non-space characters in overlays overwrite the base.
485
+ * When both characters are Unicode junction chars, they're merged intelligently.
486
+ */
487
+ export function mergeCanvases(
488
+ base: Canvas,
489
+ offset: DrawingCoord,
490
+ useAscii: boolean,
491
+ ...overlays: Canvas[]
492
+ ): Canvas {
493
+ let [maxX, maxY] = getCanvasSize(base)
494
+ for (const overlay of overlays) {
495
+ const [oX, oY] = getCanvasSize(overlay)
496
+ maxX = Math.max(maxX, oX + offset.x)
497
+ maxY = Math.max(maxY, oY + offset.y)
498
+ }
499
+
500
+ const merged = mkCanvas(maxX, maxY)
501
+
502
+ // Copy base
503
+ for (let x = 0; x <= maxX; x++) {
504
+ for (let y = 0; y <= maxY; y++) {
505
+ if (x < base.length && y < (base[0]?.length ?? 0)) {
506
+ merged[x]![y] = base[x]![y]!
507
+ }
508
+ }
509
+ }
510
+
511
+ // Apply overlays
512
+ for (const overlay of overlays) {
513
+ for (let x = 0; x < overlay.length; x++) {
514
+ for (let y = 0; y < (overlay[0]?.length ?? 0); y++) {
515
+ const c = overlay[x]![y]!
516
+ if (c !== ' ') {
517
+ const mx = x + offset.x
518
+ const my = y + offset.y
519
+ const current = merged[mx]![my]!
520
+ if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) {
521
+ merged[mx]![my] = mergeJunctions(current, c)
522
+ } else if (isAlphanumeric(current) && isAlphanumeric(c)) {
523
+ // Don't overwrite existing label text with new label text
524
+ // This prevents label collisions (first label wins)
525
+ } else {
526
+ merged[mx]![my] = c
527
+ }
528
+ }
529
+ }
530
+ }
531
+ }
532
+
533
+ return merged
534
+ }
535
+
536
+ // ============================================================================
537
+ // Canvas → string conversion
538
+ // ============================================================================
539
+
540
+ /** Options for converting canvas to string with optional coloring. */
541
+ export interface CanvasToStringOptions {
542
+ /** Role canvas for applying colors. If not provided, output is plain text. */
543
+ roleCanvas?: RoleCanvas
544
+ /** Color mode for terminal output. Default: 'none' */
545
+ colorMode?: ColorMode
546
+ /** Theme colors for ASCII output. Uses default theme if not provided. */
547
+ theme?: AsciiTheme
548
+ /**
549
+ * Link canvas (see hyperlinks.ts) — each run of cells carrying an href is
550
+ * wrapped in an OSC 8 terminal-hyperlink escape pair. Ignored in 'html'
551
+ * color mode, which is rendered by a browser, not a terminal.
552
+ */
553
+ linkCanvas?: LinkCanvas
554
+ }
555
+
556
+ /**
557
+ * Convert the canvas to a multi-line string (row by row, left to right).
558
+ * Optionally applies ANSI color codes based on character roles, and OSC 8
559
+ * hyperlink escapes based on the link canvas.
560
+ */
561
+ export function canvasToString(
562
+ canvas: Canvas,
563
+ options?: CanvasToStringOptions,
564
+ ): string {
565
+ const [maxX, maxY] = getCanvasSize(canvas)
566
+ const lines: string[] = []
567
+
568
+ const roleCanvas = options?.roleCanvas
569
+ const colorMode = options?.colorMode ?? 'none'
570
+ const theme = options?.theme ?? DEFAULT_ASCII_THEME
571
+ const linkCanvas = colorMode === 'html' ? undefined : options?.linkCanvas
572
+
573
+ for (let y = 0; y <= maxY; y++) {
574
+ if (colorMode === 'none' || !roleCanvas) {
575
+ // Plain text output — no colors
576
+ const chars: string[] = []
577
+ for (let x = 0; x <= maxX; x++) {
578
+ chars.push(canvas[x]![y]!)
579
+ }
580
+ if (linkCanvas) {
581
+ const links = chars.map((_, x) => linkCanvas[x]?.[y] ?? null)
582
+ lines.push(joinWithLinks(chars, links))
583
+ } else {
584
+ lines.push(chars.join(''))
585
+ }
586
+ } else {
587
+ // Colored output — collect chars, roles, and links for this row
588
+ const chars: string[] = []
589
+ const roles: (CharRole | null)[] = []
590
+ const links: (string | null)[] = []
591
+ for (let x = 0; x <= maxX; x++) {
592
+ chars.push(canvas[x]![y]!)
593
+ roles.push(roleCanvas[x]?.[y] ?? null)
594
+ links.push(linkCanvas?.[x]?.[y] ?? null)
595
+ }
596
+ lines.push(
597
+ colorizeLine(
598
+ chars,
599
+ roles,
600
+ theme,
601
+ colorMode,
602
+ linkCanvas ? links : undefined,
603
+ ),
604
+ )
605
+ }
606
+ }
607
+
608
+ return lines.join('\n')
609
+ }
610
+
611
+ // ============================================================================
612
+ // Canvas vertical flip — used for BT (bottom-to-top) direction support.
613
+ //
614
+ // The ASCII renderer lays out graphs top-down (TD). For BT direction, we
615
+ // flip the finished canvas vertically and remap directional characters so
616
+ // arrows point upward and corners are mirrored correctly.
617
+ // ============================================================================
618
+
619
+ /**
620
+ * Characters that change meaning when the Y-axis is flipped.
621
+ * Symmetric characters (─, │, ├, ┤, ┼) are unchanged.
622
+ */
623
+ const VERTICAL_FLIP_MAP: Record<string, string> = {
624
+ // Unicode arrows
625
+ '▲': '▼',
626
+ '▼': '▲',
627
+ // Diagonal arrowheads — U+2196–U+2199, not the filled triangles (◢◣◤◥),
628
+ // since JetBrains Mono NL has no glyph for those at all. See
629
+ // draw-arrows.ts's drawArrowHead and issue #1062.
630
+ '↖': '↙',
631
+ '↙': '↖',
632
+ '↗': '↘',
633
+ '↘': '↗',
634
+ // ASCII arrows
635
+ '^': 'v',
636
+ v: '^',
637
+ // Unicode corners
638
+ '┌': '└',
639
+ '└': '┌',
640
+ '┐': '┘',
641
+ '┘': '┐',
642
+ // Unicode junctions (T-pieces flip vertically)
643
+ '┬': '┴',
644
+ '┴': '┬',
645
+ // Box-start junctions (exit points from node boxes)
646
+ '╵': '╷',
647
+ '╷': '╵',
648
+ }
649
+
650
+ /**
651
+ * Flip the canvas vertically (mirror across the horizontal center).
652
+ * Reverses row order within each column and remaps directional characters
653
+ * (arrows, corners, junctions) so they point the correct way after flip.
654
+ *
655
+ * Used to transform a TD-rendered canvas into BT output.
656
+ * Mutates the canvas in place and returns it.
657
+ */
658
+ export function flipCanvasVertically(canvas: Canvas): Canvas {
659
+ // Reverse each column array (Y-axis flip in column-major layout)
660
+ for (const col of canvas) {
661
+ col.reverse()
662
+ }
663
+
664
+ // Remap directional characters that change meaning after vertical flip
665
+ for (const col of canvas) {
666
+ for (const [y, ch] of col.entries()) {
667
+ const flipped = VERTICAL_FLIP_MAP[ch]
668
+ if (flipped) col[y] = flipped
669
+ }
670
+ }
671
+
672
+ return canvas
673
+ }
674
+
675
+ /**
676
+ * Flip the role canvas vertically to match flipCanvasVertically.
677
+ * Mutates the role canvas in place and returns it.
678
+ */
679
+ export function flipRoleCanvasVertically(roleCanvas: RoleCanvas): RoleCanvas {
680
+ for (const col of roleCanvas) {
681
+ col.reverse()
682
+ }
683
+ return roleCanvas
684
+ }
685
+
686
+ /**
687
+ * Draw text string onto the canvas starting at the given coordinate.
688
+ * By default, preserves existing non-space characters (labels don't overwrite each other).
689
+ * Set forceOverwrite=true to always overwrite (for box content).
690
+ *
691
+ * Wide characters (CJK/kana/hangul/fullwidth-form/emoji — see
692
+ * `isWideChar`/`toDisplayCells` in `display-width.ts`) occupy two grid cells:
693
+ * the glyph itself followed by a placeholder cell. This keeps grid-cell
694
+ * count in sync with the two terminal columns the glyph actually renders as,
695
+ * so text drawn here lines up with widths computed via `displayWidth`.
696
+ */
697
+ export function drawText(
698
+ canvas: Canvas,
699
+ start: DrawingCoord,
700
+ text: string,
701
+ forceOverwrite = false,
702
+ ): void {
703
+ const cells = toDisplayCells(text)
704
+ increaseSize(canvas, start.x + cells.length, start.y)
705
+ for (const [i, cell] of cells.entries()) {
706
+ const x = start.x + i
707
+ const current = canvas[x]![start.y]!
708
+ // Only write if target is empty or we're forcing overwrite
709
+ if (forceOverwrite || current === ' ') {
710
+ canvas[x]![start.y] = cell
711
+ }
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Set the canvas size to fit all grid columns and rows.
717
+ * Called after layout to ensure the canvas covers the full drawing area.
718
+ *
719
+ * `offsetX`/`offsetY` must be the same drawing-coordinate offset
720
+ * `gridToDrawingCoord` adds to every point it computes (`graph.offsetX`/
721
+ * `offsetY`, set by `offsetDrawingForSubgraphs`) — omitting them once left
722
+ * the canvas exactly that much too narrow/short, silently clipping any
723
+ * edge line whose drawing coordinate landed in the unreserved margin (see
724
+ * `createMapping`'s call site for the full story).
725
+ */
726
+ export function setCanvasSizeToGrid(
727
+ canvas: Canvas,
728
+ columnWidth: Map<number, number>,
729
+ rowHeight: Map<number, number>,
730
+ offsetX = 0,
731
+ offsetY = 0,
732
+ ): void {
733
+ let maxX = offsetX
734
+ let maxY = offsetY
735
+ for (const w of columnWidth.values()) maxX += w
736
+ for (const h of rowHeight.values()) maxY += h
737
+ increaseSize(canvas, maxX - 1, maxY - 1)
738
+ }
739
+
740
+ /**
741
+ * Set the role canvas size to match the grid dimensions.
742
+ * Should be called alongside setCanvasSizeToGrid, with the same
743
+ * `offsetX`/`offsetY` — see that function's doc.
744
+ */
745
+ export function setRoleCanvasSizeToGrid(
746
+ roleCanvas: RoleCanvas,
747
+ columnWidth: Map<number, number>,
748
+ rowHeight: Map<number, number>,
749
+ offsetX = 0,
750
+ offsetY = 0,
751
+ ): void {
752
+ let maxX = offsetX
753
+ let maxY = offsetY
754
+ for (const w of columnWidth.values()) maxX += w
755
+ for (const h of rowHeight.values()) maxY += h
756
+ increaseRoleCanvasSize(roleCanvas, maxX - 1, maxY - 1)
757
+ }