UNPKG

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
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 }