/**
* 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 (
``
);
}
/** 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);
}