4 Commits
0.3.1 ... 0.5.0

5 changed files with 164 additions and 44 deletions

View File

@ -1,6 +1,6 @@
# Package # Package
version = "0.3.1" version = "0.5.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Chord chart formatter compatible with Planning Center Online" description = "Chord chart formatter compatible with Planning Center Online"
license = "MIT" license = "MIT"

View File

@ -34,11 +34,16 @@ when isMainModule:
$ast & "\p" & $ast & "\p" &
"-".repeat(16) & "\p" "-".repeat(16) & "\p"
let outputHtml = ast.toHtml() let transpose =
if args["--transpose"]: parseInt($args["--transpose"])
else: 0
let outputHtml = ast.toHtml(transpose, args["--number-chart"])
if args["--output"]: writeFile($args["--output"], outputHtml) if args["--output"]: writeFile($args["--output"], outputHtml)
else: stdout.write(outputHtml) else: stdout.write(outputHtml)
except: except CatchableError:
fatal getCurrentExceptionMsg() fatal getCurrentExceptionMsg()
debug getCurrentException().getStackTrace() debug getCurrentException().getStackTrace()
quit(QuitFailure) quit(QuitFailure)

View File

@ -1,10 +1,10 @@
import std/logging, std/nre, std/strtabs, std/strutils import std/[nre, strtabs, strutils]
import zero_functional import zero_functional
type type
ChordChartMetadata* = object ChordChartMetadata* = object
title*: string title*: string
key*: ChordChartPitch key*: ChordChartChord
optionalProps: StringTableRef optionalProps: StringTableRef
ChordChart* = ref object ChordChart* = ref object
@ -26,7 +26,7 @@ type
ChordChartPitch* = enum Af, A, Bf, B, C, Df, D, Ef, E, F, Fs, G ChordChartPitch* = enum Af, A, Bf, B, C, Df, D, Ef, E, F, Fs, G
ChordChartChord* = object ChordChartChord* = object
original*: string original*: Option[string]
rootPitch*: ChordChartPitch rootPitch*: ChordChartPitch
flavor*: Option[string] flavor*: Option[string]
bassPitch*: Option[ChordChartPitch] bassPitch*: Option[ChordChartPitch]
@ -35,6 +35,7 @@ type
case kind: ChordChartNodeKind case kind: ChordChartNodeKind
of ccnkSection: of ccnkSection:
sectionName*: string sectionName*: string
remainingSectionLine*: Option[string]
sectionContents*: seq[ChordChartNode] sectionContents*: seq[ChordChartNode]
of ccnkLine: of ccnkLine:
line*: seq[ChordChartNode] line*: seq[ChordChartNode]
@ -132,13 +133,15 @@ func dump*(ccn: ChordChartNode, indent = ""): string =
if ccn.chord.isSome: if ccn.chord.isSome:
let chord = ccn.chord.get let chord = ccn.chord.get
result &= "[" result &= "["
if chord.flavor.isNone and chord.bassPitch.isNone: if chord.original.isSome and chord.flavor.isNone and
result &= chord.original chord.bassPitch.isNone:
result &= chord.original.get
else: else:
result &= $chord.rootPitch
if chord.flavor.isSome: if chord.flavor.isSome:
result &= $chord.rootPitch & "_" & chord.flavor.get result &= "_" & chord.flavor.get
if chord.bassPitch.isSome: if chord.bassPitch.isSome:
result &= $chord.rootPitch & "/" & $chord.bassPitch.get result &= "/" & $chord.bassPitch.get
result &= "]" result &= "]"
if ccn.word.isSome: result &= ccn.word.get if ccn.word.isSome: result &= ccn.word.get
@ -184,12 +187,13 @@ template addToCurSection(n: ChordChartNode): untyped =
if ctx.curSection.kind == ccnkSection: ctx.curSection.sectionContents.add(n) if ctx.curSection.kind == ccnkSection: ctx.curSection.sectionContents.add(n)
else: result.add(n) else: result.add(n)
func parsePitch*(ctx: ParserContext, keyValue: string): ChordChartPitch = proc parsePitch*(ctx: ParserContext, keyValue: string): ChordChartPitch =
case keyValue.strip.toLower let normK = keyValue.strip.toLower
case normK
of "gs", "gis", "g#", "ab", "a♭", "af", "aes": return ChordChartPitch.Af of "gs", "gis", "g#", "ab", "a♭", "af", "aes": return ChordChartPitch.Af
of "g𝄪", "a", "a♮", "b𝄫": return ChordChartPitch.A of "g𝄪", "a", "a♮", "b𝄫": return ChordChartPitch.A
of "as", "ais", "a#", "bf", "bb", "b♭", "bes", "c𝄫": return ChordChartPitch.Bf of "as", "ais", "a#", "bf", "bb", "b♭", "bes", "c𝄫": return ChordChartPitch.Bf
of "a𝄪", "b", "ces", "cf": return ChordChartPitch.B of "a𝄪", "b", "cb", "ces", "cf": return ChordChartPitch.B
of "bs", "bis", "b#", "c", "d𝄫": return ChordChartPitch.C of "bs", "bis", "b#", "c", "d𝄫": return ChordChartPitch.C
of "b𝄪", "cs", "cis", "c#", "df", "db", "des": return ChordChartPitch.Df of "b𝄪", "cs", "cis", "c#", "df", "db", "des": return ChordChartPitch.Df
of "c𝄪", "d", "e𝄫": return ChordChartPitch.D of "c𝄪", "d", "e𝄫": return ChordChartPitch.D
@ -198,6 +202,13 @@ func parsePitch*(ctx: ParserContext, keyValue: string): ChordChartPitch =
of "es", "eis", "e#", "f", "g𝄫": return ChordChartPitch.F of "es", "eis", "e#", "f", "g𝄫": return ChordChartPitch.F
of "e𝄪", "fs", "fis", "f#", "gf", "gb", "ges": return ChordChartPitch.Fs of "e𝄪", "fs", "fis", "f#", "gf", "gb", "ges": return ChordChartPitch.Fs
of "f𝄪", "g", "a𝄫": return ChordChartPitch.G of "f𝄪", "g", "a𝄫": return ChordChartPitch.G
of "1": return ctx.curKeyCenter
of "2": return ctx.curKeyCenter + 2
of "3": return ctx.curKeyCenter + 4
of "4": return ctx.curKeyCenter + 5
of "5": return ctx.curKeyCenter + 7
of "6": return ctx.curKeyCenter + 9
of "7": return ctx.curKeyCenter + 11
else: raise ctx.makeError(keyValue.strip & " is not a recognized key.") else: raise ctx.makeError(keyValue.strip & " is not a recognized key.")
# see regexr.com/70nv1 # see regexr.com/70nv1
@ -225,7 +236,7 @@ proc parseChord*(
else: none[ChordChartPitch]() else: none[ChordChartPitch]()
return some(ChordChartChord( return some(ChordChartChord(
original: chordValue, original: some(chordValue),
rootPitch: ctx.parsePitch(m.get.captures[0]), rootPitch: ctx.parsePitch(m.get.captures[0]),
flavor: flavor, flavor: flavor,
bassPitch: bassPitch)) bassPitch: bassPitch))
@ -235,8 +246,8 @@ let METADATA_END_PAT = re"^-+$"
proc parseMetadata(ctx: var ParserContext): ChordChartMetadata = proc parseMetadata(ctx: var ParserContext): ChordChartMetadata =
var title = "MISSING" var title = "MISSING"
var songKey = "MISSING"
var optProps = newStringTable() var optProps = newStringTable()
var songKey: Option[ChordChartChord]
while ctx.curLineNum < ctx.lines.len: while ctx.curLineNum < ctx.lines.len:
let line = ctx.nextLine let line = ctx.nextLine
@ -251,28 +262,32 @@ proc parseMetadata(ctx: var ParserContext): ChordChartMetadata =
let key = m.get.captures[0].strip.tolower let key = m.get.captures[0].strip.tolower
let value = m.get.captures[1].strip let value = m.get.captures[1].strip
if key == "title": title = value if key == "title": title = value
elif key == "key": songKey = value elif key == "key":
songKey = ctx.parseChord(value)
if songKey.isNone:
raise ctx.makeError("unrecognized key: " & value)
else: optProps[key] = value else: optProps[key] = value
if title == "MISSING": if title == "MISSING":
raise ctx.makeError("metadata is missing the 'title' property") raise ctx.makeError("metadata is missing the 'title' property")
if songKey == "MISSING": if songKey.isNone:
raise ctx.makeError("metadata is missing the 'key' property") raise ctx.makeError("metadata is missing the 'key' property")
result = ChordChartMetadata( result = ChordChartMetadata(
title: title, title: title,
key: ctx.parsePitch(songKey), key: songKey.get,
optionalProps: optProps) optionalProps: optProps)
const KNOWN_SECTION_NAMES = [ const KNOWN_SECTION_NAMES = [
"chorus", "verse", "bridge", "breakdown", "vamp", "intstrumental", "chorus", "verse", "bridge", "breakdown", "vamp", "intstrumental",
"interlude", "intro", "outtro", "ending", "end", "tag" "interlude", "intro", "outtro", "ending", "end", "tag", "prechorus",
"pre-chorus", "pre chorus"
] ]
let SECTION_LINE_PAT = re( let SECTION_LINE_PAT = re(
"^((" & # case insensitive "^((" & "((?i)" & # case insensitive
"((?i)" & KNOWN_SECTION_NAMES.join("|") & ")" & # known names KNOWN_SECTION_NAMES.join("|") & ")" & # known names
"|[[:upper:]]{3,}" & # all upper-case words "|[[:upper:]]{3,}" & # all upper-case words
") ?\\d*)" & # numeric suffix (Verse 2) ") ?\\d*)" & # numeric suffix (Verse 2)
@ -422,12 +437,14 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
let captures = m.get.captures.toSeq let captures = m.get.captures.toSeq
ctx.curSection = ChordChartNode( ctx.curSection = ChordChartNode(
kind: ccnkSection, kind: ccnkSection,
sectionName: if captures[0].isSome: captures[0].get.strip sectionName:
else: raise ctx.makeError("unknown error parsing section header: " & line), if captures[0].isSome: captures[0].get.strip
else: raise ctx.makeError("unknown error parsing section header: " & line),
remainingSectionLine:
if captures[3].isSome: some(captures[3].get.strip)
else: none[string](),
sectionContents: @[]) sectionContents: @[])
result.add(ctx.curSection) result.add(ctx.curSection)
if captures[3].isSome:
ctx.curSection.sectionContents &= ctx.parseLineParts(captures[3].get)
continue continue
else: else:
@ -443,7 +460,7 @@ proc parseChordChart*(s: string): ChordChart =
unparsedLineParts: @[]) unparsedLineParts: @[])
let metadata = parseMetadata(parserCtx) let metadata = parseMetadata(parserCtx)
parserCtx.curKeyCenter = metadata.key parserCtx.curKeyCenter = metadata.key.rootPitch
result = ChordChart( result = ChordChart(
rawContent: s, rawContent: s,

View File

@ -1,4 +1,4 @@
const PCO_CHORDS_VERSION* = "0.3.1" const PCO_CHORDS_VERSION* = "0.5.0"
const USAGE* = """Usage: const USAGE* = """Usage:
pco_chords [options] pco_chords [options]
@ -6,12 +6,20 @@ const USAGE* = """Usage:
Options: Options:
-i, --input <in-file> Read input from <in-file> (rather than from -i, --input <in-file> Read input from <in-file> (rather than from
STDIN, which is the default). STDIN, which is the default).
-o, --output <out-file> Write output to <out-file> (rather than from -o, --output <out-file> Write output to <out-file> (rather than from
STDOUT, which is the default). STDOUT, which is the default).
-t, --transpose <distance> Transpose the chart by the given number of
semitones. Distance can be expressed as a
positive or negative number of semitones,
for example: 4, +3 or -5.
-n, --number-chart Write out a chart using the Nashville number
system rather than explicit pitches.
--help Print this usage information --help Print this usage information
--debug Enable debug logging. --debug Enable debug logging.
--echo-args Echo the input parameters. --echo-args Echo the input parameters.

View File

@ -9,6 +9,8 @@ type
currentKey: ChordChartPitch currentKey: ChordChartPitch
sourceKey: ChordChartPitch sourceKey: ChordChartPitch
currentSection: ChordChartNode currentSection: ChordChartNode
numberChart: bool
transposeSteps: int
const DEFAULT_STYLESHEET* = """ const DEFAULT_STYLESHEET* = """
<style> <style>
@ -40,6 +42,13 @@ h3 {
text-decoration: underline; text-decoration: underline;
} }
h3 .section-text {
font-style: italic;
font-size: 1em;
font-weight: normal;
margin: 0 0.5em;
}
.line { .line {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -62,6 +71,20 @@ h3 {
margin-right: 0.5em; margin-right: 0.5em;
} }
.chord .flavor {
font-variant-position: super;
}
.song-order h3 {
font-style: italic;
font-weight: normal;
}
.song-order li {
list-style: none;
margin-left: 1em;
}
@media screen { @media screen {
body { margin: 1em; } body { margin: 1em; }
} }
@ -72,17 +95,66 @@ h3 {
</style> </style>
""" """
proc transpose(ctx: FormatContext, chord: ChordChartChord): string = func scaleDegree(root: ChordChartPitch, p: ChordChartPitch): string =
let distance = ctx.currentKey - ctx.sourceKey let distance = p - root
case distance:
of 0: "1"
of 1: "♭2"
of 2: "2"
of 3: "♭3"
of 4: "3"
of 5: "4"
of 6: "♭5"
of 7: "5"
of 8: "♭6"
of 9: "6"
of 10: "♭7"
of 11: "7"
else: raise newException(Exception, "Impossible")
if distance != 0: func format(ctx: FormatContext, chord: ChordChartChord, useNumber = false): string =
result = $(chord.rootPitch + distance) if not useNumber and chord.original.isSome: return chord.original.get
elif useNumber:
result = "<span class=root>" &
ctx.currentKey.scaleDegree(chord.rootPitch) & "</span>"
if chord.flavor.isSome:
result &= "<span class=flavor>" & chord.flavor.get & "</span>"
if chord.bassPitch.isSome:
result &= "<span class=bass>/" &
ctx.currentKey.scaleDegree(chord.bassPitch.get) & "</span>"
else:
result = $chord.rootPitch
if chord.flavor.isSome: result &= chord.flavor.get if chord.flavor.isSome: result &= chord.flavor.get
if chord.bassPitch.isSome: result &= "/" & $(chord.bassPitch.get + distance) if chord.bassPitch.isSome: result &= "/" & $chord.bassPitch.get
#debug "transposed " & $ctx.sourceKey & " -> " & $ctx.currentKey & #debug "transposed " & $ctx.sourceKey & " -> " & $ctx.currentKey &
# " (distance " & $distance & ")\p\tchord: " & $chord & " to " & result # " (distance " & $distance & ")\p\tchord: " & $chord & " to " & result
else: result = chord.original proc transpose(ctx: FormatContext, chord: ChordChartChord): ChordChartChord =
result = chord
let distance = ctx.currentKey - ctx.sourceKey
if distance != 0:
result = ChordChartChord(
original: none[string](),
rootPitch: chord.rootPitch + distance,
flavor:
if chord.flavor.isSome: some(chord.flavor.get)
else: none[string](),
bassPitch:
if chord.bassPitch.isSome: some(chord.bassPitch.get + distance)
else: none[ChordChartPitch]())
func makeSongOrder(songOrder, indent: string): string =
result = indent & "<section class=song-order>\p" &
indent & " <h3>Song Order</h3>\p" &
indent & " <ul>\p"
result &= join(songOrder.split(",") -->
map(indent & " <li>" & it.strip & "</li>\p"), "")
result &= indent & " </ul>\p" & indent & "</section>\p"
proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): string = proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): string =
result = "" result = ""
@ -90,7 +162,13 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
of ccnkSection: of ccnkSection:
ctx.currentSection = node ctx.currentSection = node
result &= indent & "<section>\p" & result &= indent & "<section>\p" &
indent & " " & "<h3>" & node.sectionName & "</h3>\p" indent & " " & "<h3>" & node.sectionName
if ctx.currentSection.remainingSectionLine.isSome:
result &= "<span class='section-text'>" &
ctx.currentSection.remainingSectionLine.get & "</span>"
result &= "</h3>\p"
var contents = newSeq[string]() var contents = newSeq[string]()
for contentNode in node.sectionContents: for contentNode in node.sectionContents:
@ -112,7 +190,8 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
result &= "'>" result &= "'>"
if node.chord.isSome: if node.chord.isSome:
result &= "<span class=chord>" & ctx.transpose(node.chord.get) & "</span>" result &= "<span class=chord>" &
ctx.format(ctx.transpose(node.chord.get), ctx.numberChart) & "</span>"
result &= "<span class=lyric>" result &= "<span class=lyric>"
if node.word.isSome: result &= node.word.get if node.word.isSome: result &= node.word.get
@ -138,8 +217,8 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
result &= "</section><section>" & headingVal & "</section><section>" result &= "</section><section>" & headingVal & "</section><section>"
of ccnkRedefineKey: of ccnkRedefineKey:
let oldKey = ctx.sourceKey let oldKey = ctx.currentKey
ctx.sourceKey = node.newKey ctx.sourceKey = node.newKey + ctx.transposeSteps
ctx.currentKey = ctx.sourceKey ctx.currentKey = ctx.sourceKey
if oldKey != ctx.currentKey: if oldKey != ctx.currentKey:
@ -152,14 +231,21 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
proc toHtml*( proc toHtml*(
cc: ChordChart, cc: ChordChart,
transpose = 0,
numberChart = false,
stylesheets = @[DEFAULT_STYLESHEET], stylesheets = @[DEFAULT_STYLESHEET],
scripts: seq[string] = @[]): string = scripts: seq[string] = @[]): string =
var ctx = FormatContext( var ctx = FormatContext(
chart: cc, chart: cc,
currentKey: cc.metadata.key, currentKey: cc.metadata.key.rootPitch + transpose,
sourceKey: cc.metadata.key, sourceKey: cc.metadata.key.rootPitch,
currentSection: EMPTY_CHORD_CHART_NODE) currentSection: EMPTY_CHORD_CHART_NODE,
numberChart: numberChart,
transposeSteps: transpose)
debug "Formatting:\p\tsource key: " & $ctx.sourceKey & "\p\ttransposing: " &
$transpose & "\p\ttarget key: " & $ctx.currentKey
result = """<!doctype html> result = """<!doctype html>
<html> <html>
@ -179,11 +265,11 @@ proc toHtml*(
result &= " </head>\p <body>" result &= " </head>\p <body>"
var indent = " " var indent = " "
#
# Title # Title
result &= indent & "<h1>" & cc.metadata.title & "</h1>\p" result &= indent & "<h1>" & cc.metadata.title & "</h1>\p"
var metadataPieces = @["Key: " & $cc.metadata.key] var metadataPieces = @["Key: " & ctx.format(ctx.transpose(cc.metadata.key))]
if cc.metadata.contains("time signature"): if cc.metadata.contains("time signature"):
metadataPieces.add(cc.metadata["time signature"]) metadataPieces.add(cc.metadata["time signature"])
if cc.metadata.contains("bpm"): if cc.metadata.contains("bpm"):
@ -195,6 +281,10 @@ proc toHtml*(
result &= "<div class=page-contents>" result &= "<div class=page-contents>"
result &= join(cc.nodes --> map(ctx.toHtml(it, indent & " ")), "\p") result &= join(cc.nodes --> map(ctx.toHtml(it, indent & " ")), "\p")
if cc.metadata.contains("song order"):
result &= makeSongOrder(cc.metadata["song order"], indent)
result &= "</div>" result &= "</div>"
result &= " </body>\p</html>" result &= " </body>\p</html>"