Initial implementation of songbook toolset
Kotlin/JVM multi-module project for generating a scout songbook PDF from ChordPro-format text files. Includes ChordPro parser, layout engine with greedy spread packing for double-page songs, OpenPDF renderer, CLI (Clikt), Compose Desktop GUI, and 5 sample songs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
parser/build.gradle.kts
Normal file
13
parser/build.gradle.kts
Normal file
@@ -0,0 +1,13 @@
|
||||
plugins {
|
||||
id("songbook-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":model"))
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.3")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.3")
|
||||
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package de.pfadfinder.songbook.parser
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import java.io.File
|
||||
|
||||
object ChordProParser {
|
||||
|
||||
fun parse(input: String): Song {
|
||||
val lines = input.lines()
|
||||
|
||||
var title: String? = null
|
||||
val aliases = mutableListOf<String>()
|
||||
var lyricist: String? = null
|
||||
var composer: String? = null
|
||||
var key: String? = null
|
||||
val tags = mutableListOf<String>()
|
||||
val notes = mutableListOf<String>()
|
||||
val references = mutableMapOf<String, Int>()
|
||||
var capo: Int? = null
|
||||
|
||||
val sections = mutableListOf<SongSection>()
|
||||
|
||||
// Current section being built
|
||||
var currentType: SectionType? = null
|
||||
var currentLabel: String? = null
|
||||
var currentLines = mutableListOf<SongLine>()
|
||||
|
||||
fun flushSection() {
|
||||
if (currentType != null) {
|
||||
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
|
||||
currentType = null
|
||||
currentLabel = null
|
||||
currentLines = mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
for (rawLine in lines) {
|
||||
val line = rawLine.trimEnd()
|
||||
|
||||
// Skip comments
|
||||
if (line.trimStart().startsWith("#")) continue
|
||||
|
||||
// Skip empty lines
|
||||
if (line.isBlank()) continue
|
||||
|
||||
// Directive line
|
||||
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
|
||||
val inner = line.trim().removePrefix("{").removeSuffix("}").trim()
|
||||
val colonIndex = inner.indexOf(':')
|
||||
val directive: String
|
||||
val value: String?
|
||||
if (colonIndex >= 0) {
|
||||
directive = inner.substring(0, colonIndex).trim().lowercase()
|
||||
value = inner.substring(colonIndex + 1).trim()
|
||||
} else {
|
||||
directive = inner.trim().lowercase()
|
||||
value = null
|
||||
}
|
||||
|
||||
when (directive) {
|
||||
"title", "t" -> title = value
|
||||
"alias" -> if (value != null) aliases.add(value)
|
||||
"lyricist" -> lyricist = value
|
||||
"composer" -> composer = value
|
||||
"key" -> key = value
|
||||
"tags" -> if (value != null) {
|
||||
tags.addAll(value.split(",").map { it.trim() }.filter { it.isNotEmpty() })
|
||||
}
|
||||
"note" -> if (value != null) notes.add(value)
|
||||
"capo" -> capo = value?.toIntOrNull()
|
||||
"ref" -> if (value != null) {
|
||||
parseReference(value)?.let { (bookId, page) ->
|
||||
references[bookId] = page
|
||||
}
|
||||
}
|
||||
"start_of_verse", "sov" -> {
|
||||
flushSection()
|
||||
currentType = SectionType.VERSE
|
||||
currentLabel = value
|
||||
}
|
||||
"end_of_verse", "eov" -> {
|
||||
flushSection()
|
||||
}
|
||||
"start_of_chorus", "soc" -> {
|
||||
flushSection()
|
||||
currentType = SectionType.CHORUS
|
||||
currentLabel = value
|
||||
}
|
||||
"end_of_chorus", "eoc" -> {
|
||||
flushSection()
|
||||
}
|
||||
"start_of_repeat", "sor" -> {
|
||||
flushSection()
|
||||
currentType = SectionType.REPEAT
|
||||
currentLabel = value
|
||||
}
|
||||
"end_of_repeat", "eor" -> {
|
||||
flushSection()
|
||||
}
|
||||
"chorus" -> {
|
||||
flushSection()
|
||||
sections.add(SongSection(type = SectionType.CHORUS))
|
||||
}
|
||||
"repeat" -> {
|
||||
// Store repeat count as label on current section or create a new section
|
||||
if (currentType != null) {
|
||||
currentLabel = value
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Text/chord line: if we're not inside a section, start an implicit VERSE
|
||||
if (currentType == null) {
|
||||
currentType = SectionType.VERSE
|
||||
}
|
||||
|
||||
val songLine = parseChordLine(line)
|
||||
currentLines.add(songLine)
|
||||
}
|
||||
|
||||
// Flush any remaining section
|
||||
flushSection()
|
||||
|
||||
return Song(
|
||||
title = title ?: "",
|
||||
aliases = aliases.toList(),
|
||||
lyricist = lyricist,
|
||||
composer = composer,
|
||||
key = key,
|
||||
tags = tags.toList(),
|
||||
notes = notes.toList(),
|
||||
references = references.toMap(),
|
||||
capo = capo,
|
||||
sections = sections.toList()
|
||||
)
|
||||
}
|
||||
|
||||
fun parseFile(file: File): Song = parse(file.readText())
|
||||
|
||||
internal fun parseChordLine(line: String): SongLine {
|
||||
val segments = mutableListOf<LineSegment>()
|
||||
var i = 0
|
||||
val len = line.length
|
||||
|
||||
// Check if line starts with text before any chord
|
||||
if (len > 0 && line[0] != '[') {
|
||||
val nextBracket = line.indexOf('[')
|
||||
if (nextBracket < 0) {
|
||||
// No chords at all, entire line is text
|
||||
segments.add(LineSegment(chord = null, text = line))
|
||||
return SongLine(segments)
|
||||
}
|
||||
segments.add(LineSegment(chord = null, text = line.substring(0, nextBracket)))
|
||||
i = nextBracket
|
||||
}
|
||||
|
||||
while (i < len) {
|
||||
if (line[i] == '[') {
|
||||
val closeBracket = line.indexOf(']', i)
|
||||
if (closeBracket < 0) {
|
||||
// Malformed: treat rest as text
|
||||
segments.add(LineSegment(chord = null, text = line.substring(i)))
|
||||
break
|
||||
}
|
||||
val chord = line.substring(i + 1, closeBracket)
|
||||
val textStart = closeBracket + 1
|
||||
val nextBracket = line.indexOf('[', textStart)
|
||||
val text = if (nextBracket < 0) {
|
||||
line.substring(textStart)
|
||||
} else {
|
||||
line.substring(textStart, nextBracket)
|
||||
}
|
||||
segments.add(LineSegment(chord = chord, text = text))
|
||||
i = if (nextBracket < 0) len else nextBracket
|
||||
} else {
|
||||
// Should not happen if logic is correct, but handle gracefully
|
||||
val nextBracket = line.indexOf('[', i)
|
||||
if (nextBracket < 0) {
|
||||
segments.add(LineSegment(chord = null, text = line.substring(i)))
|
||||
break
|
||||
}
|
||||
segments.add(LineSegment(chord = null, text = line.substring(i, nextBracket)))
|
||||
i = nextBracket
|
||||
}
|
||||
}
|
||||
|
||||
return SongLine(segments)
|
||||
}
|
||||
|
||||
internal fun parseReference(value: String): Pair<String, Int>? {
|
||||
val parts = value.trim().split("\\s+".toRegex())
|
||||
if (parts.size < 2) return null
|
||||
val page = parts.last().toIntOrNull() ?: return null
|
||||
val bookId = parts.dropLast(1).joinToString(" ")
|
||||
return bookId to page
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.pfadfinder.songbook.parser
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
|
||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
||||
import de.pfadfinder.songbook.model.BookConfig
|
||||
import java.io.File
|
||||
|
||||
object ConfigParser {
|
||||
|
||||
private val mapper: ObjectMapper = ObjectMapper(YAMLFactory())
|
||||
.registerKotlinModule()
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
fun parse(file: File): BookConfig {
|
||||
return mapper.readValue(file, BookConfig::class.java)
|
||||
}
|
||||
|
||||
fun parse(input: String): BookConfig {
|
||||
return mapper.readValue(input, BookConfig::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.pfadfinder.songbook.parser
|
||||
|
||||
import de.pfadfinder.songbook.model.BookConfig
|
||||
import de.pfadfinder.songbook.model.Song
|
||||
|
||||
data class ValidationError(val file: String?, val line: Int?, val message: String)
|
||||
|
||||
object Validator {
|
||||
|
||||
fun validateSong(song: Song, fileName: String? = null): List<ValidationError> {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
if (song.title.isBlank()) {
|
||||
errors.add(ValidationError(file = fileName, line = null, message = "Song must have a title"))
|
||||
}
|
||||
|
||||
if (song.sections.isEmpty()) {
|
||||
errors.add(ValidationError(file = fileName, line = null, message = "Song must have at least one section"))
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
fun validateSong(song: Song, config: BookConfig, fileName: String? = null): List<ValidationError> {
|
||||
val errors = validateSong(song, fileName).toMutableList()
|
||||
|
||||
val knownBookIds = config.referenceBooks.map { it.id }.toSet()
|
||||
for ((bookId, _) in song.references) {
|
||||
if (bookId !in knownBookIds) {
|
||||
errors.add(
|
||||
ValidationError(
|
||||
file = fileName,
|
||||
line = null,
|
||||
message = "Reference to unknown book '$bookId'. Known books: ${knownBookIds.joinToString(", ")}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
fun validateConfig(config: BookConfig): List<ValidationError> {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
with(config.layout.margins) {
|
||||
if (top <= 0) errors.add(ValidationError(file = null, line = null, message = "Top margin must be greater than 0"))
|
||||
if (bottom <= 0) errors.add(ValidationError(file = null, line = null, message = "Bottom margin must be greater than 0"))
|
||||
if (inner <= 0) errors.add(ValidationError(file = null, line = null, message = "Inner margin must be greater than 0"))
|
||||
if (outer <= 0) errors.add(ValidationError(file = null, line = null, message = "Outer margin must be greater than 0"))
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package de.pfadfinder.songbook.parser
|
||||
|
||||
import de.pfadfinder.songbook.model.SectionType
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import kotlin.test.Test
|
||||
|
||||
class ChordProParserTest {
|
||||
|
||||
@Test
|
||||
fun `parse complete song`() {
|
||||
val input = """
|
||||
# This is a comment
|
||||
{title: Wonderwall}
|
||||
{alias: Wonderwall (Oasis)}
|
||||
{lyricist: Noel Gallagher}
|
||||
{composer: Noel Gallagher}
|
||||
{key: F#m}
|
||||
{tags: pop, rock, 90s}
|
||||
{note: Play with capo on 2nd fret}
|
||||
{ref: mundorgel 42}
|
||||
{capo: 2}
|
||||
|
||||
{start_of_verse: Verse 1}
|
||||
[Em7]Today is [G]gonna be the day
|
||||
That they're [Dsus4]gonna throw it back to [A7sus4]you
|
||||
{end_of_verse}
|
||||
|
||||
{start_of_chorus}
|
||||
[C]And all the [D]roads we have to [Em]walk are winding
|
||||
{end_of_chorus}
|
||||
|
||||
{chorus}
|
||||
""".trimIndent()
|
||||
|
||||
val song = ChordProParser.parse(input)
|
||||
|
||||
song.title shouldBe "Wonderwall"
|
||||
song.aliases shouldHaveSize 1
|
||||
song.aliases[0] shouldBe "Wonderwall (Oasis)"
|
||||
song.lyricist shouldBe "Noel Gallagher"
|
||||
song.composer shouldBe "Noel Gallagher"
|
||||
song.key shouldBe "F#m"
|
||||
song.tags shouldBe listOf("pop", "rock", "90s")
|
||||
song.notes shouldHaveSize 1
|
||||
song.notes[0] shouldBe "Play with capo on 2nd fret"
|
||||
song.references shouldBe mapOf("mundorgel" to 42)
|
||||
song.capo shouldBe 2
|
||||
|
||||
song.sections shouldHaveSize 3
|
||||
|
||||
// Verse 1
|
||||
val verse = song.sections[0]
|
||||
verse.type shouldBe SectionType.VERSE
|
||||
verse.label shouldBe "Verse 1"
|
||||
verse.lines shouldHaveSize 2
|
||||
|
||||
// First line of verse
|
||||
val firstLine = verse.lines[0]
|
||||
firstLine.segments shouldHaveSize 2
|
||||
firstLine.segments[0].chord shouldBe "Em7"
|
||||
firstLine.segments[0].text shouldBe "Today is "
|
||||
firstLine.segments[1].chord shouldBe "G"
|
||||
firstLine.segments[1].text shouldBe "gonna be the day"
|
||||
|
||||
// Chorus
|
||||
val chorus = song.sections[1]
|
||||
chorus.type shouldBe SectionType.CHORUS
|
||||
chorus.label.shouldBeNull()
|
||||
chorus.lines shouldHaveSize 1
|
||||
|
||||
// Empty chorus reference
|
||||
val chorusRef = song.sections[2]
|
||||
chorusRef.type shouldBe SectionType.CHORUS
|
||||
chorusRef.lines.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse title directive`() {
|
||||
val input = "{title: My Song}"
|
||||
val song = ChordProParser.parse(input)
|
||||
song.title shouldBe "My Song"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse short title directive`() {
|
||||
val input = "{t: My Song}"
|
||||
val song = ChordProParser.parse(input)
|
||||
song.title shouldBe "My Song"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse missing title results in empty string`() {
|
||||
val input = """
|
||||
{start_of_verse}
|
||||
Hello world
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.title shouldBe ""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `comments are skipped`() {
|
||||
val input = """
|
||||
{title: Test}
|
||||
# This is a comment
|
||||
{start_of_verse}
|
||||
Hello world
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.title shouldBe "Test"
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].lines shouldHaveSize 1
|
||||
song.sections[0].lines[0].segments[0].text shouldBe "Hello world"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse chord line with no chords`() {
|
||||
val line = ChordProParser.parseChordLine("Just plain text")
|
||||
line.segments shouldHaveSize 1
|
||||
line.segments[0].chord.shouldBeNull()
|
||||
line.segments[0].text shouldBe "Just plain text"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse chord line starting with chord`() {
|
||||
val line = ChordProParser.parseChordLine("[Am]Hello [C]World")
|
||||
line.segments shouldHaveSize 2
|
||||
line.segments[0].chord shouldBe "Am"
|
||||
line.segments[0].text shouldBe "Hello "
|
||||
line.segments[1].chord shouldBe "C"
|
||||
line.segments[1].text shouldBe "World"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse chord line starting with text`() {
|
||||
val line = ChordProParser.parseChordLine("Hello [Am]World")
|
||||
line.segments shouldHaveSize 2
|
||||
line.segments[0].chord.shouldBeNull()
|
||||
line.segments[0].text shouldBe "Hello "
|
||||
line.segments[1].chord shouldBe "Am"
|
||||
line.segments[1].text shouldBe "World"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse chord line with chord at end`() {
|
||||
val line = ChordProParser.parseChordLine("[Am]Hello [C]")
|
||||
line.segments shouldHaveSize 2
|
||||
line.segments[0].chord shouldBe "Am"
|
||||
line.segments[0].text shouldBe "Hello "
|
||||
line.segments[1].chord shouldBe "C"
|
||||
line.segments[1].text shouldBe ""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse chord line with only chord`() {
|
||||
val line = ChordProParser.parseChordLine("[Am]")
|
||||
line.segments shouldHaveSize 1
|
||||
line.segments[0].chord shouldBe "Am"
|
||||
line.segments[0].text shouldBe ""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse multiple aliases`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{alias: Alias One}
|
||||
{alias: Alias Two}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.aliases shouldBe listOf("Alias One", "Alias Two")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse reference with multi-word book name`() {
|
||||
val ref = ChordProParser.parseReference("My Big Songbook 123")
|
||||
ref.shouldNotBeNull()
|
||||
ref.first shouldBe "My Big Songbook"
|
||||
ref.second shouldBe 123
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse reference with single word book name`() {
|
||||
val ref = ChordProParser.parseReference("mundorgel 42")
|
||||
ref.shouldNotBeNull()
|
||||
ref.first shouldBe "mundorgel"
|
||||
ref.second shouldBe 42
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse reference with invalid page returns null`() {
|
||||
val ref = ChordProParser.parseReference("mundorgel abc")
|
||||
ref.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse reference with only one token returns null`() {
|
||||
val ref = ChordProParser.parseReference("mundorgel")
|
||||
ref.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse tags directive`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{tags: folk, german, campfire}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.tags shouldBe listOf("folk", "german", "campfire")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse tags with extra whitespace`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{tags: folk , german , campfire }
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.tags shouldBe listOf("folk", "german", "campfire")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse chorus directive creates empty section`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_chorus}
|
||||
[C]La la [G]la
|
||||
{end_of_chorus}
|
||||
{chorus}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 2
|
||||
song.sections[0].type shouldBe SectionType.CHORUS
|
||||
song.sections[0].lines shouldHaveSize 1
|
||||
song.sections[1].type shouldBe SectionType.CHORUS
|
||||
song.sections[1].lines.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse capo directive`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{capo: 3}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.capo shouldBe 3
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse capo with invalid value results in null`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{capo: abc}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.capo.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse repeat section`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_repeat: 2x}
|
||||
[Am]La la la
|
||||
{end_of_repeat}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].type shouldBe SectionType.REPEAT
|
||||
song.sections[0].label shouldBe "2x"
|
||||
song.sections[0].lines shouldHaveSize 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `implicit verse for lines outside sections`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
[Am]Hello [C]World
|
||||
Just text
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].type shouldBe SectionType.VERSE
|
||||
song.sections[0].lines shouldHaveSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple sections parsed correctly`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_verse: 1}
|
||||
Line one
|
||||
{end_of_verse}
|
||||
{start_of_verse: 2}
|
||||
Line two
|
||||
{end_of_verse}
|
||||
{start_of_chorus}
|
||||
Chorus line
|
||||
{end_of_chorus}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 3
|
||||
song.sections[0].type shouldBe SectionType.VERSE
|
||||
song.sections[0].label shouldBe "1"
|
||||
song.sections[1].type shouldBe SectionType.VERSE
|
||||
song.sections[1].label shouldBe "2"
|
||||
song.sections[2].type shouldBe SectionType.CHORUS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse multiple notes`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{note: First note}
|
||||
{note: Second note}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.notes shouldBe listOf("First note", "Second note")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse multiple references`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{ref: mundorgel 42}
|
||||
{ref: pfadfinderlied 17}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.references shouldBe mapOf("mundorgel" to 42, "pfadfinderlied" to 17)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse key directive`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{key: Am}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.key shouldBe "Am"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty input produces song with empty title and no sections`() {
|
||||
val song = ChordProParser.parse("")
|
||||
song.title shouldBe ""
|
||||
song.sections.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed chord bracket treated as text`() {
|
||||
val line = ChordProParser.parseChordLine("[Am broken text")
|
||||
line.segments shouldHaveSize 1
|
||||
line.segments[0].chord.shouldBeNull()
|
||||
line.segments[0].text shouldBe "[Am broken text"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeat directive sets label on current section`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_verse}
|
||||
Line one
|
||||
{repeat: 3}
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].label shouldBe "3"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse short directives sov eov soc eoc`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{sov: V1}
|
||||
Line one
|
||||
{eov}
|
||||
{soc}
|
||||
Chorus
|
||||
{eoc}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 2
|
||||
song.sections[0].type shouldBe SectionType.VERSE
|
||||
song.sections[0].label shouldBe "V1"
|
||||
song.sections[1].type shouldBe SectionType.CHORUS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse short directives sor eor`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{sor: 2x}
|
||||
Repeat line
|
||||
{eor}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].type shouldBe SectionType.REPEAT
|
||||
song.sections[0].label shouldBe "2x"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `section without explicit end is flushed at end of input`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_verse}
|
||||
Line one
|
||||
Line two
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].lines shouldHaveSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `section flushed when new section starts without end directive`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_verse: 1}
|
||||
Line one
|
||||
{start_of_verse: 2}
|
||||
Line two
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 2
|
||||
song.sections[0].label shouldBe "1"
|
||||
song.sections[0].lines shouldHaveSize 1
|
||||
song.sections[1].label shouldBe "2"
|
||||
song.sections[1].lines shouldHaveSize 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lyricist and composer directives`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{lyricist: John Doe}
|
||||
{composer: Jane Smith}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.lyricist shouldBe "John Doe"
|
||||
song.composer shouldBe "Jane Smith"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse consecutive chords with no text between`() {
|
||||
val line = ChordProParser.parseChordLine("[Am][C][G]End")
|
||||
line.segments shouldHaveSize 3
|
||||
line.segments[0].chord shouldBe "Am"
|
||||
line.segments[0].text shouldBe ""
|
||||
line.segments[1].chord shouldBe "C"
|
||||
line.segments[1].text shouldBe ""
|
||||
line.segments[2].chord shouldBe "G"
|
||||
line.segments[2].text shouldBe "End"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package de.pfadfinder.songbook.parser
|
||||
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlin.test.Test
|
||||
|
||||
class ConfigParserTest {
|
||||
|
||||
private val sampleYaml = """
|
||||
book:
|
||||
title: "Pfadfinder Liederbuch"
|
||||
subtitle: "Ausgabe 2024"
|
||||
edition: "3. Auflage"
|
||||
format: A5
|
||||
songs:
|
||||
directory: "./songs"
|
||||
order: alphabetical
|
||||
fonts:
|
||||
lyrics: { family: "Garamond", file: "./fonts/Garamond.ttf", size: 10 }
|
||||
chords: { family: "Garamond", file: "./fonts/Garamond-Bold.ttf", size: 9, color: "#333333" }
|
||||
title: { family: "Garamond", file: "./fonts/Garamond-Bold.ttf", size: 14 }
|
||||
metadata: { family: "Garamond", file: "./fonts/Garamond-Italic.ttf", size: 8 }
|
||||
toc: { family: "Garamond", file: "./fonts/Garamond.ttf", size: 9 }
|
||||
layout:
|
||||
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
|
||||
chord_line_spacing: 3
|
||||
verse_spacing: 4
|
||||
page_number_position: bottom-outer
|
||||
images:
|
||||
directory: "./images"
|
||||
reference_books:
|
||||
- id: mundorgel
|
||||
name: "Mundorgel"
|
||||
abbreviation: "MO"
|
||||
- id: pfadfinderlied
|
||||
name: "Das Pfadfinderlied"
|
||||
abbreviation: "PL"
|
||||
output:
|
||||
directory: "./output"
|
||||
filename: "liederbuch.pdf"
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `parse full config from yaml string`() {
|
||||
val config = ConfigParser.parse(sampleYaml)
|
||||
|
||||
// Book meta
|
||||
config.book.title shouldBe "Pfadfinder Liederbuch"
|
||||
config.book.subtitle shouldBe "Ausgabe 2024"
|
||||
config.book.edition shouldBe "3. Auflage"
|
||||
config.book.format shouldBe "A5"
|
||||
|
||||
// Songs config
|
||||
config.songs.directory shouldBe "./songs"
|
||||
config.songs.order shouldBe "alphabetical"
|
||||
|
||||
// Fonts
|
||||
config.fonts.lyrics.family shouldBe "Garamond"
|
||||
config.fonts.lyrics.file shouldBe "./fonts/Garamond.ttf"
|
||||
config.fonts.lyrics.size shouldBe 10f
|
||||
config.fonts.lyrics.color shouldBe "#000000" // default
|
||||
|
||||
config.fonts.chords.family shouldBe "Garamond"
|
||||
config.fonts.chords.file shouldBe "./fonts/Garamond-Bold.ttf"
|
||||
config.fonts.chords.size shouldBe 9f
|
||||
config.fonts.chords.color shouldBe "#333333"
|
||||
|
||||
config.fonts.title.family shouldBe "Garamond"
|
||||
config.fonts.title.size shouldBe 14f
|
||||
|
||||
config.fonts.metadata.family shouldBe "Garamond"
|
||||
config.fonts.metadata.size shouldBe 8f
|
||||
|
||||
config.fonts.toc.family shouldBe "Garamond"
|
||||
config.fonts.toc.size shouldBe 9f
|
||||
|
||||
// Layout
|
||||
config.layout.margins.top shouldBe 15f
|
||||
config.layout.margins.bottom shouldBe 15f
|
||||
config.layout.margins.inner shouldBe 20f
|
||||
config.layout.margins.outer shouldBe 12f
|
||||
config.layout.chordLineSpacing shouldBe 3f
|
||||
config.layout.verseSpacing shouldBe 4f
|
||||
config.layout.pageNumberPosition shouldBe "bottom-outer"
|
||||
|
||||
// Images
|
||||
config.images.directory shouldBe "./images"
|
||||
|
||||
// Reference books
|
||||
config.referenceBooks shouldHaveSize 2
|
||||
config.referenceBooks[0].id shouldBe "mundorgel"
|
||||
config.referenceBooks[0].name shouldBe "Mundorgel"
|
||||
config.referenceBooks[0].abbreviation shouldBe "MO"
|
||||
config.referenceBooks[1].id shouldBe "pfadfinderlied"
|
||||
config.referenceBooks[1].name shouldBe "Das Pfadfinderlied"
|
||||
config.referenceBooks[1].abbreviation shouldBe "PL"
|
||||
|
||||
// Output
|
||||
config.output.directory shouldBe "./output"
|
||||
config.output.filename shouldBe "liederbuch.pdf"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse minimal config uses defaults`() {
|
||||
val yaml = """
|
||||
book:
|
||||
title: "Minimal"
|
||||
""".trimIndent()
|
||||
val config = ConfigParser.parse(yaml)
|
||||
|
||||
config.book.title shouldBe "Minimal"
|
||||
config.book.format shouldBe "A5" // default
|
||||
config.songs.directory shouldBe "./songs" // default
|
||||
config.fonts.lyrics.family shouldBe "Helvetica" // default
|
||||
config.layout.margins.top shouldBe 15f // default
|
||||
config.output.filename shouldBe "liederbuch.pdf" // default
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse config with only book section`() {
|
||||
val yaml = """
|
||||
book:
|
||||
title: "Test"
|
||||
subtitle: "Sub"
|
||||
""".trimIndent()
|
||||
val config = ConfigParser.parse(yaml)
|
||||
config.book.title shouldBe "Test"
|
||||
config.book.subtitle shouldBe "Sub"
|
||||
config.book.edition shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse config with reference books`() {
|
||||
val yaml = """
|
||||
book:
|
||||
title: "Test"
|
||||
reference_books:
|
||||
- id: mo
|
||||
name: "Mundorgel"
|
||||
abbreviation: "MO"
|
||||
""".trimIndent()
|
||||
val config = ConfigParser.parse(yaml)
|
||||
config.referenceBooks shouldHaveSize 1
|
||||
config.referenceBooks[0].id shouldBe "mo"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse config with custom layout margins`() {
|
||||
val yaml = """
|
||||
book:
|
||||
title: "Test"
|
||||
layout:
|
||||
margins:
|
||||
top: 25
|
||||
bottom: 20
|
||||
inner: 30
|
||||
outer: 15
|
||||
chord_line_spacing: 5
|
||||
verse_spacing: 6
|
||||
""".trimIndent()
|
||||
val config = ConfigParser.parse(yaml)
|
||||
config.layout.margins.top shouldBe 25f
|
||||
config.layout.margins.bottom shouldBe 20f
|
||||
config.layout.margins.inner shouldBe 30f
|
||||
config.layout.margins.outer shouldBe 15f
|
||||
config.layout.chordLineSpacing shouldBe 5f
|
||||
config.layout.verseSpacing shouldBe 6f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse config ignores unknown properties`() {
|
||||
val yaml = """
|
||||
book:
|
||||
title: "Test"
|
||||
unknown_field: "value"
|
||||
some_extra_section:
|
||||
key: value
|
||||
""".trimIndent()
|
||||
val config = ConfigParser.parse(yaml)
|
||||
config.book.title shouldBe "Test"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package de.pfadfinder.songbook.parser
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import kotlin.test.Test
|
||||
|
||||
class ValidatorTest {
|
||||
|
||||
@Test
|
||||
fun `valid song produces no errors`() {
|
||||
val song = Song(
|
||||
title = "Test Song",
|
||||
sections = listOf(
|
||||
SongSection(type = SectionType.VERSE, lines = listOf(
|
||||
SongLine(segments = listOf(LineSegment(text = "Hello")))
|
||||
))
|
||||
)
|
||||
)
|
||||
val errors = Validator.validateSong(song)
|
||||
errors.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing title produces error`() {
|
||||
val song = Song(
|
||||
title = "",
|
||||
sections = listOf(
|
||||
SongSection(type = SectionType.VERSE, lines = listOf(
|
||||
SongLine(segments = listOf(LineSegment(text = "Hello")))
|
||||
))
|
||||
)
|
||||
)
|
||||
val errors = Validator.validateSong(song)
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "title"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank title produces error`() {
|
||||
val song = Song(
|
||||
title = " ",
|
||||
sections = listOf(
|
||||
SongSection(type = SectionType.VERSE, lines = listOf(
|
||||
SongLine(segments = listOf(LineSegment(text = "Hello")))
|
||||
))
|
||||
)
|
||||
)
|
||||
val errors = Validator.validateSong(song)
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "title"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty sections produces error`() {
|
||||
val song = Song(
|
||||
title = "Test",
|
||||
sections = emptyList()
|
||||
)
|
||||
val errors = Validator.validateSong(song)
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "section"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing title and empty sections produces two errors`() {
|
||||
val song = Song(title = "", sections = emptyList())
|
||||
val errors = Validator.validateSong(song)
|
||||
errors shouldHaveSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fileName is included in error`() {
|
||||
val song = Song(title = "", sections = emptyList())
|
||||
val errors = Validator.validateSong(song, "test.chopro")
|
||||
errors.forEach { it.file shouldContain "test.chopro" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid song with known references produces no errors`() {
|
||||
val config = BookConfig(
|
||||
referenceBooks = listOf(
|
||||
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO")
|
||||
)
|
||||
)
|
||||
val song = Song(
|
||||
title = "Test",
|
||||
references = mapOf("mundorgel" to 42),
|
||||
sections = listOf(
|
||||
SongSection(type = SectionType.VERSE, lines = listOf(
|
||||
SongLine(segments = listOf(LineSegment(text = "Hello")))
|
||||
))
|
||||
)
|
||||
)
|
||||
val errors = Validator.validateSong(song, config)
|
||||
errors.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown reference book produces error`() {
|
||||
val config = BookConfig(
|
||||
referenceBooks = listOf(
|
||||
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO")
|
||||
)
|
||||
)
|
||||
val song = Song(
|
||||
title = "Test",
|
||||
references = mapOf("unknown_book" to 42),
|
||||
sections = listOf(
|
||||
SongSection(type = SectionType.VERSE, lines = listOf(
|
||||
SongLine(segments = listOf(LineSegment(text = "Hello")))
|
||||
))
|
||||
)
|
||||
)
|
||||
val errors = Validator.validateSong(song, config)
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "unknown_book"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple unknown references produce multiple errors`() {
|
||||
val config = BookConfig(referenceBooks = emptyList())
|
||||
val song = Song(
|
||||
title = "Test",
|
||||
references = mapOf("book1" to 1, "book2" to 2),
|
||||
sections = listOf(
|
||||
SongSection(type = SectionType.VERSE, lines = listOf(
|
||||
SongLine(segments = listOf(LineSegment(text = "Hello")))
|
||||
))
|
||||
)
|
||||
)
|
||||
val errors = Validator.validateSong(song, config)
|
||||
errors shouldHaveSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid config produces no errors`() {
|
||||
val config = BookConfig()
|
||||
val errors = Validator.validateConfig(config)
|
||||
errors.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero top margin produces error`() {
|
||||
val config = BookConfig(
|
||||
layout = LayoutConfig(margins = Margins(top = 0f))
|
||||
)
|
||||
val errors = Validator.validateConfig(config)
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "Top margin"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative bottom margin produces error`() {
|
||||
val config = BookConfig(
|
||||
layout = LayoutConfig(margins = Margins(bottom = -5f))
|
||||
)
|
||||
val errors = Validator.validateConfig(config)
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "Bottom margin"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative inner margin produces error`() {
|
||||
val config = BookConfig(
|
||||
layout = LayoutConfig(margins = Margins(inner = -1f))
|
||||
)
|
||||
val errors = Validator.validateConfig(config)
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "Inner margin"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero outer margin produces error`() {
|
||||
val config = BookConfig(
|
||||
layout = LayoutConfig(margins = Margins(outer = 0f))
|
||||
)
|
||||
val errors = Validator.validateConfig(config)
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "Outer margin"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all margins zero produces four errors`() {
|
||||
val config = BookConfig(
|
||||
layout = LayoutConfig(margins = Margins(top = 0f, bottom = 0f, inner = 0f, outer = 0f))
|
||||
)
|
||||
val errors = Validator.validateConfig(config)
|
||||
errors shouldHaveSize 4
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown reference with fileName in error`() {
|
||||
val config = BookConfig(referenceBooks = emptyList())
|
||||
val song = Song(
|
||||
title = "Test",
|
||||
references = mapOf("book1" to 1),
|
||||
sections = listOf(
|
||||
SongSection(type = SectionType.VERSE, lines = listOf(
|
||||
SongLine(segments = listOf(LineSegment(text = "Hello")))
|
||||
))
|
||||
)
|
||||
)
|
||||
val errors = Validator.validateSong(song, config, "myfile.chopro")
|
||||
errors shouldHaveSize 1
|
||||
errors[0].file shouldContain "myfile.chopro"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user