Compare commits
5 Commits
Author | SHA1 | Date | |
---|---|---|---|
533089431e | |||
d7dd509138 | |||
73bdb97881 | |||
a57aed0715 | |||
2712f9ff52 |
@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "0.5.1"
|
||||
version = "0.5.6"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Chord chart formatter compatible with Planning Center Online"
|
||||
license = "MIT"
|
||||
@ -12,3 +12,9 @@ bin = @["pco_chords"]
|
||||
# Dependencies
|
||||
|
||||
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'"
|
||||
|
@ -38,7 +38,12 @@ when isMainModule:
|
||||
if args["--transpose"]: parseInt($args["--transpose"])
|
||||
else: 0
|
||||
|
||||
let outputHtml = ast.toHtml(transpose, args["--number-chart"])
|
||||
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)
|
||||
else: stdout.write(outputHtml)
|
||||
|
@ -77,7 +77,7 @@ iterator pairs*(ccmd: ChordChartMetadata): tuple[key, value: string] =
|
||||
|
||||
func kind*(ccn: ChordChartNode): ChordChartNodeKind = ccn.kind
|
||||
|
||||
func `$`(pitch: ChordChartPitch): string =
|
||||
func `$`*(pitch: ChordChartPitch): string =
|
||||
["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"][ord(pitch)]
|
||||
|
||||
func renderPitchInKey*(
|
||||
@ -87,14 +87,43 @@ func renderPitchInKey*(
|
||||
|
||||
var scaleNames: array[(ord(high(ChordChartPitch)) + 1), string]
|
||||
if useSharps.isNone:
|
||||
# If we aren't told to use sharps or flats, we render the diatonic pitches
|
||||
# according to standard theory (C# in the key of D, Db in the key of Ab)
|
||||
# but we render non-diatonic notes with flats (prefer the b6 and b7 over
|
||||
# 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 A, B, C, D, E, G:
|
||||
["G#", "A", "A#", "B", "C", "C#", "D", "D#", "E", "F", "F#", "G"]
|
||||
of Af, Bf, Df, Ef, F:
|
||||
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:
|
||||
@ -112,7 +141,7 @@ func renderPitchInKey*(
|
||||
of F:
|
||||
["G#", "G𝄪", "A#", "B", "B#", "C#", "C𝄪", "D#", "D𝄪", "E#", "F#", "F𝄪"]
|
||||
|
||||
else: # useSharps.isSome and not useSharps.get
|
||||
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"]
|
||||
@ -259,7 +288,7 @@ proc parsePitch*(ctx: ParserContext, keyValue: string): ChordChartPitch =
|
||||
# see regexr.com/70nv1
|
||||
let CHORD_REGEX =
|
||||
"([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
|
||||
"(\\/([b#♭♮𝄫𝄪]?[A-G1-7][b#♭♮𝄫𝄪]?))?" # optional bass
|
||||
let CHORD_PAT = re(CHORD_REGEX)
|
||||
|
||||
@ -344,13 +373,46 @@ let PAGE_BREAK_PAT = re"\s*PAGE_BREAK\s*$"
|
||||
let TRANSPOSE_PAT = re"\s*TRANSPOSE KEY ([+-]\d+)\s*$"
|
||||
let REDEFINE_KEY_PAT = re"\s*REDEFINE KEY ([+-]\d+)\s*$"
|
||||
#let NOTE_PAT = re"^(.*)({{[^}]+}}|{[^}]+})(.*)$"
|
||||
let NOTE_START_PAT = re"{{"
|
||||
let NOTE_END_PAT = re"}}"
|
||||
let NOTE_START_PAT = re"\{\{"
|
||||
let NOTE_END_PAT = re"\}\}"
|
||||
let SPACE_PAT = re"\s"
|
||||
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 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] =
|
||||
result = @[]
|
||||
@ -394,10 +456,11 @@ proc parseLineParts(ctx: ParserContext, parts: varargs[string]): seq[ChordChartN
|
||||
chord: none[ChordChartChord](),
|
||||
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)
|
||||
|
||||
let m = line.match(NAKED_CHORDS_ONLY_PAT)
|
||||
var m = line.match(NAKED_CHORDS_ONLY_PAT)
|
||||
if m.isSome:
|
||||
result.line = line.splitWhitespace --> map(
|
||||
ChordChartNode(
|
||||
@ -406,49 +469,28 @@ proc parseLine(ctx: ParserContext, line: string): ChordChartNode =
|
||||
spaceBefore: false, #FIXME
|
||||
chord: ctx.parseChord(it.strip),
|
||||
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)
|
||||
|
||||
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] =
|
||||
result = @[]
|
||||
while ctx.hasNextLine:
|
||||
var line = ctx.nextLine
|
||||
|
||||
var m = line.find(NOTE_START_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)
|
||||
var m = line.match(TRANSPOSE_PAT)
|
||||
if m.isSome:
|
||||
addToCurSection(ChordChartNode(
|
||||
kind: ccnkTransposeKey,
|
||||
@ -475,6 +517,7 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
|
||||
m = line.match(PAGE_BREAK_PAT)
|
||||
if m.isSome:
|
||||
result.add(ChordChartNode(kind: ccnkPageBreak))
|
||||
ctx.curSection = EMPTY_CHORD_CHART_NODE
|
||||
continue
|
||||
|
||||
m = line.match(SECTION_LINE_PAT)
|
||||
|
@ -1,4 +1,4 @@
|
||||
const PCO_CHORDS_VERSION* = "0.5.1"
|
||||
const PCO_CHORDS_VERSION* = "0.5.6"
|
||||
|
||||
const USAGE* = """Usage:
|
||||
pco_chords [options]
|
||||
@ -23,6 +23,7 @@ Options:
|
||||
--help Print this usage information
|
||||
--debug Enable debug logging.
|
||||
--echo-args Echo the input parameters.
|
||||
--large-print Use the large type styling.
|
||||
"""
|
||||
|
||||
const ONLINE_HELP* = """
|
||||
|
@ -22,11 +22,102 @@ const DEFAULT_STYLESHEET* = """
|
||||
|
||||
html { font-family: sans-serif; }
|
||||
|
||||
.page-contents {
|
||||
column-width: 336px;
|
||||
column-width: 3.5in;
|
||||
.page-contents { line-height: 1.1; }
|
||||
.one-column .page-contents { column-count: 1; }
|
||||
.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; }
|
||||
|
||||
h2 { font-size: 1.25em; }
|
||||
@ -49,10 +140,12 @@ h3 .section-text {
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
|
||||
.artist { font-style: italic; }
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
align-items: baseline;
|
||||
|
||||
margin-left: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
@ -65,16 +158,29 @@ h3 .section-text {
|
||||
}
|
||||
|
||||
.word.space-after { margin-right: 0.5em; }
|
||||
.word.no-chord { align-self: flex-end; }
|
||||
|
||||
.lyric {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chord {
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
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;
|
||||
@ -88,10 +194,6 @@ h3 .section-text {
|
||||
@media screen {
|
||||
body { margin: 1em; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
.page-contents { column-count: 2; }
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
@ -191,6 +293,7 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
|
||||
result &= "<span class='word"
|
||||
#if node.spaceBefore: result&=" space-before"
|
||||
if node.spaceAfter: result&=" space-after"
|
||||
if node.chord.isNone: result&=" no-chord"
|
||||
result &= "'>"
|
||||
|
||||
if node.chord.isSome:
|
||||
@ -215,10 +318,7 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
|
||||
|
||||
of ccnkTransposeKey:
|
||||
ctx.currentKey = ctx.currentKey + node.transposeSteps
|
||||
let headingVal = indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
|
||||
if ctx.currentSection.kind == ccnkNone: result &= headingVal
|
||||
else:
|
||||
result &= "</section><section>" & headingVal & "</section><section>"
|
||||
result &= indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
|
||||
|
||||
of ccnkRedefineKey:
|
||||
let oldKey = ctx.currentKey
|
||||
@ -254,7 +354,7 @@ proc toHtml*(
|
||||
result = """<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>""" & cc.metadata.title & "</title>\p"
|
||||
<title>""" & cc.metadata.title & " — " & $ctx.currentKey & "</title>\p"
|
||||
|
||||
for ss in stylesheets:
|
||||
if ss.startsWith("<style>"): result &= ss
|
||||
@ -266,7 +366,12 @@ proc toHtml*(
|
||||
elif fileExists(sc): result &= "<script>\p" & readFile(sc) & "\p</script>"
|
||||
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 = " "
|
||||
|
||||
@ -283,6 +388,9 @@ proc toHtml*(
|
||||
|
||||
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 &= join(cc.nodes --> map(ctx.toHtml(it, indent & " ")), "\p")
|
||||
|
||||
|
Reference in New Issue
Block a user