10 Commits
0.3.1 ... 0.5.6

Author SHA1 Message Date
533089431e Use better heuristics for chord pitch naming.
Previously we used a heuristic for choosing pitch names based on the
major scale degree, minimizing radicals. So, for example, in the key of
Gb, we render the 4 as Cb rather than B because Bb is the 3 of Gb. In
other words, we want 1:Gb, 2:Ab, 3:Bb, 4:Cb instead of re-using B. This
is standard practice in western music notation.

When rendering non-diatonic notes we prefered choosing the version that
minimized the radicals. Again in the key of Gb we would choose to render
E as E (the #6) rather than considering it as Fb (the b7) and choose to
render D as D (the #5) rather than E𝄫 (the b6). This was chosen to
reduce the number of unusual radicals like 𝄫.

However, in practice this leads to unusual charts because it is more
common when writing chordal harmony to use the b6 rather than the #5.
Similarly the b7 is far more common than the #6. This is, I think, due
to the prevalence of the major scale and minor scales and the fact that
the minor scale is built from flatting the 3, 6, and 7 of the major
scale. So when thinking in a key-center agnostic manner (like numbers)
we almost always think about these altered scale degrees as being
flatted relative to the major scale, not sharped. Because of this, in
the key of Gb, we would prefer to render a b6, b7, 1 chord walkup as
E𝄫, Fb, Gb rather than D, E, Gb.

This change redefines the heuristic used to name chord pitches to follow
a heuristic that covers all pitches in the octave based on scale degree:
1, b2, 2, b3, 3, 4, b5, 5, b6, 6, b7, 7
2025-06-02 10:59:52 -05:00
d7dd509138 Tweak styling to make the lead sheet more compact. 2025-06-01 07:28:42 -05:00
73bdb97881 Support one- and two-column layouts. Transposing keys doesn't start a new section. 2025-04-26 11:40:55 -05:00
a57aed0715 Fix chord parsing, formatting. 2025-04-11 15:47:53 -05:00
2712f9ff52 Key of C should use flats for accidentals. 2024-03-21 18:51:48 -05:00
8271129b90 Better support for transposition and Nashville numbers.
- Non-diatonic pitches are supported using Nashville numbers (#5).
- When using Nashville numbers chord variants with non-diatonic roots
  are now recognized (e.g. b7)
- Pitch rendering is now aware of key centers. For example, F# is F#
  rendered as F# when in the key of G but Gb when in the key of Ab.
2024-01-10 08:23:19 -06:00
5b1238f038 Support for number charts, transposition when generating charts. 2023-07-19 13:26:01 -05:00
9ca5a1b99c Support additional text on the same lines as section headings. 2023-07-19 13:23:59 -05:00
e832b91443 Bugfix: recognize Cb as a valid pitch. 2023-02-07 12:07:55 -06:00
2acfb42a38 Add support for specifying the song order in the metadata section. 2023-02-06 21:44:47 -06:00
5 changed files with 455 additions and 123 deletions

View File

@ -1,6 +1,6 @@
# Package # Package
version = "0.3.1" version = "0.5.6"
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"
@ -12,3 +12,9 @@ bin = @["pco_chords"]
# Dependencies # Dependencies
requires "nim >= 1.6.6", "docopt", "zero_functional" requires "nim >= 1.6.6", "docopt", "zero_functional"
# Dependencies from git.jdbernard.com/jdb/nim-package
requires "update_nim_package_version"
task updateVersion, "Update the version of this tool.":
exec "update_nim_package_version pco_chords 'src/pco_chordspkg/cliconstants.nim'"

View File

@ -34,11 +34,21 @@ 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 =
if args["--large-print"]:
ast.toHtml(transpose, args["--number-chart"],
stylesheets = @[LARGE_PRINT_STYLESHEET])
else:
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
@ -23,10 +23,10 @@ type
ccnkRedefineKey, ccnkRedefineKey,
ccnkNone ccnkNone
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, Gf, 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]
@ -77,19 +78,88 @@ iterator pairs*(ccmd: ChordChartMetadata): tuple[key, value: string] =
func kind*(ccn: ChordChartNode): ChordChartNodeKind = ccn.kind func kind*(ccn: ChordChartNode): ChordChartNodeKind = ccn.kind
func `$`*(pitch: ChordChartPitch): string = func `$`*(pitch: ChordChartPitch): string =
case pitch ["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"][ord(pitch)]
of Af: "Ab"
of A: "A" func renderPitchInKey*(
of Bf: "Bb" pitch: ChordChartPitch,
of B: "B" key: ChordChartPitch,
of C: "C" useSharps: Option[bool] = none[bool]()): string =
of Df: "Db"
of D: "D" var scaleNames: array[(ord(high(ChordChartPitch)) + 1), string]
of Ef: "Eb" if useSharps.isNone:
of E: "E" # If we aren't told to use sharps or flats, we render the diatonic pitches
of F: "F" # according to standard theory (C# in the key of D, Db in the key of Ab)
of Fs: "F#" # but we render non-diatonic notes with flats (prefer the b6 and b7 over
of G: "G" # the #5 and #6).
#
# TODO: In the future, we should also remember the scale degree of the
# chord when parsing. So, for example, in the key of D we would parse Bb as
# the b6 and A# as the #5. The pitch would be the same, but the scale
# degree would differ. This would allow us to preserve intentional choices
# in the chart when transposing.
scaleNames = case key
of C:
["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"]
of G:
["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "F#", "G"]
of D:
["A♭", "A", "B♭", "B", "C", "C#", "D", "E♭", "E", "F", "F#", "G"]
of A:
["G#", "A", "B♭", "B", "C", "C#", "D", "E♭", "E", "F", "F#", "G"]
of E:
["G#", "A", "B♭", "B", "C", "C#", "D", "D#", "E", "F", "F#", "G"]
of B:
["G#", "A", "A#", "B", "C", "C#", "D", "D#", "E", "F", "F#", "G"]
of Gf:
["A♭", "B𝄫", "B♭", "C♭", "D𝄫", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "A𝄫"]
of Df:
["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "A𝄫"]
of Af:
["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "G"]
of Ef:
["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "D", "E♭", "F♭", "F", "G♭", "G"]
of Bf:
["A♭", "A", "B♭", "C♭", "C", "D♭", "D", "E♭", "F♭", "F", "G♭", "G"]
of F:
["A♭", "A", "B♭", "C♭", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"]
elif useSharps.isSome and useSharps.get:
scaleNames = case key
of A, B, C, D, E, G:
["G#", "A", "A#", "B", "C", "C#", "D", "D#", "E", "F", "F#", "G"]
of Af:
["G#", "A", "A#", "B", "B#", "C#", "D", "D#", "E", "E#", "F#", "F𝄪"]
of Bf:
["G#", "G𝄪", "A#", "B", "B#", "C#", "C𝄪", "D#", "E", "E#", "F#", "F𝄪"]
of Df:
["G#", "A", "A#", "B", "B#", "C#", "D", "D#", "E", "E#", "F#", "G"]
of Ef:
["G#", "A", "A#", "B", "B#", "C#", "C𝄪", "D#", "E", "E#", "F#", "F𝄪"]
of Gf:
["G#", "A", "A#", "B", "C", "C#", "D", "D#", "E", "E#", "F#", "G"]
of F:
["G#", "G𝄪", "A#", "B", "B#", "C#", "C𝄪", "D#", "D𝄪", "E#", "F#", "F𝄪"]
else: # !useSharps (useSharps.isSome and not useSharps.get)
scaleNames = case key
of C, Af, Bf, Df, Ef, F:
["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"]
of A:
["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "G"]
of B:
["A♭", "A", "B♭", "C♭", "C", "D♭", "D", "E♭", "F♭", "F", "G♭", "G"]
of D:
["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "A𝄫"]
of E:
["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "D", "E♭", "F♭", "F", "G♭", "G"]
of G:
["A♭", "B𝄫", "B♭", "C♭", "D𝄫", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "A𝄫"]
of Gf:
["A♭", "A", "B♭", "C♭", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"]
return scaleNames[ord(pitch)]
func `+`*(pitch: ChordChartPitch, steps: int): ChordChartPitch = func `+`*(pitch: ChordChartPitch, steps: int): ChordChartPitch =
cast[ChordChartPitch]((ord(pitch) + steps) mod 12) cast[ChordChartPitch]((ord(pitch) + steps) mod 12)
@ -132,13 +202,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,27 +256,40 @@ 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", "c♭", "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#", "d♭", "df", "db", "des": return ChordChartPitch.Df
of "c𝄪", "d", "e𝄫": return ChordChartPitch.D of "c𝄪", "d", "e𝄫": return ChordChartPitch.D
of "ds", "dis", "d#", "ef", "eb", "ees", "f𝄫": return ChordChartPitch.Ef of "ds", "dis", "d#", "ef", "e♭", "eb", "ees", "f𝄫": return ChordChartPitch.Ef
of "d𝄪", "e", "fes", "ff": return ChordChartPitch.E of "d𝄪", "e", "f♭", "fes", "ff": return ChordChartPitch.E
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#", "g♭", "gf", "gb", "ges": return ChordChartPitch.Gf
of "f𝄪", "g", "a𝄫": return ChordChartPitch.G of "f𝄪", "g", "a𝄫": return ChordChartPitch.G
of "#7", "1", "𝄫2": return ctx.curKeyCenter
of "𝄪7", "#1", "b2", "♭2": return ctx.curKeyCenter + 1
of "𝄪1", "2", "𝄫3": return ctx.curKeyCenter + 2
of "#2", "b3", "♭3", "𝄫4": return ctx.curKeyCenter + 3
of "𝄪2", "3", "b4", "♭4": return ctx.curKeyCenter + 4
of "#3", "4", "𝄫5": return ctx.curKeyCenter + 5
of "𝄪3", "#4", "b5", "♭5": return ctx.curKeyCenter + 6
of "𝄪4", "5", "𝄫6": return ctx.curKeyCenter + 7
of "#5", "b6", "♭6": return ctx.curKeyCenter + 8
of "𝄪5", "6", "𝄫7": return ctx.curKeyCenter + 9
of "#6", "b7", "♭7", "𝄫1": return ctx.curKeyCenter + 10
of "7", "b1", "♭1": 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
let CHORD_REGEX = let CHORD_REGEX =
"([A-G1-7][b#♭♮𝄫𝄪]?)" & # chord root "([b#♭♮𝄫𝄪]?[A-G1-7][b#♭♮𝄫𝄪]?)" & # chord root
"(([mM1-9#b♭♮𝄫𝄪Δ+oøoø][0-9]?|min|maj|aug|dim|sus|6\\/9|\\([1-9#b♭]+\\))*)" & # chord flavor/type "((min|maj|aug|dim|sus|6\\/9|[mM1-9#b♭♮𝄫𝄪Δ+oøoø°𝆩][0-9]?|\\([1-9#b♭]+\\))*)" & # chord flavor/type
"(\\/([A-G1-7][b#♭♮𝄫𝄪]?))?" # optional bass "(\\/([b#♭♮𝄫𝄪]?[A-G1-7][b#♭♮𝄫𝄪]?))?" # optional bass
let CHORD_PAT = re(CHORD_REGEX) let CHORD_PAT = re(CHORD_REGEX)
proc parseChord*( proc parseChord*(
@ -225,7 +310,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 +320,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 +336,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)
@ -284,13 +373,46 @@ let PAGE_BREAK_PAT = re"\s*PAGE_BREAK\s*$"
let TRANSPOSE_PAT = re"\s*TRANSPOSE KEY ([+-]\d+)\s*$" let TRANSPOSE_PAT = re"\s*TRANSPOSE KEY ([+-]\d+)\s*$"
let REDEFINE_KEY_PAT = re"\s*REDEFINE KEY ([+-]\d+)\s*$" let REDEFINE_KEY_PAT = re"\s*REDEFINE KEY ([+-]\d+)\s*$"
#let NOTE_PAT = re"^(.*)({{[^}]+}}|{[^}]+})(.*)$" #let NOTE_PAT = re"^(.*)({{[^}]+}}|{[^}]+})(.*)$"
let NOTE_START_PAT = re"{{" let NOTE_START_PAT = re"\{\{"
let NOTE_END_PAT = re"}}" let NOTE_END_PAT = re"\}\}"
let SPACE_PAT = re"\s" let SPACE_PAT = re"\s"
let CHORD_IN_LYRICS_PAT = re"(\w+)(\[.+)" let CHORD_IN_LYRICS_PAT = re"(\w+)(\[.+)"
let CHORD_AND_LYRICS_PAT = re"^\[([^\]]+)\]([^\s\[]+)(.*)$" let CHORD_AND_LYRICS_PAT = re"^\[([^\]]*)\]([^\s\[]+)(.*)$"
let BRACED_CHORD_PAT = re"^\[([^\]]+)\]$" let BRACED_CHORD_PAT = re"^\[([^\]]+)\]$"
let NAKED_CHORDS_ONLY_PAT = re("^(" & CHORD_REGEX & "\\s*\\|*\\s*)+$") let NAKED_CHORDS_ONLY_PAT = re("^\\s*(" & CHORD_REGEX & "\\s*\\|*\\s*)+$")
proc readNote(ctx: var ParserContext, firstLine: string): tuple[pre, note, post: string] =
let startLineNum = ctx.curLineNum
result = ("", "", "")
let startMatch = firstLine.find(NOTE_START_PAT)
var endMatch = firstLine.find(NOTE_END_PAT)
if startMatch.isNone: return
if startMatch.get.matchBounds.a > 0:
result.pre = firstLine[0..<startMatch.get.matchBounds.a]
if endMatch.isSome:
result.note = firstLine[(startMatch.get.matchBounds.b + 1)..<endMatch.get.matchBounds.a]
result.post = firstLine[(endMatch.get.matchBounds.b + 1)..^1]
else:
# if we don't find the end of the note, then we need to read more lines
# until we do
result.note = firstLine[(startMatch.get.matchBounds.b + 1)..^1]
while ctx.lines.len > ctx.curLineNum and ctx.hasNextLine:
let line = ctx.nextLine
endMatch = line.find(NOTE_END_PAT)
if endMatch.isSome:
result.note &= line[0..<endMatch.get.matchBounds.a]
result.post = line[(endMatch.get.matchBounds.b + 1)..^1]
return
else: result.note &= line
raise ctx.makeError("a note section was started on line " &
$startLineNum & " and never closed")
proc parseLineParts(ctx: ParserContext, parts: varargs[string]): seq[ChordChartNode] = proc parseLineParts(ctx: ParserContext, parts: varargs[string]): seq[ChordChartNode] =
result = @[] result = @[]
@ -334,10 +456,11 @@ proc parseLineParts(ctx: ParserContext, parts: varargs[string]): seq[ChordChartN
chord: none[ChordChartChord](), chord: none[ChordChartChord](),
word: some(p))) word: some(p)))
proc parseLine(ctx: ParserContext, line: string): ChordChartNode = proc parseLine(lentCtx: var ParserContext, line: string): ChordChartNode =
let ctx = lentCtx
result = ChordChartNode(kind: ccnkLine) result = ChordChartNode(kind: ccnkLine)
let m = line.match(NAKED_CHORDS_ONLY_PAT) var m = line.match(NAKED_CHORDS_ONLY_PAT)
if m.isSome: if m.isSome:
result.line = line.splitWhitespace --> map( result.line = line.splitWhitespace --> map(
ChordChartNode( ChordChartNode(
@ -346,49 +469,28 @@ proc parseLine(ctx: ParserContext, line: string): ChordChartNode =
spaceBefore: false, #FIXME spaceBefore: false, #FIXME
chord: ctx.parseChord(it.strip), chord: ctx.parseChord(it.strip),
word: none[string]())) word: none[string]()))
return
m = line.match(NOTE_START_PAT)
if m.isSome:
let (pre, note, post) = lentCtx.readNote(line)
result.line = ctx.parseLineParts(pre) &
@[ChordChartNode(
kind: ccnkNote,
inclInLyrics: true,
note: note)] &
ctx.parseLineParts(post)
return
else: result.line = ctx.parseLineParts(line.splitWhitespace) else: result.line = ctx.parseLineParts(line.splitWhitespace)
proc readNote(ctx: var ParserContext, endPat: Regex): string =
let startLineNum = ctx.curLineNum
result = ""
while ctx.lines.len > ctx.curLineNum and ctx.hasNextLine:
let line = ctx.nextLine
let m = line.find(endPat)
if m.isSome:
result &= line[0..<m.get.matchBounds.a]
ctx.pushPartialsToParse(line[m.get.matchBounds.b..^1])
break
else: result &= line
if not ctx.hasNextLine:
raise ctx.makeError("a note section was started on line " &
$startLineNum & " and never closed")
proc parseBody(ctx: var ParserContext): seq[ChordChartNode] = proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
result = @[] result = @[]
while ctx.hasNextLine: while ctx.hasNextLine:
var line = ctx.nextLine var line = ctx.nextLine
var m = line.find(NOTE_START_PAT) var m = line.match(TRANSPOSE_PAT)
if m.isSome:
if m.get.matchBounds.a > 0:
# if this is not the first character of the line then let's split the
# line and continue to parse
ctx.pushPartialsToParse(
line[0..<m.get.matchBounds.a],
line[m.get.matchBounds.a..^1])
continue
else:
# if this is the first character of the line, then let's parse the note
result.add(ChordChartNode(
kind: ccnkNote,
inclInLyrics: m.get.match.len < 2,
note: ctx.readNote(NOTE_END_PAT)))
m = line.match(TRANSPOSE_PAT)
if m.isSome: if m.isSome:
addToCurSection(ChordChartNode( addToCurSection(ChordChartNode(
kind: ccnkTransposeKey, kind: ccnkTransposeKey,
@ -398,7 +500,7 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
m = line.match(REDEFINE_KEY_PAT) m = line.match(REDEFINE_KEY_PAT)
if m.isSome: if m.isSome:
let newKeyInt = ( let newKeyInt = (
cast[int](ctx.curKeyCenter) + ord(ctx.curKeyCenter) +
parseInt(m.get.captures[0]) parseInt(m.get.captures[0])
) mod 12 ) mod 12
@ -415,6 +517,7 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
m = line.match(PAGE_BREAK_PAT) m = line.match(PAGE_BREAK_PAT)
if m.isSome: if m.isSome:
result.add(ChordChartNode(kind: ccnkPageBreak)) result.add(ChordChartNode(kind: ccnkPageBreak))
ctx.curSection = EMPTY_CHORD_CHART_NODE
continue continue
m = line.match(SECTION_LINE_PAT) m = line.match(SECTION_LINE_PAT)
@ -422,12 +525,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 +548,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.6"
const USAGE* = """Usage: const USAGE* = """Usage:
pco_chords [options] pco_chords [options]
@ -6,15 +6,24 @@ 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.
--large-print Use the large type styling.
""" """
const ONLINE_HELP* = """ const ONLINE_HELP* = """

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>
@ -20,11 +22,102 @@ const DEFAULT_STYLESHEET* = """
html { font-family: sans-serif; } html { font-family: sans-serif; }
.page-contents { .page-contents { line-height: 1.1; }
column-width: 336px; .one-column .page-contents { column-count: 1; }
column-width: 3.5in; .two-column .page-contents { column-count: 2; }
.column-break { margin-bottom: auto; }
h2 { font-size: 1.25em; }
section {
break-inside: avoid;
padding-top: 1em;
} }
h3 {
font-size: 1.125rem;
margin-bottom: 0.125em;
text-decoration: underline;
}
h3 .section-text {
font-style: italic;
font-size: 1em;
font-weight: normal;
margin: 0 0.5em;
}
.artist { font-style: italic; }
.line {
display: flex;
flex-direction: row;
align-items: end;
margin-left: 0.5em;
margin-bottom: 0.5em;
}
.word {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.word.space-after { margin-right: 0.5em; }
.word.no-chord { align-self: flex-end; }
.chord {
font-weight: 600;
margin-bottom: -0.25em;
margin-right: 0.5em;
white-space: nowrap;
}
.chord > * {
display: inline-block;
height: 1.2em;
}
.chord .flavor {
font-variant-position: super;
}
.note { margin-right: 1em; }
.song-order h3 {
font-style: italic;
font-weight: normal;
}
.song-order li {
list-style: none;
margin-left: 1em;
}
@media screen {
body { margin: 1em; }
}
</style>
"""
const LARGE_PRINT_STYLESHEET* = """
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: sans-serif;
font-size: 1.5em;
}
.one-column .page-contents { column-count: 1; }
.two-column .page-contents { column-count: 2; }
.column-break { margin-bottom: auto; } .column-break { margin-bottom: auto; }
h2 { font-size: 1.25em; } h2 { font-size: 1.25em; }
@ -40,10 +133,19 @@ h3 {
text-decoration: underline; text-decoration: underline;
} }
h3 .section-text {
font-style: italic;
font-size: 1em;
font-weight: normal;
margin: 0 0.5em;
}
.artist { font-style: italic; }
.line { .line {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: flex-end; align-items: baseline;
margin-left: 0.5em; margin-left: 0.5em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
@ -56,33 +158,109 @@ h3 {
} }
.word.space-after { margin-right: 0.5em; } .word.space-after { margin-right: 0.5em; }
.word.no-chord { align-self: flex-end; }
.lyric {
font-weight: 600;
}
.chord { .chord {
font-weight: 600; font-style: italic;
font-weight: 500;
margin-right: 0.5em; margin-right: 0.5em;
} }
.chord > * {
display: inline-block;
height: 1.2em;
}
.chord .flavor {
font-variant-position: super;
}
.note { margin-right: 1em; }
.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; }
} }
@media print {
.page-contents { column-count: 2; }
}
</style> </style>
""" """
proc transpose(ctx: FormatContext, chord: ChordChartChord): string = func scaleDegree(root: ChordChartPitch, p: ChordChartPitch): string =
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")
func format(ctx: FormatContext, chord: ChordChartChord, useNumber = false): string =
##if not useNumber and chord.original.isSome: return chord.original.get
if 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 = "<span class=root>" &
renderPitchInKey(chord.rootPitch, ctx.currentKey) & "</span>"
if chord.flavor.isSome:
result &= "<span class=flavor>" & chord.flavor.get & "</span>"
if chord.bassPitch.isSome:
result &= "<span class=bass>/" &
renderPitchInKey(chord.bassPitch.get, ctx.currentKey) & "</span>"
proc transpose(ctx: FormatContext, chord: ChordChartChord): ChordChartChord =
result = chord
let distance = ctx.currentKey - ctx.sourceKey let distance = ctx.currentKey - ctx.sourceKey
if distance != 0: if distance != 0:
result = $(chord.rootPitch + distance) result = ChordChartChord(
if chord.flavor.isSome: result &= chord.flavor.get original: none[string](),
if chord.bassPitch.isSome: result &= "/" & $(chord.bassPitch.get + distance) rootPitch: chord.rootPitch + distance,
#debug "transposed " & $ctx.sourceKey & " -> " & $ctx.currentKey & flavor:
# " (distance " & $distance & ")\p\tchord: " & $chord & " to " & result if chord.flavor.isSome: some(chord.flavor.get)
else: none[string](),
bassPitch:
if chord.bassPitch.isSome: some(chord.bassPitch.get + distance)
else: none[ChordChartPitch]())
else: result = chord.original 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 +268,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:
@ -109,10 +293,12 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
result &= "<span class='word" result &= "<span class='word"
#if node.spaceBefore: result&=" space-before" #if node.spaceBefore: result&=" space-before"
if node.spaceAfter: result&=" space-after" if node.spaceAfter: result&=" space-after"
if node.chord.isNone: result&=" no-chord"
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
@ -132,14 +318,11 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
of ccnkTransposeKey: of ccnkTransposeKey:
ctx.currentKey = ctx.currentKey + node.transposeSteps ctx.currentKey = ctx.currentKey + node.transposeSteps
let headingVal = indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>" result &= indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
if ctx.currentSection.kind == ccnkNone: result &= headingVal
else:
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,19 +335,26 @@ 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>
<head> <head>
<title>""" & cc.metadata.title & "</title>\p" <title>""" & cc.metadata.title & "" & $ctx.currentKey & "</title>\p"
for ss in stylesheets: for ss in stylesheets:
if ss.startsWith("<style>"): result &= ss if ss.startsWith("<style>"): result &= ss
@ -176,14 +366,19 @@ proc toHtml*(
elif fileExists(sc): result &= "<script>\p" & readFile(sc) & "\p</script>" elif fileExists(sc): result &= "<script>\p" & readFile(sc) & "\p</script>"
else: warn "cannot read script file '" & sc & "'" else: warn "cannot read script file '" & sc & "'"
result &= " </head>\p <body>" result &= " </head>\p"
if cc.metadata.contains("columns") and cc.metadata["columns"] == "1":
result &= " <body class='one-column'>"
else:
result &= " <body class='two-column'>"
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"):
@ -193,8 +388,15 @@ proc toHtml*(
result &= indent & "<h2>" & metadataPieces.join(" | ") & "</h2>\p" result &= indent & "<h2>" & metadataPieces.join(" | ") & "</h2>\p"
if cc.metadata.contains("artist"):
result &= indent & "<div class=artist>" & cc.metadata["artist"] & "</div>\p"
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>"