369 lines
8.3 KiB
Nim
369 lines
8.3 KiB
Nim
import std/[logging, options, os, strutils]
|
|
import zero_functional
|
|
|
|
import ./[ast, notation]
|
|
|
|
type
|
|
FormatContext = ref object
|
|
chart: ChordChart
|
|
currentKey: Key
|
|
sourceKey: Key
|
|
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 { 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;
|
|
}
|
|
|
|
.number-chart .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.3em;
|
|
font-variant: small-caps;
|
|
}
|
|
|
|
.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.5em;
|
|
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: baseline;
|
|
|
|
margin-left: 0.5em;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
.word {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.word.space-after { margin-right: 0.3em; }
|
|
.word.no-chord { align-self: flex-end; }
|
|
|
|
.lyric {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.chord {
|
|
font-size: 125%;
|
|
font-style: italic;
|
|
font-weight: 700;
|
|
margin-right: 0.5em;
|
|
}
|
|
|
|
.chord > * {
|
|
display: inline-block;
|
|
height: 1.2em;
|
|
}
|
|
|
|
.number-chart .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>
|
|
"""
|
|
|
|
func format(ctx: FormatContext, chord: ChordChartChord, useNumber = false): string =
|
|
##if not useNumber and chord.original.isSome: return chord.original.get
|
|
|
|
result = "<span class=root>"
|
|
if useNumber: result &= $chord.root
|
|
else: result &= $ctx.currentKey.spellPitch(chord.root)
|
|
result &= "</span>"
|
|
|
|
if chord.flavor.isSome:
|
|
result &= "<span class=flavor>" & chord.flavor.get & "</span>"
|
|
|
|
if chord.bass.isSome:
|
|
result &= "<span class=bass>/"
|
|
if useNumber: result &= $chord.bass.get
|
|
else: result &= $ctx.currentKey.spellPitch(chord.bass.get)
|
|
result &= "</span>"
|
|
|
|
|
|
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"
|
|
if node.chord.isNone: result&=" no-chord"
|
|
result &= "'>"
|
|
|
|
if node.chord.isSome:
|
|
result &= "<span class=chord>" &
|
|
ctx.format(node.chord.get, ctx.numberChart) & "</span>"
|
|
|
|
result &= "<span class=lyric>"
|
|
if node.word.isSome: result &= node.word.get
|
|
else: result &= " "
|
|
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.transpose(node.transposeSteps)
|
|
result &= indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
|
|
|
|
of ccnkRedefineKey:
|
|
let oldKey = ctx.currentKey
|
|
ctx.sourceKey = node.newKey
|
|
ctx.currentKey = ctx.sourceKey.transpose(ctx.transposeSteps)
|
|
|
|
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.transpose(transpose),
|
|
sourceKey: cc.metadata.key,
|
|
currentSection: EMPTY_CHORD_CHART_NODE,
|
|
numberChart: numberChart,
|
|
transposeSteps: transpose)
|
|
|
|
debug "Formatting:\p\tsource key: $#\p\ttransposing: $#\p\ttarget key: $#" %
|
|
[$ctx.sourceKey, $transpose, $ctx.currentKey]
|
|
|
|
result = """<!doctype html>
|
|
<html>
|
|
<head>
|
|
<title>""" & cc.metadata.title & " — " & $ctx.currentKey & "</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"
|
|
|
|
var bodyClasses = newSeq[string]()
|
|
if numberChart: bodyClasses.add("number-chart")
|
|
if cc.metadata.contains("columns") and cc.metadata["columns"] == "1":
|
|
bodyClasses.add("one-column")
|
|
else: bodyClasses.add("two-column")
|
|
result &= " <body class='" & bodyClasses.join(" ") & "'>"
|
|
|
|
var indent = " "
|
|
|
|
# Title
|
|
result &= indent & "<h1>" & cc.metadata.title & "</h1>\p"
|
|
|
|
var metadataPieces = @["Key: " & $ctx.currentKey]
|
|
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"
|
|
|
|
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")
|
|
|
|
if cc.metadata.contains("song order"):
|
|
result &= makeSongOrder(cc.metadata["song order"], indent)
|
|
|
|
result &= "</div>"
|
|
|
|
result &= " </body>\p</html>"
|