text-guitar-chart 0.0.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.
@@ -0,0 +1,261 @@
1
+ //@ts-check
2
+
3
+ /**
4
+ * Parse a string representation of a guitar fingering into internal format
5
+ * @param {import("svguitar").Chord} chord
6
+ * @param {object} [options]
7
+ * @param {boolean} [options.useUnicode=false] - Whether to use Unicode characters for string/fret markers
8
+ * @returns {string}
9
+ */
10
+ export default function fingeringToString(chord, options = {}) {
11
+ const { useUnicode = false } = options;
12
+ const { fingers = [], title = "", position } = chord;
13
+
14
+ // Parse fingers to build data structure
15
+ const stringData = new Map(); // Map<string, Map<fret, fingerInfo>>
16
+ let maxFret = 0;
17
+ const openStrings = new Set();
18
+ const mutedStrings = new Set();
19
+
20
+ for (const finger of fingers) {
21
+ const [string, fret, opts = {}] = finger;
22
+ const optsObject = typeof opts === "object" ? opts : {};
23
+ const { text = "", color = "#000000" } = optsObject;
24
+
25
+ if (fret === 0) {
26
+ openStrings.add(string);
27
+ } else if (fret === "x") {
28
+ mutedStrings.add(string);
29
+ } else {
30
+ if (!stringData.has(string)) {
31
+ stringData.set(string, new Map());
32
+ }
33
+ stringData.get(string).set(fret, { text, color });
34
+
35
+ if (fret > maxFret) maxFret = fret;
36
+ }
37
+ }
38
+
39
+ // Determine number of frets to show
40
+ const numFrets = fingers.some(f => typeof f[1] === "number" && f[1] > 0)
41
+ ? Math.max(3, maxFret)
42
+ : 3;
43
+
44
+ if (useUnicode) {
45
+ return buildUnicodeOutput(
46
+ title,
47
+ stringData,
48
+ openStrings,
49
+ mutedStrings,
50
+ numFrets,
51
+ position
52
+ );
53
+ } else {
54
+ return buildAsciiOutput(
55
+ title,
56
+ stringData,
57
+ openStrings,
58
+ mutedStrings,
59
+ numFrets,
60
+ position
61
+ );
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Build ASCII format output
67
+ * @param {string} title
68
+ * @param {Map<number, Map<number, {text: string, color: string}>>} stringData
69
+ * @param {Set<number>} openStrings
70
+ * @param {Set<number>} mutedStrings
71
+ * @param {number} numFrets
72
+ * @param {number|undefined} position
73
+ * @returns {string}
74
+ */
75
+ function buildAsciiOutput(
76
+ title,
77
+ stringData,
78
+ openStrings,
79
+ mutedStrings,
80
+ numFrets,
81
+ position
82
+ ) {
83
+ const lines = [];
84
+
85
+ // Title section
86
+ if (title && title.length > 0) {
87
+ const clampedTitle = title.length > 15 ? title.slice(0, 15) : title;
88
+ lines.push(` ${clampedTitle}`);
89
+ // Add separator line of # characters (exactly 6 chars minimum)
90
+ lines.push(` ${'#'.repeat(Math.max(6, clampedTitle.length))}`);
91
+ }
92
+
93
+ // Open/muted strings line - only if there are any
94
+ if (openStrings.size > 0 || mutedStrings.size > 0) {
95
+ let openLine = " ";
96
+ let lowestMarked = 6; // lowest numbered string (rightmost in display) that has a marker
97
+
98
+ // Find lowest numbered string with a marker
99
+ for (let str = 6; str >= 1; str--) {
100
+ if (openStrings.has(str) || mutedStrings.has(str)) {
101
+ lowestMarked = str;
102
+ }
103
+ }
104
+
105
+ // Show from string 6 down to lowestMarked, extending to 3 only if lowestMarked > 4
106
+ const showTo = lowestMarked > 4 ? 3 : lowestMarked;
107
+
108
+ // Build line from string 6 down to showTo
109
+ for (let str = 6; str >= showTo; str--) {
110
+ if (openStrings.has(str)) {
111
+ openLine += "o";
112
+ } else if (mutedStrings.has(str)) {
113
+ openLine += "x";
114
+ } else {
115
+ openLine += " ";
116
+ }
117
+ }
118
+
119
+ lines.push(openLine.trimEnd());
120
+ }
121
+
122
+ // Separator line before fretboard (always included)
123
+ lines.push(position === 1 ? " ======" : " ------");
124
+
125
+ // Fret lines
126
+ for (let fret = 1; fret <= numFrets; fret++) {
127
+ let line = "";
128
+
129
+ // Add position number on first fret line if specified
130
+ if (fret === 1 && position !== undefined && position !== 1) {
131
+ line = position < 10 ? ` ${position}` : `${position}`;
132
+ } else {
133
+ line = " ";
134
+ }
135
+
136
+ // Build fret line (strings 6 to 1, left to right)
137
+ for (let str = 6; str >= 1; str--) {
138
+ const fingerInfo = stringData.get(str)?.get(fret);
139
+
140
+ if (fingerInfo) {
141
+ if (fingerInfo.color !== "#000000") {
142
+ line += "*";
143
+ } else if (fingerInfo.text) {
144
+ line += fingerInfo.text[0];
145
+ } else {
146
+ line += "o";
147
+ }
148
+ } else {
149
+ line += "|";
150
+ }
151
+ }
152
+
153
+ lines.push(line);
154
+ }
155
+
156
+ return lines.join("\n");
157
+ }
158
+
159
+ /**
160
+ * Build Unicode format output
161
+ * @param {string} title
162
+ * @param {Map<number, Map<number, {text: string, color: string}>>} stringData
163
+ * @param {Set<number>} openStrings
164
+ * @param {Set<number>} mutedStrings
165
+ * @param {number} numFrets
166
+ * @param {number|undefined} position
167
+ * @returns {string}
168
+ */
169
+ function buildUnicodeOutput(
170
+ title,
171
+ stringData,
172
+ openStrings,
173
+ mutedStrings,
174
+ numFrets,
175
+ position
176
+ ) {
177
+ const lines = [];
178
+
179
+ // Title section
180
+ if (title && title.length > 0) {
181
+ const clampedTitle = title.length > 15 ? title.slice(0, 15) : title;
182
+ lines.push(` ${clampedTitle}`);
183
+ // Add separator line of # characters (exactly 6 chars minimum)
184
+ lines.push(` ${'‾'.repeat(Math.max(11, clampedTitle.length))}`);
185
+ }
186
+
187
+ // Open/muted strings line - only if there are any
188
+ if (openStrings.size > 0 || mutedStrings.size > 0) {
189
+ let openLine = " ";
190
+ let lowestMarked = 6;
191
+
192
+ // Find lowest numbered string with a marker
193
+ for (let str = 6; str >= 1; str--) {
194
+ if (openStrings.has(str) || mutedStrings.has(str)) {
195
+ lowestMarked = str;
196
+ }
197
+ }
198
+
199
+ // Show from string 6 down to lowestMarked, extending to 3 only if lowestMarked > 4
200
+ const showTo = lowestMarked > 4 ? 3 : lowestMarked;
201
+
202
+ // Build line from string 6 down to showTo with spaces between characters
203
+ const chars = [];
204
+ for (let str = 6; str >= showTo; str--) {
205
+ if (openStrings.has(str)) {
206
+ chars.push("○");
207
+ } else if (mutedStrings.has(str)) {
208
+ chars.push("×");
209
+ } else {
210
+ chars.push(" ");
211
+ }
212
+ }
213
+ openLine += chars.join(" ");
214
+
215
+ lines.push(openLine.trimEnd());
216
+ }
217
+
218
+ // Top border
219
+ lines.push(position === 1 ? " ╒═╤═╤═╤═╤═╕" : " ┌─┬─┬─┬─┬─┐");
220
+
221
+ // Fret lines
222
+ for (let fret = 1; fret <= numFrets; fret++) {
223
+ let line = "";
224
+
225
+ // Add position number on first fret line if specified
226
+ if (fret === 1 && position !== undefined && position > 1) {
227
+ line = position < 10 ? ` ${position}` : `${position}`;
228
+ } else {
229
+ line = " ";
230
+ }
231
+
232
+ // Build fret line (strings 6 to 1, left to right)
233
+ for (let str = 6; str >= 1; str--) {
234
+ const fingerInfo = stringData.get(str)?.get(fret);
235
+
236
+ if (fingerInfo) {
237
+ if (fingerInfo.color !== "#000000") {
238
+ line += "●";
239
+ } else if (fingerInfo.text) {
240
+ line += fingerInfo.text[0];
241
+ } else {
242
+ line += "○";
243
+ }
244
+ } else {
245
+ line += "│";
246
+ }
247
+ if (str > 1) line += " ";
248
+ }
249
+
250
+ lines.push(line);
251
+
252
+ // Add separator or bottom border
253
+ if (fret < numFrets) {
254
+ lines.push(" ├─┼─┼─┼─┼─┤");
255
+ } else {
256
+ lines.push(" └─┴─┴─┴─┴─┘");
257
+ }
258
+ }
259
+
260
+ return lines.join("\n");
261
+ }
@@ -0,0 +1,92 @@
1
+ //@ts-check
2
+
3
+ /**
4
+ * Arranges an array of multi-line chord strings into a grid layout with proper padding.
5
+ * Empty strings are filtered out and trailing spaces are trimmed from each output line.
6
+ *
7
+ * @param {Array<string>} strings - Array of chord strings (can be multi-line)
8
+ * @param {number} columns - Number of columns per row (default: 3)
9
+ * @param {number} spacing - Number of spaces between columns (default: 1)
10
+ * @returns {string} - Formatted grid layout string
11
+ */
12
+ export default function layoutChordStrings(strings, columns = 3, spacing = 1) {
13
+ // Filter out empty strings
14
+ const filtered = strings.filter(s => s !== '');
15
+
16
+ // Handle empty array
17
+ if (filtered.length === 0) {
18
+ return '';
19
+ }
20
+
21
+ // Trim trailing newlines from each string
22
+ const trimmed = filtered.map(s => s.replace(/\n+$/, ''));
23
+
24
+ // Split each string into lines
25
+ const stringLines = trimmed.map(s => s.split('\n'));
26
+
27
+ // Group strings into rows
28
+ const rows = [];
29
+ for (let i = 0; i < stringLines.length; i += columns) {
30
+ rows.push(stringLines.slice(i, i + columns));
31
+ }
32
+
33
+ // Calculate max width for each column across all rows
34
+ const columnWidths = new Array(columns).fill(0);
35
+ for (const row of rows) {
36
+ row.forEach((lines, colIndex) => {
37
+ const maxWidth = Math.max(...lines.map(line => line.length));
38
+ columnWidths[colIndex] = Math.max(columnWidths[colIndex], maxWidth);
39
+ });
40
+ }
41
+
42
+ // Build output
43
+ const outputRows = [];
44
+
45
+ // Determine spacing strategy based on column width uniformity
46
+ // If all columns have the same width of 1: use fixed separator with spacing
47
+ // If all columns have the same width > 1: use 1-space separator (unless spacing overrides)
48
+ // If columns have different widths: use variable padding for better alignment
49
+ const allWidthsEqual = columnWidths.length > 0 && columnWidths.every(w => w === columnWidths[0]);
50
+ const firstWidth = columnWidths[0] || 0;
51
+ const useFixedSpacing = allWidthsEqual && firstWidth === 1;
52
+
53
+ for (const row of rows) {
54
+ // Find max height in this row
55
+ const rowHeight = Math.max(...row.map(lines => lines.length));
56
+
57
+ // Build each line of this row
58
+ const rowLines = [];
59
+ for (let lineIdx = 0; lineIdx < rowHeight; lineIdx++) {
60
+ const lineParts = [];
61
+ for (let colIdx = 0; colIdx < columns; colIdx++) {
62
+ const lines = colIdx < row.length ? row[colIdx] : [];
63
+ const line = lineIdx < lines.length ? lines[lineIdx] : '';
64
+
65
+ if (!useFixedSpacing && colIdx < columns - 1 && colIdx < columnWidths.length - 1) {
66
+ // Variable padding: based on column widths
67
+ // Pad to max(current_width + 1, next_width + 1) to ensure proper alignment
68
+ const padWidth = Math.max(columnWidths[colIdx], columnWidths[colIdx + 1]);
69
+ lineParts.push(line.padEnd(padWidth + spacing));
70
+ } else {
71
+ // Fixed spacing or last column: pad to column width
72
+ lineParts.push(line.padEnd(columnWidths[colIdx]));
73
+ }
74
+ }
75
+
76
+ // Join and trim
77
+ if (useFixedSpacing) {
78
+ // Fixed spacing: join with spacing + 1 separator
79
+ const separator = ' '.repeat(spacing + 1);
80
+ rowLines.push(lineParts.join(separator).trimEnd());
81
+ } else {
82
+ // Variable padding: parts already include spacing
83
+ rowLines.push(lineParts.join('').trimEnd());
84
+ }
85
+ }
86
+ outputRows.push(rowLines.join('\n'));
87
+ }
88
+
89
+ // Use spacing to determine the number of blank lines between rows
90
+ const rowSeparator = '\n'.repeat(spacing + 1);
91
+ return outputRows.join(rowSeparator);
92
+ }
@@ -0,0 +1,132 @@
1
+ //@ts-check
2
+
3
+ /**
4
+ * This function takes a string and splits it into an array of strings,
5
+ * each of the strings is a rectangle present in the original string and
6
+ * separated by spaces
7
+ *
8
+ * How it works:
9
+ * - convert the string into a 2D array of characters
10
+ * - scan the array left to right and top to bottom to find a non space character
11
+ * - once found search all adjacent non space characters using breadth first search,
12
+ * Consider horizontal, vertical and diagonal adjacency
13
+ * - once exhausted the search, determine the rectangle that bounds all found characters
14
+ * - extract the rectangle as a string and add it to the result array
15
+ * - mark all characters in the rectangle as visited (set to space)
16
+ * - continue scanning the array until all characters are visited
17
+ * @param {string} str
18
+ * @returns {Array<string>}
19
+ */
20
+ export default function splitStringInRectangles(str) {
21
+ // Early validation
22
+ if (!str || str.trim() === '') {
23
+ return [];
24
+ }
25
+
26
+ // Convert string to 2D array of characters
27
+ const grid = str.split('\n').map(line => line.split(''));
28
+ const result = [];
29
+
30
+ // 8 directions: horizontal, vertical, and diagonal
31
+ const directions = [
32
+ [-1, -1], [-1, 0], [-1, 1],
33
+ [0, -1], [0, 1],
34
+ [1, -1], [1, 0], [1, 1]
35
+ ];
36
+
37
+ /**
38
+ * BFS to find all connected non-space characters
39
+ * @param {number} startRow
40
+ * @param {number} startCol
41
+ * @returns {{minRow: number, maxRow: number, minCol: number, maxCol: number, positions: Array<[number, number]>}}
42
+ */
43
+ function bfs(startRow, startCol) {
44
+ const queue = [[startRow, startCol]];
45
+ /** @type {Array<[number, number]>} */
46
+ const positions = [[startRow, startCol]];
47
+ let minRow = startRow;
48
+ let maxRow = startRow;
49
+ let minCol = startCol;
50
+ let maxCol = startCol;
51
+
52
+ // Mark starting position as visited
53
+ grid[startRow][startCol] = ' ';
54
+
55
+ while (queue.length > 0) {
56
+ const [row, col] = queue.shift();
57
+
58
+ // Check all 8 directions
59
+ for (const [dr, dc] of directions) {
60
+ const newRow = row + dr;
61
+ const newCol = col + dc;
62
+
63
+ // Check bounds
64
+ if (newRow >= 0 && newRow < grid.length &&
65
+ newCol >= 0 && newCol < grid[newRow].length) {
66
+ // Check if non-space character
67
+ if (grid[newRow][newCol] !== ' ') {
68
+ queue.push([newRow, newCol]);
69
+ positions.push([newRow, newCol]);
70
+
71
+ // Update bounding rectangle
72
+ minRow = Math.min(minRow, newRow);
73
+ maxRow = Math.max(maxRow, newRow);
74
+ minCol = Math.min(minCol, newCol);
75
+ maxCol = Math.max(maxCol, newCol);
76
+
77
+ // Mark as visited
78
+ grid[newRow][newCol] = ' ';
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ return { minRow, maxRow, minCol, maxCol, positions };
85
+ }
86
+
87
+ /**
88
+ * Extract rectangle string from positions
89
+ * @param {{minRow: number, maxRow: number, minCol: number, maxCol: number, positions: Array<[number, number]>}} bounds
90
+ * @returns {string}
91
+ */
92
+ function extractRectangle(bounds) {
93
+ const { minRow, maxRow, minCol, maxCol, positions } = bounds;
94
+ const height = maxRow - minRow + 1;
95
+ const width = maxCol - minCol + 1;
96
+
97
+ // Create 2D array filled with spaces
98
+ const rectGrid = Array(height).fill(null).map(() => Array(width).fill(' '));
99
+
100
+ // Place characters at their relative positions
101
+ for (const [row, col] of positions) {
102
+ const relRow = row - minRow;
103
+ const relCol = col - minCol;
104
+ // Get original character from input (before it was marked as space)
105
+ const lines = str.split('\n');
106
+ if (row < lines.length && col < lines[row].length) {
107
+ rectGrid[relRow][relCol] = lines[row][col];
108
+ }
109
+ }
110
+
111
+ // Convert to string, preserving internal spaces
112
+ return rectGrid
113
+ .map(row => row.join(''))
114
+ .join('\n');
115
+ }
116
+
117
+ // Scan grid left to right, top to bottom
118
+ for (let row = 0; row < grid.length; row++) {
119
+ for (let col = 0; col < grid[row].length; col++) {
120
+ // Find non-space character
121
+ if (grid[row][col] !== ' ') {
122
+ // BFS to find all connected characters
123
+ const bounds = bfs(row, col);
124
+ // Extract rectangle and add to result
125
+ const rectangle = extractRectangle(bounds);
126
+ result.push(rectangle);
127
+ }
128
+ }
129
+ }
130
+
131
+ return result;
132
+ }