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,435 @@
1
+ //@ts-check
2
+
3
+ // ASCII format characters
4
+ const ASCII_VERTICAL = "|";
5
+ const ASCII_DASH = "-";
6
+ const ASCII_EQUALS = "=";
7
+ const ASCII_OPEN = "o";
8
+ const ASCII_MUTED = "x";
9
+ const ASCII_ROOT = "*";
10
+
11
+ // Unicode format characters
12
+ const UNICODE_VERTICAL = "│";
13
+ const UNICODE_OPEN = "○";
14
+ const UNICODE_MUTED = "×";
15
+ const UNICODE_ROOT = "●";
16
+
17
+ // Unicode box drawing characters for grid detection
18
+ // Includes both double-line (╒═╤╕) and light (┌─┬┐) box-drawing sets
19
+ const UNICODE_BOX_CHARS = "╒═╤╕├─┼┤└┴┘┌┬┐";
20
+
21
+ /**
22
+ * Detect if the string uses Unicode format
23
+ * @param {string} str
24
+ * @returns {boolean}
25
+ */
26
+ function isUnicodeFormat(str) {
27
+ return (
28
+ str.includes(UNICODE_VERTICAL) ||
29
+ str.includes(UNICODE_OPEN) ||
30
+ str.includes(UNICODE_ROOT) ||
31
+ str.includes(UNICODE_MUTED) ||
32
+ [...UNICODE_BOX_CHARS].some(c => str.includes(c))
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Find grid boundaries for Unicode format
38
+ * Unicode grids use box-drawing characters with consistent spacing.
39
+ * The grid line looks like: ╒═╤═╤═╤═╤═╕ or │ │ │ │ │ │
40
+ * Separators are at even positions, cells (where notes go) are at odd positions.
41
+ * @param {string[]} lines
42
+ * @param {number} firstGridRowIdx
43
+ * @returns {{ startCol: number, endCol: number, numStrings: number }}
44
+ */
45
+ function findUnicodeGridBoundaries(lines, firstGridRowIdx) {
46
+ let minPos = Infinity;
47
+ let maxPos = -1;
48
+
49
+ // Scan all grid lines to find the full extent
50
+ for (let i = firstGridRowIdx; i < lines.length; i++) {
51
+ const line = lines[i];
52
+ for (let j = 0; j < line.length; j++) {
53
+ const char = line[j];
54
+ if (char === UNICODE_VERTICAL || "╒╤╕├┼┤└┴┘═─┌┬┐".includes(char)) {
55
+ if (j < minPos) minPos = j;
56
+ if (j > maxPos) maxPos = j;
57
+ }
58
+ }
59
+ }
60
+
61
+ if (minPos === Infinity || maxPos === -1) {
62
+ return { startCol: 0, endCol: 0, numStrings: 0 };
63
+ }
64
+
65
+ // In Unicode format, grid cells are spaced every 2 characters
66
+ // For a grid spanning positions 2-12 (width 11), we have 6 positions: 2,4,6,8,10,12
67
+ // Which maps to 6 strings
68
+ const numStrings = Math.floor((maxPos - minPos) / 2) + 1;
69
+ return { startCol: minPos, endCol: maxPos, numStrings };
70
+ }
71
+
72
+ /**
73
+ * Map a character position to a string number for Unicode format (1-6, right to left)
74
+ * @param {number} charPos - Character position in the line
75
+ * @param {number} startCol - Start column of the grid
76
+ * @param {number} numStrings - Number of strings
77
+ * @returns {number} String number (1-6) or -1 if out of bounds
78
+ */
79
+ function unicodeCharPosToStringNum(charPos, startCol, numStrings) {
80
+ // In Unicode format, positions are at startCol, startCol+2, startCol+4, etc.
81
+ const offset = charPos - startCol;
82
+ if (offset < 0 || offset % 2 !== 0) return -1;
83
+ const idx = offset / 2;
84
+ if (idx >= numStrings) return -1;
85
+ // String 6 is at idx 0, string 1 is at idx (numStrings - 1)
86
+ return numStrings - idx;
87
+ }
88
+
89
+ /**
90
+ * Find grid boundaries for ASCII format
91
+ * In the new format, the grid looks like: ------
92
+ * And fret rows look like: |||||| or |||o||
93
+ * The first dash/equals line or pipe sequence determines the grid extent.
94
+ * @param {string[]} lines
95
+ * @param {number} firstGridRowIdx
96
+ * @returns {{ startCol: number, endCol: number, numStrings: number }}
97
+ */
98
+ function findAsciiGridBoundaries(lines, firstGridRowIdx) {
99
+ let minPos = Infinity;
100
+ let maxPos = -1;
101
+
102
+ // Scan all grid lines to find the full extent of the grid
103
+ for (let i = firstGridRowIdx; i < lines.length; i++) {
104
+ const line = lines[i];
105
+
106
+ // Look for continuous sequences of pipes or dashes/equals
107
+ let inSequence = false;
108
+ let seqStart = -1;
109
+
110
+ for (let j = 0; j < line.length; j++) {
111
+ const char = line[j];
112
+ const isGridChar = char === ASCII_VERTICAL || char === ASCII_DASH || char === ASCII_EQUALS;
113
+
114
+ if (isGridChar && !inSequence) {
115
+ // Start of sequence
116
+ inSequence = true;
117
+ seqStart = j;
118
+ } else if (!isGridChar && inSequence) {
119
+ // End of sequence
120
+ inSequence = false;
121
+ const seqEnd = j - 1;
122
+ if (seqStart < minPos) minPos = seqStart;
123
+ if (seqEnd > maxPos) maxPos = seqEnd;
124
+ }
125
+ }
126
+
127
+ // Handle sequence that extends to end of line
128
+ if (inSequence) {
129
+ const seqEnd = line.length - 1;
130
+ if (seqStart < minPos) minPos = seqStart;
131
+ if (seqEnd > maxPos) maxPos = seqEnd;
132
+ }
133
+ }
134
+
135
+ if (minPos === Infinity || maxPos === -1) {
136
+ return { startCol: 0, endCol: 0, numStrings: 0 };
137
+ }
138
+
139
+ // Number of strings is the span from minPos to maxPos inclusive
140
+ const numStrings = maxPos - minPos + 1;
141
+ return { startCol: minPos, endCol: maxPos, numStrings };
142
+ }
143
+
144
+ /**
145
+ * Map a character position to a string number for ASCII format (1-6, right to left)
146
+ * @param {number} charPos - Character position in the line
147
+ * @param {number} startCol - Start column of the grid
148
+ * @param {number} numStrings - Number of strings (typically 6)
149
+ * @returns {number} String number (1-6) or -1 if out of bounds
150
+ */
151
+ function asciiCharPosToStringNumber(charPos, startCol, numStrings) {
152
+ const offset = charPos - startCol;
153
+ if (offset < 0 || offset >= numStrings) return -1;
154
+ // String 6 is at offset 0, string 1 is at offset (numStrings - 1)
155
+ return numStrings - offset;
156
+ }
157
+
158
+ /**
159
+ * Check if a line is a grid row (contains structural characters like pipes, dashes, box drawing)
160
+ * In new ASCII format: ------ or ====== for separator, |||||| for content
161
+ * @param {string} line
162
+ * @param {boolean} isUnicode
163
+ * @returns {boolean}
164
+ */
165
+ function isGridRow(line, isUnicode) {
166
+ if (isUnicode) {
167
+ // For Unicode, look for box drawing characters or vertical bars
168
+ let count = 0;
169
+ for (const char of line) {
170
+ if (char === UNICODE_VERTICAL || "╒╤╕├┼┤└┴┘┌┬┐".includes(char)) count++;
171
+ }
172
+ return count >= 2;
173
+ } else {
174
+ // For ASCII, check for:
175
+ // 1. Separator lines: 4+ consecutive dashes or equals
176
+ // 2. Content lines: multiple pipes (even if interrupted by notes/digits)
177
+ const hasDashes = /-{4,}/.test(line);
178
+ const hasEquals = /={4,}/.test(line);
179
+ if (hasDashes || hasEquals) return true;
180
+
181
+ // Count pipes for content rows (allow notes/digits between pipes)
182
+ const pipeCount = (line.match(/\|/g) || []).length;
183
+ return pipeCount >= 4;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Check if a Unicode line is a fret content row (has │) vs separator row (has ├, ─, etc.)
189
+ * @param {string} line
190
+ * @returns {boolean}
191
+ */
192
+ function isUnicodeFretRow(line) {
193
+ // Fret rows contain │ (vertical bar with content), not just ├┼┤ (horizontal connectors)
194
+ if (line.includes(UNICODE_VERTICAL)) return true;
195
+
196
+ // Also check for note characters (for rows with notes but no vertical bars)
197
+ const noteChars = [UNICODE_OPEN, UNICODE_ROOT, UNICODE_MUTED];
198
+ for (const char of line) {
199
+ if (noteChars.includes(char) || /[0-9]/.test(char)) return true;
200
+ }
201
+ return false;
202
+ }
203
+
204
+ /**
205
+ * Check if an ASCII line is a fret content row (has |) vs separator row (has - or =)
206
+ * @param {string} line
207
+ * @returns {boolean}
208
+ */
209
+ function isAsciiFretRow(line) {
210
+ // Separator rows contain dashes or equals
211
+ if (/-{4,}/.test(line) || /={4,}/.test(line)) return false;
212
+
213
+ // Fret rows contain pipes
214
+ if (line.includes(ASCII_VERTICAL)) return true;
215
+
216
+ // Also check for note characters (for rows with notes but no pipes)
217
+ const noteChars = [ASCII_OPEN, ASCII_ROOT, ASCII_MUTED];
218
+ for (const char of line) {
219
+ if (noteChars.includes(char) || /[0-9]/.test(char)) return true;
220
+ }
221
+ return false;
222
+ }
223
+
224
+ /**
225
+ * Parse a string representation of a guitar fingering into internal format
226
+ * @param {string} fingeringStr - The string representation of the fingering
227
+ * @param {{ redColor?: string, blackColor?: string }} [options]
228
+ * @returns {import("svguitar").Chord | null} The parsed fingering object
229
+ */
230
+ export default function stringToFingering(fingeringStr, options = {}) {
231
+ const { redColor = "#e74c3c", blackColor = "#000000" } = options;
232
+
233
+ if (!fingeringStr || fingeringStr.trim() === "") {
234
+ return null;
235
+ }
236
+
237
+ const lines = fingeringStr.split("\n");
238
+ const isUnicode = isUnicodeFormat(fingeringStr);
239
+
240
+ const openChar = isUnicode ? UNICODE_OPEN : ASCII_OPEN;
241
+ const mutedChar = isUnicode ? UNICODE_MUTED : ASCII_MUTED;
242
+ const rootChar = isUnicode ? UNICODE_ROOT : ASCII_ROOT;
243
+
244
+ /** @type {import("svguitar").Finger[]} */
245
+ const fingers = [];
246
+ /** @type {string | undefined} */
247
+ let title;
248
+ /** @type {number | undefined} */
249
+ let position;
250
+
251
+ // Find the first grid row
252
+ let firstGridRowIdx = -1;
253
+ for (let i = 0; i < lines.length; i++) {
254
+ if (isGridRow(lines[i], isUnicode)) {
255
+ firstGridRowIdx = i;
256
+ break;
257
+ }
258
+ }
259
+
260
+ if (firstGridRowIdx === -1) {
261
+ // No valid grid found
262
+ return null;
263
+ }
264
+
265
+ // Get column mapping based on format
266
+ let startCol = 0;
267
+ let numStrings = 6;
268
+
269
+ if (isUnicode) {
270
+ const bounds = findUnicodeGridBoundaries(lines, firstGridRowIdx);
271
+ startCol = bounds.startCol;
272
+ numStrings = bounds.numStrings;
273
+ if (numStrings === 0) {
274
+ return { fingers: [], barres: [] };
275
+ }
276
+ // Check if first grid row uses ╒═╤ pattern (indicates position = 1)
277
+ const firstGridLine = lines[firstGridRowIdx];
278
+ if (firstGridLine.includes("╒") && firstGridLine.includes("═")) {
279
+ position = 1;
280
+ }
281
+ } else {
282
+ const bounds = findAsciiGridBoundaries(lines, firstGridRowIdx);
283
+ startCol = bounds.startCol;
284
+ numStrings = bounds.numStrings;
285
+ if (numStrings === 0) {
286
+ return { fingers: [], barres: [] };
287
+ }
288
+ // Check if first grid row uses ====== pattern (indicates position = 1)
289
+ const firstGridLine = lines[firstGridRowIdx];
290
+ if (/={4,}/.test(firstGridLine)) {
291
+ position = 1;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Get string number for a character position
297
+ * @param {number} charPos
298
+ * @returns {number}
299
+ */
300
+ const getStringNumber = (charPos) => {
301
+ if (isUnicode) {
302
+ return unicodeCharPosToStringNum(charPos, startCol, numStrings);
303
+ } else {
304
+ return asciiCharPosToStringNumber(charPos, startCol, numStrings);
305
+ }
306
+ };
307
+
308
+ // Parse title from lines before the grid
309
+ // Title must be followed by a separator line (all # or all ‾)
310
+ for (let i = 0; i < firstGridRowIdx - 1; i++) {
311
+ const line = lines[i];
312
+ const nextLine = lines[i + 1];
313
+ const trimmed = line.trim();
314
+
315
+ if (trimmed === "") continue;
316
+
317
+ // Check if next line is a separator line
318
+ const asciiSeparatorPattern = /^[\s#]+$/;
319
+ const unicodeSeparatorPattern = /^[\s‾]+$/;
320
+ const nextTrimmed = nextLine.trim();
321
+
322
+ if (asciiSeparatorPattern.test(nextTrimmed) || unicodeSeparatorPattern.test(nextTrimmed)) {
323
+ // This line is the title
324
+ title = trimmed;
325
+ break;
326
+ }
327
+ }
328
+
329
+ // If no title found, set to empty string
330
+ if (title === undefined) {
331
+ title = "";
332
+ }
333
+
334
+ // Parse open/muted string indicators (line just before first grid row)
335
+ const indicatorLineIdx = firstGridRowIdx - 1;
336
+ if (indicatorLineIdx >= 0) {
337
+ const indicatorLine = lines[indicatorLineIdx];
338
+ for (let i = 0; i < indicatorLine.length; i++) {
339
+ const char = indicatorLine[i];
340
+ const stringNum = getStringNumber(i);
341
+ if (stringNum <= 0) continue;
342
+
343
+ if (char === openChar || (!isUnicode && char === ASCII_OPEN)) {
344
+ fingers.push([stringNum, 0, { text: "", color: blackColor }]);
345
+ } else if (char === mutedChar || (!isUnicode && char === ASCII_MUTED)) {
346
+ fingers.push([stringNum, "x", { text: "", color: blackColor }]);
347
+ }
348
+ }
349
+ }
350
+
351
+ // Parse fret rows
352
+ let fretNumber = 1;
353
+ let isFirstFretRow = true;
354
+
355
+ for (let lineIdx = firstGridRowIdx; lineIdx < lines.length; lineIdx++) {
356
+ const line = lines[lineIdx];
357
+
358
+ // Check if this is a grid row or a fret content row
359
+ const isStructuralGridRow = isGridRow(line, isUnicode);
360
+
361
+ // If not a structural grid row, check if it's a note-only fret row
362
+ let isFretContentRow = false;
363
+ if (!isStructuralGridRow) {
364
+ // Check for note characters (only after first grid row is found)
365
+ if (isUnicode) {
366
+ isFretContentRow = isUnicodeFretRow(line);
367
+ } else {
368
+ isFretContentRow = isAsciiFretRow(line);
369
+ }
370
+ } else {
371
+ // It's a structural grid row, check if it's content vs separator
372
+ if (isUnicode) {
373
+ isFretContentRow = isUnicodeFretRow(line);
374
+ } else {
375
+ isFretContentRow = isAsciiFretRow(line);
376
+ }
377
+ }
378
+
379
+ // Skip if not a fret content row
380
+ if (!isFretContentRow) continue;
381
+
382
+ // Check for position number at start of first fret row (1-2 digits)
383
+ if (isFirstFretRow) {
384
+ const posMatch = line.match(/^\s*(\d{1,2})[\s│|]/);
385
+ if (posMatch) {
386
+ position = parseInt(posMatch[1], 10);
387
+ }
388
+ isFirstFretRow = false;
389
+ }
390
+
391
+ // Scan for fretted notes and finger numbers in this row
392
+ for (let i = 0; i < line.length; i++) {
393
+ const char = line[i];
394
+
395
+ const stringNum = getStringNumber(i);
396
+ if (stringNum <= 0) continue;
397
+
398
+ // Skip empty positions and structural characters
399
+ if (isUnicode) {
400
+ if (char === UNICODE_VERTICAL || "╒═╤╕├─┼┤└┴┘┌┬┐".includes(char) || char === " ") continue;
401
+ } else {
402
+ // In ASCII format, skip pipes and spaces, but process notes/digits
403
+ if (char === ASCII_VERTICAL || char === " ") continue;
404
+ }
405
+
406
+ // Check for root marker
407
+ if (char === rootChar) {
408
+ fingers.push([stringNum, fretNumber, { text: "", color: redColor }]);
409
+ }
410
+ // Check for regular note (○ in Unicode, o in ASCII within grid)
411
+ else if (char === UNICODE_OPEN || char === ASCII_OPEN) {
412
+ fingers.push([stringNum, fretNumber, { text: "", color: blackColor }]);
413
+ }
414
+ // Check for finger number (digit)
415
+ else if (/[0-9]/.test(char)) {
416
+ fingers.push([stringNum, fretNumber, { text: char, color: blackColor }]);
417
+ }
418
+ }
419
+
420
+ fretNumber++;
421
+ }
422
+
423
+ /** @type {import("svguitar").Chord} */
424
+ const result = {
425
+ fingers,
426
+ barres: [],
427
+ title,
428
+ };
429
+
430
+ if (position !== undefined) {
431
+ result.position = position;
432
+ }
433
+
434
+ return result;
435
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "text-guitar-chart",
3
+ "version": "0.0.1",
4
+ "description": "A JavaScript library to write text based guitar chord charts and convert them to SVG.",
5
+ "main": "index.js",
6
+ "types": "types/index.d.ts",
7
+ "type": "module",
8
+ "scripts": {
9
+ "test": "node --test test/*.test.js",
10
+ "test:watch": "node --test --watch test/*.test.js",
11
+ "build:types": "tsc",
12
+ "build": "node ./scripts/build.js",
13
+ "serve:demo": "npx http-server -p 8080 docs"
14
+ },
15
+ "keywords": [
16
+ "guitar",
17
+ "chords",
18
+ "music",
19
+ "voicing",
20
+ "intervals",
21
+ "inversions"
22
+ ],
23
+ "author": "Maurizio Lupo <maurizio.lupo@gmail.com>",
24
+ "license": "MIT",
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/sithmel/text-guitar-chart.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/sithmel/text-guitar-chart/issues"
34
+ },
35
+ "homepage": "https://github.com/sithmel/text-guitar-chart#readme",
36
+ "dependencies": {
37
+ "svguitar": "^2.5.0"
38
+ },
39
+ "devDependencies": {
40
+ "esbuild": "^0.27.2",
41
+ "typescript": "^5.9.2"
42
+ }
43
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ //@ts-check
3
+ import { build } from 'esbuild';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, resolve } from 'node:path';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ const root = resolve(__dirname, '..');
12
+
13
+ async function run() {
14
+ // Build demo bundle
15
+ await build({
16
+ entryPoints: [resolve(root, 'docs', 'app.js')],
17
+ bundle: true,
18
+ format: 'esm',
19
+ platform: 'browser',
20
+ target: ['es2018'],
21
+ sourcemap: true,
22
+ outfile: resolve(root, 'docs', 'bundle.js'),
23
+ logLevel: 'info',
24
+ });
25
+
26
+ const demoSize = readFileSync(resolve(root, 'docs', 'bundle.js')).length;
27
+ console.log(`EditableSVGuitar demo bundle written: ${demoSize} bytes`);
28
+ }
29
+
30
+ run().catch((err) => { console.error(err); process.exit(1); });