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:
shahondin1624
2026-03-17 08:35:42 +01:00
commit e386501b57
56 changed files with 5152 additions and 0 deletions

3
model/build.gradle.kts Normal file
View File

@@ -0,0 +1,3 @@
plugins {
id("songbook-conventions")
}

View File

@@ -0,0 +1,67 @@
package de.pfadfinder.songbook.model
data class BookConfig(
val book: BookMeta = BookMeta(),
val songs: SongsConfig = SongsConfig(),
val fonts: FontsConfig = FontsConfig(),
val layout: LayoutConfig = LayoutConfig(),
val images: ImagesConfig = ImagesConfig(),
val referenceBooks: List<ReferenceBook> = emptyList(),
val output: OutputConfig = OutputConfig()
)
data class BookMeta(
val title: String = "Liederbuch",
val subtitle: String? = null,
val edition: String? = null,
val format: String = "A5"
)
data class SongsConfig(
val directory: String = "./songs",
val order: String = "alphabetical" // "alphabetical" or "manual"
)
data class FontsConfig(
val lyrics: FontSpec = FontSpec(family = "Helvetica", size = 10f),
val chords: FontSpec = FontSpec(family = "Helvetica", size = 9f, color = "#333333"),
val title: FontSpec = FontSpec(family = "Helvetica", size = 14f),
val metadata: FontSpec = FontSpec(family = "Helvetica", size = 8f),
val toc: FontSpec = FontSpec(family = "Helvetica", size = 9f)
)
data class FontSpec(
val family: String = "Helvetica",
val file: String? = null,
val size: Float = 10f,
val color: String = "#000000"
)
data class LayoutConfig(
val margins: Margins = Margins(),
val chordLineSpacing: Float = 3f, // mm
val verseSpacing: Float = 4f, // mm
val pageNumberPosition: String = "bottom-outer"
)
data class Margins(
val top: Float = 15f,
val bottom: Float = 15f,
val inner: Float = 20f,
val outer: Float = 12f
)
data class ImagesConfig(
val directory: String = "./images"
)
data class ReferenceBook(
val id: String,
val name: String,
val abbreviation: String
)
data class OutputConfig(
val directory: String = "./output",
val filename: String = "liederbuch.pdf"
)

View File

@@ -0,0 +1,7 @@
package de.pfadfinder.songbook.model
import java.io.OutputStream
interface BookRenderer {
fun render(layout: LayoutResult, config: BookConfig, output: OutputStream)
}

View File

@@ -0,0 +1,6 @@
package de.pfadfinder.songbook.model
interface FontMetrics {
fun measureTextWidth(text: String, font: FontSpec, size: Float): Float
fun measureLineHeight(font: FontSpec, size: Float): Float
}

View File

@@ -0,0 +1,26 @@
package de.pfadfinder.songbook.model
data class MeasuredSong(
val song: Song,
val totalHeightMm: Float,
val pageCount: Int // 1 or 2
)
sealed class PageContent {
data class SongPage(val song: Song, val pageIndex: Int) : PageContent() // pageIndex 0 or 1 for 2-page songs
data class FillerImage(val imagePath: String) : PageContent()
data object BlankPage : PageContent()
}
data class LayoutResult(
val tocPages: Int,
val pages: List<PageContent>,
val tocEntries: List<TocEntry>
)
data class TocEntry(
val title: String,
val pageNumber: Int,
val isAlias: Boolean = false,
val references: Map<String, Int> = emptyMap() // bookAbbrev → page
)

View File

@@ -0,0 +1,31 @@
package de.pfadfinder.songbook.model
data class Song(
val title: String,
val aliases: List<String> = emptyList(),
val lyricist: String? = null,
val composer: String? = null,
val key: String? = null,
val tags: List<String> = emptyList(),
val notes: List<String> = emptyList(),
val references: Map<String, Int> = emptyMap(), // bookId → page number
val capo: Int? = null,
val sections: List<SongSection> = emptyList()
)
data class SongSection(
val type: SectionType,
val label: String? = null,
val lines: List<SongLine> = emptyList()
)
enum class SectionType {
VERSE, CHORUS, BRIDGE, REPEAT
}
data class SongLine(val segments: List<LineSegment>)
data class LineSegment(
val chord: String? = null, // null = no chord above this segment
val text: String
)