@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.
- package/LICENSE +22 -0
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +146 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.js +5392 -0
- package/dist/index.js.map +1 -0
- package/package.json +36 -0
- package/src/__tests__/ascii-arrowhead-direction-1083.test.ts +51 -0
- package/src/__tests__/ascii-canvas-first-claim-wins-1093.test.ts +89 -0
- package/src/__tests__/ascii-canvas-size-offset-1093.test.ts +95 -0
- package/src/__tests__/ascii-canvas-write.test.ts +132 -0
- package/src/__tests__/ascii-chain-edge-overlap-1067.test.ts +100 -0
- package/src/__tests__/ascii-charset-border-junctions.test.ts +63 -0
- package/src/__tests__/ascii-cjk-width.test.ts +150 -0
- package/src/__tests__/ascii-class-box-occupancy.test.ts +462 -0
- package/src/__tests__/ascii-class-column-width-488-489.test.ts +639 -0
- package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
- package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +114 -0
- package/src/__tests__/ascii-class-diagram-compartments.test.ts +70 -0
- package/src/__tests__/ascii-class-label-box-collision.test.ts +60 -0
- package/src/__tests__/ascii-class-label-row-collision-531.test.ts +135 -0
- package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +57 -0
- package/src/__tests__/ascii-class-padding.test.ts +104 -0
- package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
- package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
- package/src/__tests__/ascii-class-reciprocal-relationships-448.test.ts +169 -0
- package/src/__tests__/ascii-combining-mark-width.test.ts +70 -0
- package/src/__tests__/ascii-coords-overlay.test.ts +64 -0
- package/src/__tests__/ascii-decision-lr-box-start.test.ts +106 -0
- package/src/__tests__/ascii-display-width-unit.test.ts +154 -0
- package/src/__tests__/ascii-draw-arrows-coverage.test.ts +224 -0
- package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +105 -0
- package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +72 -0
- package/src/__tests__/ascii-edge-ending-glyphs.test.ts +206 -0
- package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +95 -0
- package/src/__tests__/ascii-edge-routing-fixes.test.ts +178 -0
- package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +103 -0
- package/src/__tests__/ascii-edge-style-consistency-1067.test.ts +89 -0
- package/src/__tests__/ascii-edge-styles.test.ts +149 -0
- package/src/__tests__/ascii-emoji-cluster-width.test.ts +101 -0
- package/src/__tests__/ascii-er-box-occupancy.test.ts +251 -0
- package/src/__tests__/ascii-er-cardinality.test.ts +40 -0
- package/src/__tests__/ascii-er-corner-glyphs.test.ts +249 -0
- package/src/__tests__/ascii-er-jog-stray-line.test.ts +111 -0
- package/src/__tests__/ascii-er-label-padding.test.ts +118 -0
- package/src/__tests__/ascii-er-padding.test.ts +114 -0
- package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +458 -0
- package/src/__tests__/ascii-er-relationship-overwrite.test.ts +325 -0
- package/src/__tests__/ascii-er-stray-connectors.test.ts +344 -0
- package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +98 -0
- package/src/__tests__/ascii-er-vertical-one-marker.test.ts +110 -0
- package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +168 -0
- package/src/__tests__/ascii-lane-search.test.ts +207 -0
- package/src/__tests__/ascii-multibox-cjk-width.test.ts +184 -0
- package/src/__tests__/ascii-multiline.test.ts +288 -0
- package/src/__tests__/ascii-padding-edge-cases.test.ts +154 -0
- package/src/__tests__/ascii-pathfinder-route-edge.test.ts +184 -0
- package/src/__tests__/ascii-sequence-alt-else-label.test.ts +236 -0
- package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +217 -0
- package/src/__tests__/ascii-sequence-box-group.test.ts +159 -0
- package/src/__tests__/ascii-sequence-cjk-width.test.ts +235 -0
- package/src/__tests__/ascii-sequence-create-destroy.test.ts +114 -0
- package/src/__tests__/ascii-sequence-form-invariants.test.ts +432 -0
- package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +219 -0
- package/src/__tests__/ascii-sequence-notes.test.ts +61 -0
- package/src/__tests__/ascii-sequence-padding.test.ts +119 -0
- package/src/__tests__/ascii-sequence-self-arrow.test.ts +206 -0
- package/src/__tests__/ascii-shape-diamond.test.ts +38 -0
- package/src/__tests__/ascii-shape-rectangle.test.ts +258 -0
- package/src/__tests__/ascii-shape-rounded.test.ts +36 -0
- package/src/__tests__/ascii-shapes-circle.test.ts +41 -0
- package/src/__tests__/ascii-shapes-hexagon.test.ts +42 -0
- package/src/__tests__/ascii-shapes-special.test.ts +344 -0
- package/src/__tests__/ascii-shapes-stadium.test.ts +217 -0
- package/src/__tests__/ascii-shapes-state.test.ts +224 -0
- package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +130 -0
- package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +90 -0
- package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +152 -0
- package/src/__tests__/ascii-subgraph-title-padding.test.ts +77 -0
- package/src/__tests__/ascii-territory-unit.test.ts +219 -0
- package/src/__tests__/ascii-validate.test.ts +220 -0
- package/src/__tests__/ascii.test.ts +325 -0
- package/src/__tests__/class-arrow-directions.test.ts +505 -0
- package/src/__tests__/draw-lines.test.ts +93 -0
- package/src/__tests__/edge-cell-styles.test.ts +278 -0
- package/src/__tests__/grid-occupancy.test.ts +240 -0
- package/src/__tests__/helpers/ascii-form.ts +142 -0
- package/src/__tests__/helpers/terminal-display-width.ts +74 -0
- package/src/__tests__/pathfinder.test.ts +239 -0
- package/src/__tests__/testdata/ascii/ampersand_lhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_lhs_and_rhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_rhs.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_td_fanin.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_td_fanout.txt +18 -0
- package/src/__tests__/testdata/ascii/ampersand_without_edge.txt +18 -0
- package/src/__tests__/testdata/ascii/back_reference_from_child.txt +10 -0
- package/src/__tests__/testdata/ascii/backlink_from_bottom.txt +22 -0
- package/src/__tests__/testdata/ascii/backlink_from_top.txt +22 -0
- package/src/__tests__/testdata/ascii/backlink_with_short_y_padding.txt +20 -0
- package/src/__tests__/testdata/ascii/cls_all_relationships.txt +19 -0
- package/src/__tests__/testdata/ascii/cls_annotation.txt +29 -0
- package/src/__tests__/testdata/ascii/cls_association.txt +14 -0
- package/src/__tests__/testdata/ascii/cls_basic.txt +15 -0
- package/src/__tests__/testdata/ascii/cls_dependency.txt +14 -0
- package/src/__tests__/testdata/ascii/cls_inheritance.txt +20 -0
- package/src/__tests__/testdata/ascii/cls_methods.txt +21 -0
- package/src/__tests__/testdata/ascii/comments.txt +23 -0
- package/src/__tests__/testdata/ascii/custom_padding.txt +10 -0
- package/src/__tests__/testdata/ascii/duplicate_labels.txt +19 -0
- package/src/__tests__/testdata/ascii/er_attributes.txt +21 -0
- package/src/__tests__/testdata/ascii/er_basic.txt +8 -0
- package/src/__tests__/testdata/ascii/er_identifying.txt +18 -0
- package/src/__tests__/testdata/ascii/flowchart_tb_simple.txt +29 -0
- package/src/__tests__/testdata/ascii/graph_bt_direction.txt +28 -0
- package/src/__tests__/testdata/ascii/graph_tb_direction.txt +26 -0
- package/src/__tests__/testdata/ascii/nested_subgraphs_with_labels.txt +36 -0
- package/src/__tests__/testdata/ascii/preserve_order_of_definition.txt +23 -0
- package/src/__tests__/testdata/ascii/self_reference.txt +10 -0
- package/src/__tests__/testdata/ascii/self_reference_with_edge.txt +10 -0
- package/src/__tests__/testdata/ascii/seq_basic.txt +17 -0
- package/src/__tests__/testdata/ascii/seq_multiple_messages.txt +25 -0
- package/src/__tests__/testdata/ascii/seq_self_message.txt +18 -0
- package/src/__tests__/testdata/ascii/single_node.txt +8 -0
- package/src/__tests__/testdata/ascii/single_node_longer_name.txt +8 -0
- package/src/__tests__/testdata/ascii/subgraph_complex_mixed.txt +38 -0
- package/src/__tests__/testdata/ascii/subgraph_complex_nested.txt +49 -0
- package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +47 -0
- package/src/__tests__/testdata/ascii/subgraph_empty.txt +10 -0
- package/src/__tests__/testdata/ascii/subgraph_mixed_nodes.txt +20 -0
- package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +48 -0
- package/src/__tests__/testdata/ascii/subgraph_multiple_edges.txt +32 -0
- package/src/__tests__/testdata/ascii/subgraph_multiple_nodes.txt +16 -0
- package/src/__tests__/testdata/ascii/subgraph_nested.txt +24 -0
- package/src/__tests__/testdata/ascii/subgraph_nested_with_external.txt +30 -0
- package/src/__tests__/testdata/ascii/subgraph_node_outside_lr.txt +17 -0
- package/src/__tests__/testdata/ascii/subgraph_single_node.txt +16 -0
- package/src/__tests__/testdata/ascii/subgraph_td_direction.txt +26 -0
- package/src/__tests__/testdata/ascii/subgraph_td_multiple.txt +44 -0
- package/src/__tests__/testdata/ascii/subgraph_td_multiple_paddingy.txt +42 -0
- package/src/__tests__/testdata/ascii/subgraph_three_levels_nested.txt +32 -0
- package/src/__tests__/testdata/ascii/subgraph_three_separate.txt +24 -0
- package/src/__tests__/testdata/ascii/subgraph_two_separate.txt +20 -0
- package/src/__tests__/testdata/ascii/subgraph_with_labels.txt +20 -0
- package/src/__tests__/testdata/ascii/three_nodes.txt +9 -0
- package/src/__tests__/testdata/ascii/three_nodes_single_line.txt +8 -0
- package/src/__tests__/testdata/ascii/two_layer_single_graph.txt +19 -0
- package/src/__tests__/testdata/ascii/two_layer_single_graph_longer_names.txt +19 -0
- package/src/__tests__/testdata/ascii/two_nodes_linked.txt +8 -0
- package/src/__tests__/testdata/ascii/two_nodes_longer_names.txt +8 -0
- package/src/__tests__/testdata/ascii/two_root_nodes.txt +19 -0
- package/src/__tests__/testdata/ascii/two_root_nodes_longer_names.txt +19 -0
- package/src/__tests__/testdata/ascii/two_single_root_nodes.txt +19 -0
- package/src/__tests__/testdata/unicode/ampersand_lhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_lhs_and_rhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_rhs.txt +18 -0
- package/src/__tests__/testdata/unicode/ampersand_without_edge.txt +18 -0
- package/src/__tests__/testdata/unicode/back_reference_from_child.txt +10 -0
- package/src/__tests__/testdata/unicode/backlink_from_bottom.txt +22 -0
- package/src/__tests__/testdata/unicode/backlink_from_top.txt +22 -0
- package/src/__tests__/testdata/unicode/cls_all_relationships.txt +19 -0
- package/src/__tests__/testdata/unicode/cls_annotation.txt +29 -0
- package/src/__tests__/testdata/unicode/cls_association.txt +14 -0
- package/src/__tests__/testdata/unicode/cls_basic.txt +15 -0
- package/src/__tests__/testdata/unicode/cls_dependency.txt +14 -0
- package/src/__tests__/testdata/unicode/cls_inheritance.txt +20 -0
- package/src/__tests__/testdata/unicode/cls_methods.txt +21 -0
- package/src/__tests__/testdata/unicode/comments.txt +23 -0
- package/src/__tests__/testdata/unicode/duplicate_labels.txt +19 -0
- package/src/__tests__/testdata/unicode/er_attributes.txt +21 -0
- package/src/__tests__/testdata/unicode/er_basic.txt +8 -0
- package/src/__tests__/testdata/unicode/er_identifying.txt +18 -0
- package/src/__tests__/testdata/unicode/graph_bt_direction.txt +28 -0
- package/src/__tests__/testdata/unicode/preserve_order_of_definition.txt +23 -0
- package/src/__tests__/testdata/unicode/self_reference.txt +10 -0
- package/src/__tests__/testdata/unicode/self_reference_with_edge.txt +10 -0
- package/src/__tests__/testdata/unicode/seq_basic.txt +17 -0
- package/src/__tests__/testdata/unicode/seq_multiple_messages.txt +25 -0
- package/src/__tests__/testdata/unicode/seq_self_message.txt +18 -0
- package/src/__tests__/testdata/unicode/single_node.txt +8 -0
- package/src/__tests__/testdata/unicode/single_node_longer_name.txt +8 -0
- package/src/__tests__/testdata/unicode/three_nodes.txt +9 -0
- package/src/__tests__/testdata/unicode/three_nodes_single_line.txt +8 -0
- package/src/__tests__/testdata/unicode/two_layer_single_graph.txt +19 -0
- package/src/__tests__/testdata/unicode/two_layer_single_graph_longer_names.txt +19 -0
- package/src/__tests__/testdata/unicode/two_nodes_linked.txt +8 -0
- package/src/__tests__/testdata/unicode/two_nodes_longer_names.txt +8 -0
- package/src/__tests__/testdata/unicode/two_root_nodes.txt +19 -0
- package/src/__tests__/testdata/unicode/two_root_nodes_longer_names.txt +19 -0
- package/src/__tests__/testdata/unicode/two_single_root_nodes.txt +19 -0
- package/src/__tests__/xychart-ascii.test.ts +376 -0
- package/src/ansi.ts +490 -0
- package/src/canvas.ts +757 -0
- package/src/class-diagram.ts +2001 -0
- package/src/converter.ts +446 -0
- package/src/coords.ts +58 -0
- package/src/display-width.ts +151 -0
- package/src/draw-arrows.ts +593 -0
- package/src/draw-boxes.ts +267 -0
- package/src/draw-bundles.ts +611 -0
- package/src/draw-lines.ts +174 -0
- package/src/draw-subgraphs.ts +108 -0
- package/src/draw.ts +350 -0
- package/src/edge-bundling.ts +435 -0
- package/src/edge-cell-styles.ts +209 -0
- package/src/edge-routing.ts +1070 -0
- package/src/er-diagram.ts +1488 -0
- package/src/flowchart.ts +94 -0
- package/src/grid-occupancy.ts +234 -0
- package/src/grid.ts +1309 -0
- package/src/hyperlinks.ts +248 -0
- package/src/index.ts +163 -0
- package/src/lane-search.ts +68 -0
- package/src/multiline-utils.ts +82 -0
- package/src/pathfinder.ts +448 -0
- package/src/registry.ts +88 -0
- package/src/sequence.ts +1318 -0
- package/src/shapes/circle.ts +31 -0
- package/src/shapes/corners.ts +273 -0
- package/src/shapes/diamond.ts +31 -0
- package/src/shapes/hexagon.ts +35 -0
- package/src/shapes/index.ts +123 -0
- package/src/shapes/rectangle.ts +199 -0
- package/src/shapes/rounded.ts +31 -0
- package/src/shapes/special.ts +360 -0
- package/src/shapes/stadium.ts +122 -0
- package/src/shapes/state.ts +204 -0
- package/src/shapes/types.ts +78 -0
- package/src/territory.ts +136 -0
- package/src/types.ts +454 -0
- package/src/validate.ts +189 -0
- package/src/xychart.ts +1085 -0
package/src/converter.ts
ADDED
|
@@ -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
|
+
}
|