/** * Minimal ChordPro parser + SVG leadsheet formatter (issue #155). * * Deliberately hand-rolled instead of pulling in a chord-sheet library: the * subset a leadsheet needs is small (directives, `[chord]` markers, comments, * chorus fences), and the SVG layout would be custom code either way — this * keeps the plugin dependency-free (supply-chain lesson from #136). * * Supported ChordPro: * - `{title: …}` / `{t: …}`, `{subtitle: …}`/`{st: …}`/`{artist: …}`, * `{key: …}`, `{capo: …}`, `{tempo: …}` * - `{comment: …}` / `{c: …}` — shown as an italic line * - `{start_of_chorus}`/`{soc}` … `{end_of_chorus}`/`{eoc}` — indented block * - lyric lines with inline `[C]`/`[Am7]`/… chord markers * - `#` comment lines (ignored), blank lines (verse separation) */ export interface SheetMeta { title: string; subtitle: string; key: string; capo: string; tempo: string; } export interface SheetLine { kind: 'pair' | 'comment' | 'blank'; /** Lyric text with the chord markers removed. */ lyrics: string; /** Chords with the lyric column (character index) they sit above. */ chords: { at: number; chord: string }[]; chorus: boolean; } export interface Sheet { meta: SheetMeta; lines: SheetLine[]; } const DIRECTIVE = /^\{\s*([a-z_]+)\s*(?::\s*(.*?))?\s*\}\s*$/i; const CHORD = /\[([^\][\n]+)\]/g; export function parseChordPro(source: string): Sheet { const meta: SheetMeta = { title: '', subtitle: '', key: '', capo: '', tempo: '' }; const lines: SheetLine[] = []; let chorus = false; for (const raw of source.split('\n')) { const line = raw.replace(/\s+$/, ''); if (line.trimStart().startsWith('#')) continue; const directive = DIRECTIVE.exec(line.trim()); if (directive) { const name = directive[1]!.toLowerCase(); const value = (directive[2] ?? '').trim(); if (name === 'title' || name === 't') meta.title = value; else if (name === 'subtitle' || name === 'st' || name === 'artist') meta.subtitle = value; else if (name === 'key') meta.key = value; else if (name === 'capo') meta.capo = value; else if (name === 'tempo') meta.tempo = value; else if (name === 'start_of_chorus' || name === 'soc') chorus = true; else if (name === 'end_of_chorus' || name === 'eoc') chorus = false; else if (name === 'comment' || name === 'c') { lines.push({ kind: 'comment', lyrics: value, chords: [], chorus }); } // Unknown directives are ignored on purpose (forward compatibility). continue; } if (line.trim() === '') { lines.push({ kind: 'blank', lyrics: '', chords: [], chorus }); continue; } let lyrics = ''; const chords: { at: number; chord: string }[] = []; let last = 0; for (const match of line.matchAll(CHORD)) { lyrics += line.slice(last, match.index); chords.push({ at: lyrics.length, chord: match[1]! }); last = match.index! + match[0].length; } lyrics += line.slice(last); lines.push({ kind: 'pair', lyrics, chords, chorus }); } // Trim leading/trailing blank lines. while (lines[0]?.kind === 'blank') lines.shift(); while (lines[lines.length - 1]?.kind === 'blank') lines.pop(); return { meta, lines }; } /** Monospace metrics — fixed so the static SVG lays out identically wherever * it renders (editor sandbox, public view, PDF export). */ const CHAR_W = 8.4; const LINE_H = 18; const PAD = 12; const CHORUS_INDENT = 2; function escapeXml(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } /** The sheet as a self-contained SVG: chords above lyrics, monospace grid. */ export function sheetToSvg(sheet: Sheet): string { const parts: string[] = []; let y = PAD + 4; let maxCols = 0; const push = (text: string, x: number, options: string): void => { parts.push(`${escapeXml(text)}`); }; if (sheet.meta.title) { y += 20; push(sheet.meta.title, PAD, 'font-size="18" font-weight="bold" font-family="sans-serif"'); maxCols = Math.max(maxCols, (sheet.meta.title.length * 11) / CHAR_W); } const subtitleBits = [ sheet.meta.subtitle, sheet.meta.key && `Key: ${sheet.meta.key}`, sheet.meta.capo && `Capo: ${sheet.meta.capo}`, sheet.meta.tempo && `♩ ${sheet.meta.tempo}`, ].filter(Boolean) as string[]; if (subtitleBits.length > 0) { y += LINE_H; push(subtitleBits.join(' — '), PAD, 'font-size="13" fill="#555" font-family="sans-serif"'); maxCols = Math.max(maxCols, subtitleBits.join(' — ').length); } if (sheet.meta.title || subtitleBits.length > 0) y += 8; for (const line of sheet.lines) { const indent = line.chorus ? CHORUS_INDENT : 0; const x = PAD + indent * CHAR_W; if (line.kind === 'blank') { y += LINE_H * 0.7; continue; } if (line.kind === 'comment') { y += LINE_H; push( line.lyrics, x, 'font-size="13" font-style="italic" fill="#555" font-family="sans-serif"', ); maxCols = Math.max(maxCols, indent + line.lyrics.length); continue; } if (line.chords.length > 0) { y += LINE_H; for (const { at, chord } of line.chords) { parts.push( `` + `${escapeXml(chord)}`, ); maxCols = Math.max(maxCols, indent + at + chord.length); } } y += LINE_H; push( line.lyrics, x, 'font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" xml:space="preserve"', ); maxCols = Math.max(maxCols, indent + line.lyrics.length); } const width = Math.max(240, Math.ceil(PAD * 2 + maxCols * CHAR_W)); const height = y + PAD; return ( `` + parts.join('') + `` ); } /** Parse + render in one step; throws on an empty sheet so callers can show * their empty-state hint instead of a blank box. */ export function chordProToSvg(source: string): string { const sheet = parseChordPro(source); if (sheet.lines.length === 0 && !sheet.meta.title) { throw new Error('empty sheet'); } return sheetToSvg(sheet); }