Jonathan Bernard 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

295 lines
7.4 KiB
Nim

import std/[logging, options, os, strutils]
import zero_functional
import ./ast
type
FormatContext = ref object
chart: ChordChart
currentKey: ChordChartPitch
sourceKey: ChordChartPitch
currentSection: ChordChartNode
numberChart: bool
transposeSteps: int
const DEFAULT_STYLESHEET* = """
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html { font-family: sans-serif; }
.page-contents {
column-width: 336px;
column-width: 3.5in;
}
.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.5em;
text-decoration: underline;
}
h3 .section-text {
font-style: italic;
font-size: 1em;
font-weight: normal;
margin: 0 0.5em;
}
.line {
display: flex;
flex-direction: row;
align-items: flex-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; }
.chord {
font-weight: 600;
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 {
body { margin: 1em; }
}
@media print {
.page-contents { column-count: 2; }
}
</style>
"""
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
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 =
result = ""
case node.kind
of ccnkSection:
ctx.currentSection = node
result &= indent & "<section>\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]()
for contentNode in node.sectionContents:
contents.add(ctx.toHtml(contentNode, indent & " "))
result &= contents.join("\p")
result &= indent & "</section>"
ctx.currentSection = EMPTY_CHORD_CHART_NODE
of ccnkLine:
result &= indent & "<div class=line>\p"
for linePart in node.line: result &= ctx.toHtml(linePart, indent & " ")
result &= indent & "</div>"
of ccnkWord:
result &= "<span class='word"
#if node.spaceBefore: result&=" space-before"
if node.spaceAfter: result&=" space-after"
result &= "'>"
if node.chord.isSome:
result &= "<span class=chord>" &
ctx.format(ctx.transpose(node.chord.get), ctx.numberChart) & "</span>"
result &= "<span class=lyric>"
if node.word.isSome: result &= node.word.get
else: result &= "&nbsp;"
result &= "</span>"
result &= "</span>"
of ccnkNote:
result &= indent & "<div class=note>" & node.note & "</div>"
of ccnkColBreak:
result &= "<div class=column-break />"
of ccnkPageBreak:
result &= "</div><div class=page-contents>"
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>"
of ccnkRedefineKey:
let oldKey = ctx.currentKey
ctx.sourceKey = node.newKey + ctx.transposeSteps
ctx.currentKey = ctx.sourceKey
if oldKey != ctx.currentKey:
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>"
of ccnkNone: discard
proc toHtml*(
cc: ChordChart,
transpose = 0,
numberChart = false,
stylesheets = @[DEFAULT_STYLESHEET],
scripts: seq[string] = @[]): string =
var ctx = FormatContext(
chart: cc,
currentKey: cc.metadata.key.rootPitch + transpose,
sourceKey: cc.metadata.key.rootPitch,
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>
<html>
<head>
<title>""" & cc.metadata.title & "</title>\p"
for ss in stylesheets:
if ss.startsWith("<style>"): result &= ss
elif fileExists(ss): result &= "<style>\p" & readFile(ss) & "\p</style>"
else: warn "cannot read stylesheet file '" & ss & "'"
for sc in scripts:
if sc.startsWith("<script"): result &= sc
elif fileExists(sc): result &= "<script>\p" & readFile(sc) & "\p</script>"
else: warn "cannot read script file '" & sc & "'"
result &= " </head>\p <body>"
var indent = " "
# Title
result &= indent & "<h1>" & cc.metadata.title & "</h1>\p"
var metadataPieces = @["Key: " & ctx.format(ctx.transpose(cc.metadata.key))]
if cc.metadata.contains("time signature"):
metadataPieces.add(cc.metadata["time signature"])
if cc.metadata.contains("bpm"):
metadataPieces.add(cc.metadata["bpm"] & "bpm")
if cc.metadata.contains("tempo"):
metadataPieces.add(cc.metadata["tempo"])
result &= indent & "<h2>" & metadataPieces.join(" | ") & "</h2>\p"
result &= "<div class=page-contents>"
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 &= " </body>\p</html>"