From 7444c791a71fee2ebd3daed841abf2c2262088e5 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Wed, 23 Sep 2026 23:21:50 +0200 Subject: [PATCH] Render chord diagrams for {define} directives See https://www.chordpro.org/chordpro/directives-define/ --- chordpro-web.js | 62 +++++++++++++++++++++++++++++++++++++++++++++++-- chordpro.css | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/chordpro-web.js b/chordpro-web.js index 5f5ba2f..93a859a 100644 --- a/chordpro-web.js +++ b/chordpro-web.js @@ -9,7 +9,57 @@ const el_song = document.getElementById('song'); // chord of a line still end up in the lyrics row; see chordpro.css. const segment = (chord, text) => html`${chord}${text}`; -// Handlers return the song content to render for the directive, if any +// {define: name [base-fret N] frets p… [fingers f…]}, see +// https://www.chordpro.org/chordpro/directives-define/ +const define_keywords = ['base-fret', 'frets', 'fingers', 'keys', 'diagram', 'display', 'copy', 'copyall', 'format']; +const define_supported = ['base-fret', 'frets', 'fingers']; + +function parseDefine(value) { + const [name, ...tokens] = value.split(/\s+/); + const props = {}; + let key; + for (const token of tokens) { + if (define_keywords.includes(token)) + props[key = token] = []; + else + props[key]?.push(token); + } + if (!props.frets) { + console.warn('chord definition without frets:', value); + return null; + } + for (const k of Object.keys(props)) + if (!define_supported.includes(k)) + console.warn(`ignoring ${k} in chord definition:`, value); + + return { + name, + base: Number(props['base-fret']?.[0] ?? 1), + // null marks a muted string + frets: props.frets.map(p => /^(x|N|-1)$/.test(p) ? null : Number(p)), + fingers: props.fingers ?? [], + }; +} + +// Strings are table columns, frets are rows; see chordpro.css +function chordDiagram({ name, base, frets, fingers }) { + const rows = Array.from({ length: Math.max(4, ...frets) }, (_, i) => i + 1); + const marker = f => f === null ? 'x' : f === 0 ? 'o' : ''; + const dot = i => html`${/^[1-9]$/.test(fingers[i]) ? fingers[i] : ''}`; + return html`
+
${name}
+ + ${frets.map(f => html``)} + ${rows.map(row => html` + + ${frets.map((f, i) => html``)} + `)} +
${marker(f)}
${row === 1 && base > 1 ? base : ''}${f === row ? dot(i) : ''}
+
`; +} + +// Handlers get the directive argument and the list of chord definitions, and +// return the song content to render for the directive, if any const directive_handlers = { title: value => { document.getElementById('title').textContent = value; @@ -22,6 +72,11 @@ const directive_handlers = { document.getElementById('key').textContent = value; }, comment: value => html`

${value}

`, + define: (value, chords) => { + const chord = parseDefine(value); + if (chord) + chords.push(chord); + }, }; // short forms directive_handlers.t = directive_handlers.title; @@ -30,6 +85,7 @@ directive_handlers.c = directive_handlers.comment; function renderChordpro(text) { const song = []; + const chords = []; let verseLines = []; let verseHasChords = false; @@ -52,7 +108,7 @@ function renderChordpro(text) { const [, directive, value] = m; const handler = directive_handlers[directive]; if (handler) - song.push(handler(value ?? '')); + song.push(handler(value ?? '', chords)); else console.warn('unknown directive:', line); } else if (line === '') { @@ -78,6 +134,8 @@ function renderChordpro(text) { } } flushVerse(); + if (chords.length > 0) + song.push(html`
${chords.map(chordDiagram)}
`); render(song, el_song); } diff --git a/chordpro.css b/chordpro.css index 75ddada..628b0fd 100644 --- a/chordpro.css +++ b/chordpro.css @@ -14,6 +14,58 @@ h2, .comment { content: '𝄞 '; } +/* Chord diagrams: strings are columns with a line through the middle, frets + are the row borders. The header row marks open (o) and muted (x) strings, + the header column shows the base fret. */ +.diagrams { + display: flex; + flex-wrap: wrap; + gap: 1em 2em; + font-size: 0.8em; + + figure { + margin: 0; + text-align: center; + } + + figcaption { + font-weight: bold; + } + + table { + border-collapse: collapse; + } + + td, th { + width: 1.5em; + height: 1.5em; + padding: 0; + font-weight: normal; + text-align: center; + } + + tbody td { + border-top: 1px solid; + border-bottom: 1px solid; + background: linear-gradient(currentColor, currentColor) center / 1px 100% no-repeat; + } + + .nut tbody tr:first-child td { + border-top-width: 3px; + } + + /* finger position, with the finger number if known */ + .dot { + display: inline-block; + width: 1.2em; + height: 1.2em; + line-height: 1.2em; + border-radius: 50%; + background: CanvasText; + color: Canvas; + } +} + .verse { white-space: pre-line; } -- 2.47.3