@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/ansi.ts ADDED
@@ -0,0 +1,490 @@
1
+ // ============================================================================
2
+ // ASCII renderer — color utilities
3
+ //
4
+ // Provides color output for themed ASCII diagrams.
5
+ // Supports ANSI terminal modes (16/256/truecolor) and HTML <span> tags
6
+ // for browser rendering.
7
+ // ============================================================================
8
+
9
+ import type { CharRole, AsciiTheme, ColorMode } from './types.ts'
10
+ import type { DiagramColors } from '@zombie-mermaid/core'
11
+ import {
12
+ MIX,
13
+ mixHexColors as mixColors,
14
+ parseHexRgba,
15
+ } from '@zombie-mermaid/core'
16
+ import { joinWithLinks, LinkRunTracker } from './hyperlinks.ts'
17
+
18
+ declare const document: unknown
19
+
20
+ // ============================================================================
21
+ // Default theme — matches SVG theme colors for consistency
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Default ASCII theme derived from the SVG renderer's color palette.
26
+ * Uses the same mixing ratios to maintain visual consistency.
27
+ */
28
+ export const DEFAULT_ASCII_THEME: AsciiTheme = {
29
+ fg: '#27272a', // zinc-800 — primary text
30
+ border: '#a1a1aa', // zinc-400 — node borders (12% mix)
31
+ line: '#71717a', // zinc-500 — edge lines (35% mix)
32
+ arrow: '#52525b', // zinc-600 — arrowheads (60% mix)
33
+ corner: '#71717a', // same as line
34
+ junction: '#a1a1aa', // same as border
35
+ }
36
+
37
+ // ============================================================================
38
+ // DiagramColors → AsciiTheme bridge
39
+ //
40
+ // Converts SVG DiagramColors into an AsciiTheme using the same MIX ratios
41
+ // that the SVG renderer uses via CSS color-mix(). This ensures visual
42
+ // consistency between SVG and ASCII output for any theme.
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Derive an AsciiTheme from SVG DiagramColors using the same mixing ratios.
47
+ * Honors optional enrichment colors (line, accent, border) when present,
48
+ * otherwise falls back to color-mix derivation — matching SVG behavior.
49
+ */
50
+ export function diagramColorsToAsciiTheme(colors: DiagramColors): AsciiTheme {
51
+ const line = colors.line ?? mixColors(colors.fg, colors.bg, MIX.line)
52
+ const border =
53
+ colors.border ?? mixColors(colors.fg, colors.bg, MIX.nodeStroke)
54
+ return {
55
+ fg: colors.fg,
56
+ border,
57
+ line,
58
+ arrow: colors.accent ?? mixColors(colors.fg, colors.bg, MIX.arrow),
59
+ accent: colors.accent,
60
+ bg: colors.bg,
61
+ corner: line,
62
+ junction: border,
63
+ }
64
+ }
65
+
66
+ // ============================================================================
67
+ // Color mode detection
68
+ // ============================================================================
69
+
70
+ /**
71
+ * Detect the best color mode for the current environment.
72
+ *
73
+ * Terminal detection order:
74
+ * 1. COLORTERM=truecolor or COLORTERM=24bit → truecolor
75
+ * 2. TERM contains "256color" → ansi256
76
+ * 3. TERM is set and not "dumb" → ansi16
77
+ *
78
+ * Browser: returns 'html' (uses <span> tags with inline styles).
79
+ * Unknown/piped: returns 'none'.
80
+ */
81
+ export function detectColorMode(): ColorMode {
82
+ // Check if we're in a Node.js-like environment with process object
83
+ // Use globalThis to safely check for process without TypeScript errors
84
+ const proc = (
85
+ globalThis as {
86
+ process?: {
87
+ stdout?: { isTTY?: boolean }
88
+ env?: Record<string, string | undefined>
89
+ }
90
+ }
91
+ ).process
92
+
93
+ if (proc) {
94
+ // Check if stdout is a TTY (not piped/redirected)
95
+ if (!proc.stdout?.isTTY) {
96
+ return 'none'
97
+ }
98
+
99
+ const colorTerm = proc.env?.COLORTERM?.toLowerCase() ?? ''
100
+ const term = proc.env?.TERM?.toLowerCase() ?? ''
101
+
102
+ // True color support
103
+ if (colorTerm === 'truecolor' || colorTerm === '24bit') {
104
+ return 'truecolor'
105
+ }
106
+
107
+ // 256 color support
108
+ if (term.includes('256color') || term.includes('256')) {
109
+ return 'ansi256'
110
+ }
111
+
112
+ // Basic color support
113
+ if (term && term !== 'dumb') {
114
+ return 'ansi16'
115
+ }
116
+
117
+ return 'none'
118
+ }
119
+
120
+ // No process object → browser environment → use HTML color output
121
+ if (typeof document !== 'undefined') {
122
+ return 'html'
123
+ }
124
+
125
+ return 'none'
126
+ }
127
+
128
+ // ============================================================================
129
+ // Hex color parsing
130
+ // ============================================================================
131
+
132
+ /**
133
+ * Parse a hex color string to RGB values for ANSI escape generation.
134
+ * Accepts 3/6-digit hex with or without a leading `#` (theme colors always
135
+ * carry one; the bare form is kept for backward compatibility). Anything
136
+ * unparseable falls back to black rather than emitting a NaN escape.
137
+ */
138
+ function parseHex(hex: string): { r: number; g: number; b: number } {
139
+ const rgba = parseHexRgba(hex.startsWith('#') ? hex : `#${hex}`)
140
+ return rgba ?? { r: 0, g: 0, b: 0 }
141
+ }
142
+
143
+ // ============================================================================
144
+ // ANSI escape code generation
145
+ // ============================================================================
146
+
147
+ /** ANSI escape sequence prefix */
148
+ const ESC = '\x1b['
149
+ /** Reset all attributes */
150
+ const RESET = `${ESC}0m`
151
+
152
+ /**
153
+ * Generate ANSI foreground color escape sequence for 24-bit true color.
154
+ * Format: ESC[38;2;R;G;Bm
155
+ */
156
+ function truecolorFg(hex: string): string {
157
+ const { r, g, b } = parseHex(hex)
158
+ return `${ESC}38;2;${r};${g};${b}m`
159
+ }
160
+
161
+ /**
162
+ * Find the closest 256-color palette index for an RGB color.
163
+ * The 256-color palette has:
164
+ * - 0-15: Standard colors (duplicates of 16-color)
165
+ * - 16-231: 6x6x6 color cube (216 colors)
166
+ * - 232-255: Grayscale ramp (24 shades)
167
+ */
168
+ function rgbTo256(r: number, g: number, b: number): number {
169
+ // Check if it's close to grayscale
170
+ const avg = (r + g + b) / 3
171
+ const maxDiff = Math.max(
172
+ Math.abs(r - avg),
173
+ Math.abs(g - avg),
174
+ Math.abs(b - avg),
175
+ )
176
+
177
+ if (maxDiff < 10) {
178
+ // Use grayscale ramp (232-255)
179
+ // Each step is ~10.625 (256/24)
180
+ const gray = Math.round((avg / 255) * 23)
181
+ return 232 + Math.min(23, Math.max(0, gray))
182
+ }
183
+
184
+ // Use 6x6x6 color cube (16-231)
185
+ // Each channel maps to 0-5: 0, 95, 135, 175, 215, 255
186
+ const toIndex = (v: number): number => {
187
+ if (v < 48) return 0
188
+ if (v < 115) return 1
189
+ return Math.min(5, Math.floor((v - 35) / 40))
190
+ }
191
+
192
+ const ri = toIndex(r)
193
+ const gi = toIndex(g)
194
+ const bi = toIndex(b)
195
+
196
+ return 16 + 36 * ri + 6 * gi + bi
197
+ }
198
+
199
+ /**
200
+ * Generate ANSI foreground color escape sequence for 256-color mode.
201
+ * Format: ESC[38;5;Nm
202
+ */
203
+ function ansi256Fg(hex: string): string {
204
+ const { r, g, b } = parseHex(hex)
205
+ const index = rgbTo256(r, g, b)
206
+ return `${ESC}38;5;${index}m`
207
+ }
208
+
209
+ /**
210
+ * Map an RGB color to the closest 16-color ANSI code.
211
+ * Returns the foreground color escape sequence.
212
+ *
213
+ * Standard 16 colors:
214
+ * 0=black, 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 7=white
215
+ * 8-15 = bright versions
216
+ */
217
+ function ansi16Fg(hex: string): string {
218
+ const { r, g, b } = parseHex(hex)
219
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b
220
+
221
+ // Determine brightness (use bright colors for better visibility)
222
+ const bright = luma > 100 ? 0 : 60 // 60 = bright variant offset
223
+
224
+ // Determine base color based on dominant channel
225
+ let code: number
226
+ if (r > 180 && g < 100 && b < 100)
227
+ code = 31 // red
228
+ else if (g > 180 && r < 100 && b < 100)
229
+ code = 32 // green
230
+ else if (r > 150 && g > 150 && b < 100)
231
+ code = 33 // yellow
232
+ else if (b > 180 && r < 100 && g < 100)
233
+ code = 34 // blue
234
+ else if (r > 150 && b > 150 && g < 100)
235
+ code = 35 // magenta
236
+ else if (g > 150 && b > 150 && r < 100)
237
+ code = 36 // cyan
238
+ else if (luma > 200)
239
+ code = 37 // white
240
+ else if (luma < 50)
241
+ code = 30 // black
242
+ else code = 37 // default to white for grays
243
+
244
+ return `${ESC}${code + bright}m`
245
+ }
246
+
247
+ // ============================================================================
248
+ // HTML color output (for browser rendering)
249
+ // ============================================================================
250
+
251
+ /** Escape characters that would break HTML output. */
252
+ function escapeHtml(text: string): string {
253
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
254
+ }
255
+
256
+ /** Wrap text in a <span> with an inline color style. */
257
+ function htmlSpan(hex: string, text: string): string {
258
+ return `<span style="color:${hex}">${escapeHtml(text)}</span>`
259
+ }
260
+
261
+ // ============================================================================
262
+ // Role → color mapping
263
+ // ============================================================================
264
+
265
+ /**
266
+ * Get the color for a character role from the theme.
267
+ */
268
+ function getRoleColor(role: CharRole, theme: AsciiTheme): string {
269
+ switch (role) {
270
+ case 'text':
271
+ return theme.fg
272
+ case 'border':
273
+ return theme.border
274
+ case 'line':
275
+ return theme.line
276
+ case 'arrow':
277
+ return theme.arrow
278
+ case 'corner':
279
+ return theme.corner ?? theme.line
280
+ case 'junction':
281
+ return theme.junction ?? theme.border
282
+ default:
283
+ return theme.fg
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Generate the ANSI escape sequence for a role color.
289
+ */
290
+ export function getAnsiColor(
291
+ role: CharRole,
292
+ theme: AsciiTheme,
293
+ mode: ColorMode,
294
+ ): string {
295
+ if (mode === 'none') return ''
296
+
297
+ const hex = getRoleColor(role, theme)
298
+
299
+ switch (mode) {
300
+ case 'truecolor':
301
+ return truecolorFg(hex)
302
+ case 'ansi256':
303
+ return ansi256Fg(hex)
304
+ case 'ansi16':
305
+ return ansi16Fg(hex)
306
+ default:
307
+ return ''
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Get the ANSI reset sequence.
313
+ */
314
+ export function getAnsiReset(mode: ColorMode): string {
315
+ return mode === 'none' ? '' : RESET
316
+ }
317
+
318
+ /**
319
+ * Wrap a character with ANSI color codes based on its role.
320
+ */
321
+ export function colorizeChar(
322
+ char: string,
323
+ role: CharRole | null,
324
+ theme: AsciiTheme,
325
+ mode: ColorMode,
326
+ ): string {
327
+ if (mode === 'none' || role === null || char === ' ') {
328
+ return char
329
+ }
330
+
331
+ const colorCode = getAnsiColor(role, theme, mode)
332
+ return `${colorCode}${char}${RESET}`
333
+ }
334
+
335
+ /**
336
+ * Colorize an entire line efficiently by grouping consecutive same-role characters.
337
+ * This reduces the number of escape sequences (ANSI) or span tags (HTML) in the output.
338
+ *
339
+ * `links` (one href-or-null per cell, see hyperlinks.ts) wraps each run of
340
+ * linked cells in an OSC 8 hyperlink pair. The pair is inserted into the
341
+ * character stream *without* disturbing the SGR grouping — an open/close
342
+ * may land inside a color run rather than splitting it — so the output with
343
+ * every OSC 8 sequence stripped is identical to the output without links.
344
+ * Ignored in 'html' mode, which is rendered by a browser, not a terminal.
345
+ */
346
+ export function colorizeLine(
347
+ chars: string[],
348
+ roles: (CharRole | null)[],
349
+ theme: AsciiTheme,
350
+ mode: ColorMode,
351
+ links?: readonly (string | null)[],
352
+ ): string {
353
+ if (mode === 'none') {
354
+ return links ? joinWithLinks(chars, links) : chars.join('')
355
+ }
356
+
357
+ if (mode === 'html') {
358
+ return colorizeLineHtml(chars, roles, theme)
359
+ }
360
+
361
+ const linkRuns = new LinkRunTracker()
362
+ let result = ''
363
+ let currentRole: CharRole | null = null
364
+ let buffer = ''
365
+
366
+ for (const [i, char] of chars.entries()) {
367
+ const role = roles[i] ?? null
368
+ // Zero-width; goes wherever this cell's character goes.
369
+ const linkMarker = links ? linkRuns.advance(links[i] ?? null) : ''
370
+
371
+ // Whitespace doesn't need coloring
372
+ if (char === ' ') {
373
+ // Flush any buffered characters (with or without color)
374
+ if (buffer.length > 0) {
375
+ if (currentRole !== null) {
376
+ result += getAnsiColor(currentRole, theme, mode) + buffer + RESET
377
+ } else {
378
+ result += buffer
379
+ }
380
+ buffer = ''
381
+ currentRole = null
382
+ }
383
+ result += linkMarker + char
384
+ continue
385
+ }
386
+
387
+ // Same role as previous — accumulate
388
+ if (role === currentRole) {
389
+ buffer += linkMarker + char
390
+ continue
391
+ }
392
+
393
+ // Role changed — flush buffer (with or without color) and start new
394
+ if (buffer.length > 0) {
395
+ if (currentRole !== null) {
396
+ result += getAnsiColor(currentRole, theme, mode) + buffer + RESET
397
+ } else {
398
+ result += buffer
399
+ }
400
+ }
401
+ buffer = linkMarker + char
402
+ currentRole = role
403
+ }
404
+
405
+ // Flush remaining buffer
406
+ if (buffer.length > 0 && currentRole !== null) {
407
+ result += getAnsiColor(currentRole, theme, mode) + buffer + RESET
408
+ } else if (buffer.length > 0) {
409
+ result += buffer
410
+ }
411
+
412
+ return result + linkRuns.finish()
413
+ }
414
+
415
+ /**
416
+ * HTML-specific line colorization.
417
+ * Groups consecutive same-role characters into <span> tags with inline color styles.
418
+ * Whitespace is emitted bare (no wrapping) to keep output compact.
419
+ */
420
+ function colorizeLineHtml(
421
+ chars: string[],
422
+ roles: (CharRole | null)[],
423
+ theme: AsciiTheme,
424
+ ): string {
425
+ let result = ''
426
+ let currentRole: CharRole | null = null
427
+ let buffer = ''
428
+
429
+ const flush = () => {
430
+ if (buffer.length === 0) return
431
+ if (currentRole !== null) {
432
+ result += htmlSpan(getRoleColor(currentRole, theme), buffer)
433
+ } else {
434
+ result += escapeHtml(buffer)
435
+ }
436
+ buffer = ''
437
+ currentRole = null
438
+ }
439
+
440
+ for (const [i, char] of chars.entries()) {
441
+ const role = roles[i] ?? null
442
+
443
+ if (char === ' ') {
444
+ flush()
445
+ result += ' '
446
+ continue
447
+ }
448
+
449
+ if (role === currentRole) {
450
+ buffer += char
451
+ continue
452
+ }
453
+
454
+ flush()
455
+ buffer = char
456
+ currentRole = role
457
+ }
458
+
459
+ flush()
460
+ return result
461
+ }
462
+
463
+ /**
464
+ * Colorize a text string with a direct hex color.
465
+ * Used by renderers that need per-cell color control (e.g. multi-series xychart).
466
+ * Handles all output modes: ANSI (16/256/truecolor) and HTML.
467
+ */
468
+ export function colorizeText(
469
+ text: string,
470
+ hex: string,
471
+ mode: ColorMode,
472
+ ): string {
473
+ if (mode === 'none' || text.length === 0) return text
474
+ if (mode === 'html') return htmlSpan(hex, text)
475
+ let code: string
476
+ switch (mode) {
477
+ case 'truecolor':
478
+ code = truecolorFg(hex)
479
+ break
480
+ case 'ansi256':
481
+ code = ansi256Fg(hex)
482
+ break
483
+ case 'ansi16':
484
+ code = ansi16Fg(hex)
485
+ break
486
+ default:
487
+ return text
488
+ }
489
+ return `${code}${text}${RESET}`
490
+ }