chordsong
Version:
ChordSong is a simple text format for the notation of lyrics with guitar chords, and an application that renders them to portable HTML pages.
330 lines (282 loc) • 12.4 kB
text/typescript
import { Chord } from './Chord'
import { Diagram } from './Diagram'
import { SvgDiagram } from './SvgDiagram'
/**
* group 0: match
* group 1: chordShortName
* group 2: chordName
* group 3: chordVariant
* group 4: chordDiagram
* group 5: onlyDiagram
* group 6: space until next diagram/comment/linebreak
* group 7: comment length (if any)
*/
const regexChordName = '([A-G](?:[b#♭♯غ∆()+-]|[2-9]|1[0-3]|[Mm](?:aj)?|add|s(?:us)?|aug|d(?:im)?)*(?:\\/[A-G][b#♭♯]?)?)'
const regexChordShortName = regexChordName
const regexChordLongName = regexChordName
const chordsRegExFields = {
pattern: `(?:(?:\\b|^)(?:${regexChordShortName}\\|)?(?:${regexChordLongName}(?::(\\w+))?(?:\\[([x:/,\\-0-9]+)\\])?)|(?:\\[([x:/,\\-0-9]+)\\]))([\\t ]*)(\\/\\/.*)?`,
flags: 'g'
}
const chordLinesRegExFields = {
pattern: `^[\\t ]*(${chordsRegExFields.pattern})+[\\t ]*$`,
flags: 'gm'
}
// export const chordsRegEx: RegExp = /(?:(?:\b|^)(?:([A-G](?:[b#♭♯غ∆()+-]|[2-9]|1[0-3]|M(?:aj)?|m(?:aj)?|add|sus|aug|dim)*(?:\/[A-G][b#♭♯]?)?)(?::(\w+))?(?:\[([x:/,\-0-9]+)\])?)|(?:\[([x:/,\-0-9]+)\]))([\t ]*)/g
// export const chordLineRegEx: RegExp = /^([\t ]*(?:(?:\b|^)(?:([A-G](?:[b#♭♯غ∆()+-]|[2-9]|1[0-3]|M(?:aj)?|m(?:aj)?|add|sus|aug|dim)*(?:\/[A-G][b#♭♯]?)?)(?::(\w+))?(?:\[([x:/,\-0-9]+)\])?)|(?:\[([x:/,\-0-9]+)\]))[\t ]*)+$/gm
export const chordsRegEx = new RegExp(chordsRegExFields.pattern, chordsRegExFields.flags)
const chordLineRegEx = new RegExp(chordLinesRegExFields.pattern, chordLinesRegExFields.flags)
interface Replacement {
index: number
length: number
replacement: string
}
interface Directive {
title: string
content: string
}
export type RenderMode = 'chordName' | 'diagram'
export class Renderer {
chords: {
[name: string]: Chord
} = {}
directives: Directive[] = []
replacements: Replacement[] = []
chordRenderMode: RenderMode
text: string = ''
constructor (text: string, defaultRenderMode: RenderMode = 'chordName') {
this.text = text
// If I don't add a linebreak in the end if the last thing is a chord it won't be parsed
if (this.text[this.text.length - 1] !== '\n\n') this.text += '\n\n'
this.chordRenderMode = defaultRenderMode
this.parseDirectives()
this.parseLyricsComments()
this.parseChords()
}
get title (): string {
return this.directive('title')
}
get artist (): string {
return this.directive('artist')
}
directive (name: string): string {
const titleDirective: Directive[] = this.directives.filter(directive => directive.title === name)
return (titleDirective.length > 0) ? titleDirective[0].content : ''
}
parseChords (): void {
const chordLines = Array.from(this.text.matchAll(chordLineRegEx))
for (let i = 0; i < chordLines.length; i++) {
const chordLine = chordLines[i]
const lineIndex: number = chordLine.index ?? 0
/* let's findout if the next line contains lyrics.
If that is the case we'll join them in html section
*/
const nextLineIndex = lineIndex + chordLine[0].length + 1
const nextLineLength = this.text.substr(nextLineIndex).indexOf('\n') + 1
const nextLineIsLyrics = ((): boolean => {
if (i === chordLines.length - 1) {
return nextLineIndex < this.text.length
}
const nextChordLine = chordLines[i + 1]
const nextChordLineIndex = nextChordLine.index ?? 0
if (nextChordLineIndex !== nextLineIndex && this.text[nextLineIndex] !== '\n') {
return true
}
return false
})()
if (nextLineIsLyrics) {
const lineSectionStart = '<section class="chords-line">'
this.replacements.push({ index: lineIndex, length: 0, replacement: lineSectionStart })
const lineSectionStop = '</section>'
this.replacements.push({ index: nextLineIndex + nextLineLength - 1, length: 0, replacement: lineSectionStop })
}
const chordMatches = chordLine[0].matchAll(chordsRegEx)
for (const match of chordMatches) {
const index: number = lineIndex + (match.index ?? 0)
const width = match[0].length - (match[7]?.length ?? 0) // I remove the comments length (if any comment at the end of the line)
const chordName = match[1] ?? match[2]
const chordRenderName = match[2]
const chordVariant = match[3]
const chordDiagram = match[4]
const diagramOnly = match[5]
let replacement: string
if (diagramOnly !== undefined) { // a diagram alone
const diagram = new Diagram(match[5])
replacement = this.renderDiagram(diagram, width)
this.replacements.push({ index, length: width, replacement })
} else {
const chord = chordName in this.chords ? this.chords[chordName] : new Chord(chordName)
if (chordDiagram !== undefined) { // It's a chord definition
const diagram = new Diagram(chordDiagram, chordRenderName, chordVariant)
chord.setDiagram(diagram, chordVariant)
this.replacements.push({ index, length: match[0].length, replacement: '' }) // remove it
} else {
replacement = this.renderChord(chord, width, chordVariant, this.chordRenderMode)
this.replacements.push({ index, length: width, replacement })
}
this.chords[chordName] = chord
}
}
}
}
parseDirectives (): number {
const regEx = /{([\w-]+):\s*([\S\s]+?)}/g
const matches = this.text.matchAll(regEx)
let index: number = 0
let width: number = 0
for (const match of matches) {
index = match.index ?? 0
width = match[0].length
const title: string = match[1]
const content: string = match[2]
let replacement = ''
switch (title) {
case 'columns':
break
case 'render-mode':
if (content === 'diagram' || content === 'chordName') this.chordRenderMode = content
break
case 'lyrics-font-size':
break
case 'lyrics-font-color':
break
case 'chords-font-size':
break
case 'chords-font-color':
break
case 'comments-font-size':
break
case 'comments-font-color':
break
case 'show-chord-diagrams':
break
default:
replacement = this.renderDirective({ title, content })
break
}
this.directives.push({ title, content })
this.replacements.push({ index, length: width, replacement })
}
// Create div for lyrics after directives
while (this.text[index + width] === '\n') {
width++
}
let divOpen = '<lyrics style="'
const columns = Number(this.directive('columns'))
if (isNaN(columns) || columns === 0) {
divOpen += `columns: ${longestLineLength(this.text.substr(index + width)) + 2}ch`
} else {
divOpen += `column-count: ${columns}`
}
if (this.directive('lyrics-font-color') !== '') divOpen += `; color: ${this.directive('lyrics-font-color')}`
divOpen += '">'
const divClose = '</lyrics>'
this.replacements.push({ index: index + width, length: 0, replacement: divOpen })
this.replacements.push({ index: this.text.length - 1, length: 0, replacement: divClose })
return index + width // where the lyrics should start
}
parseLyricsComments (): void {
const regEx = /\s\/\/(.*)$/gm
const matches = this.text.matchAll(regEx)
let commentsFontSize = this.directive('comments-font-size')
commentsFontSize = (commentsFontSize !== '') ? `font-size: ${commentsFontSize};` : ''
let commentsFontColor = this.directive('comments-font-color')
commentsFontColor = (commentsFontColor !== '') ? `color: ${commentsFontColor};` : ''
for (const match of matches) {
const index = match.index ?? 0
const width = match[0].length
const comment = match[1]
const replacement = `<span class="comment" style="${commentsFontSize} ${commentsFontColor}">${comment}</span>`
this.replacements.push({ index, length: width, replacement })
}
}
private renderDirective (directive: Directive): string {
return `<div class="directive"><label for="directive-${directive.title}">${directive.title}</label><div id="directive-${directive.title}" class="content">${directive.content}</div></div>`
}
private renderChord (chord: Chord, width: number, chordVariant?: string, renderMode?: RenderMode): string {
renderMode = renderMode ?? 'chordName'
const diagram = chord.getDiagram(chordVariant)
const svgDiagram = (diagram !== undefined) ? new SvgDiagram(diagram) : undefined
if (renderMode === 'diagram' && svgDiagram !== undefined) {
return `<span class="chord-container" style="width: ${width}ch; height: 64px;">${svgDiagram.svg()}</span>`
}
const text = (chordVariant !== undefined) ? `${chord.name}:${chordVariant}` : chord.name
let rendered = ''
if (svgDiagram !== undefined) { rendered += `<span class="diagram" style="left: 100%">${svgDiagram.svg()}</span>` }
let chordsFontSize = this.directive('chords-font-size')
chordsFontSize = (chordsFontSize !== '') ? `font-size: ${chordsFontSize};` : ''
let chordsFontColor = this.directive('chords-font-color')
chordsFontColor = (chordsFontColor !== '') ? `color: ${chordsFontColor};` : ''
rendered = `<span class="chord-name" style="${chordsFontSize} ${chordsFontColor}">${text}${rendered}</span>`
return `<span class="chord-container" style="width: ${width}ch">${rendered}</span>`
}
private renderDiagram (diagram: Diagram, width: number, unit: string = 'ch', showVariant?: boolean): string {
const svgDiagram = new SvgDiagram(diagram)
return `<span class="chord-container" style="width: ${width}${unit}; height: 64px">${svgDiagram.svg(showVariant)}</span>`
}
renderDiagrams (): string {
function getChordRoot (chordName: string): string {
let chordRoot = ''
if (chordName.length > 1 && (chordName[1] === '#' || chordName[1] === 'b')) {
chordRoot = `${chordName[0]}${chordName[1]}`
} else {
chordRoot = chordName[0]
}
return chordRoot
}
let ret = ''
const sortedChordsNames = Object.keys(this.chords).sort((a, b) => {
const aRoot = getChordRoot(a)
const bRoot = getChordRoot(b)
return ((aRoot.length - bRoot.length) !== 0 && a[0] === b[0]) ? aRoot.length - bRoot.length : a.localeCompare(b)
})
let root = ''
for (const chordName of sortedChordsNames) {
const chord = this.chords[chordName]
const chordRoot = getChordRoot(chordName)
if (chordRoot !== root) {
ret += (root === '') ? '<p>' : '</p><p>'
root = chordRoot
}
// print default diagram
const defaultDiagram = chord.getDiagram()
if (defaultDiagram === undefined) {
console.warn(`A diagram for ${chordName} hasn't be provided`)
ret += chordName
} else {
ret += this.renderDiagram(chord.getDiagram(), 64, 'px', true)
}
// and now the rest of variants
for (const chordVariant in chord.diagrams) {
ret += this.renderDiagram(chord.getDiagram(chordVariant), 64, 'px', true)
}
}
ret += '</p>'
return `<chords><div class="title">Chords in this song:</div>${ret}</chords>`
}
render (): string {
let ret: string = ''
let index = 0
const replacements = this.replacements.sort((a, b) => a.index - b.index)
for (const replacement of replacements) {
ret += this.text.slice(index, replacement.index)
ret += replacement.replacement
index = replacement.index + replacement.length
}
ret += this.text.slice(index)
// remove any preceeding linebreak before starting the lyrics
ret = ret.replace(/(<lyrics[\s\S]*?>)(\n*)/, '$1')
if (this.directive('show-chord-diagrams') === 'true') ret += this.renderDiagrams()
let fontSizeStyle = ''
if (this.directive('lyrics-font-size') !== '') fontSizeStyle += ` style="font-size: ${this.directive('lyrics-font-size')}"`
return `${SvgDiagram.svgDefs}<chordsong${fontSizeStyle}>${ret}</chordsong>`
}
}
function longestLineLength (str: string): number {
const strArr = str.split('\n')
let max = strArr[0].length
strArr.forEach(v => {
max = Math.max(max, v.length)
})
return max
}