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:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
buildSrc/build/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Output
|
||||
output/
|
||||
|
||||
# Kotlin
|
||||
*.class
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
80
CLAUDE.md
Normal file
80
CLAUDE.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
# Build everything
|
||||
gradle build
|
||||
|
||||
# Run all tests
|
||||
gradle test
|
||||
|
||||
# Run tests for a specific module
|
||||
gradle :parser:test
|
||||
gradle :layout:test
|
||||
gradle :renderer-pdf:test
|
||||
gradle :app:test
|
||||
|
||||
# Run a single test class
|
||||
gradle :parser:test --tests ChordProParserTest
|
||||
|
||||
# Run a single test method
|
||||
gradle :parser:test --tests "ChordProParserTest.parse complete song"
|
||||
|
||||
# Build and run CLI
|
||||
gradle :cli:run --args="build -d /path/to/project"
|
||||
gradle :cli:run --args="validate -d /path/to/project"
|
||||
|
||||
# Launch GUI
|
||||
gradle :gui:run
|
||||
```
|
||||
|
||||
Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.1.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Pipeline:** Parse → Measure → Paginate → Render
|
||||
|
||||
`SongbookPipeline` (in `app`) orchestrates the full flow:
|
||||
1. `ConfigParser` reads `songbook.yaml` → `BookConfig`
|
||||
2. `ChordProParser` reads `.chopro` files → `Song` objects
|
||||
3. `Validator` checks config and songs
|
||||
4. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
|
||||
5. `TocGenerator` estimates TOC page count and creates entries
|
||||
6. `PaginationEngine` arranges songs into pages (greedy spread packing)
|
||||
7. `PdfBookRenderer` generates the PDF via OpenPDF
|
||||
|
||||
**Module dependency graph:**
|
||||
```
|
||||
model ← parser
|
||||
model ← layout
|
||||
model ← renderer-pdf
|
||||
parser, layout, renderer-pdf ← app
|
||||
app ← cli (Clikt)
|
||||
app, parser ← gui (Compose Desktop)
|
||||
```
|
||||
|
||||
`model` is the foundation with no dependencies — all data classes, the `FontMetrics` interface, and the `BookRenderer` interface live here. The `FontMetrics` abstraction decouples layout from rendering: `PdfFontMetrics` is the real implementation (in renderer-pdf), `StubFontMetrics` is used in layout tests.
|
||||
|
||||
**Pagination constraint:** Songs spanning 2 pages must start on a left (even) page. The `PaginationEngine` inserts filler images or blank pages to enforce this.
|
||||
|
||||
## Key Types
|
||||
|
||||
- `Song` → sections → `SongLine` → `LineSegment(chord?, text)` — chord is placed above the text segment
|
||||
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`
|
||||
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
|
||||
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
|
||||
|
||||
## Song Format
|
||||
|
||||
ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
|
||||
|
||||
## Test Patterns
|
||||
|
||||
Tests use `kotlin.test` annotations with Kotest assertions (`shouldBe`, `shouldHaveSize`, etc.) on JUnit 5. Layout tests use `StubFontMetrics` to avoid PDF font dependencies. App integration tests create temp directories with song files and config.
|
||||
|
||||
## Package
|
||||
|
||||
All code under `de.pfadfinder.songbook.*` — subpackages match module names (`.model`, `.parser`, `.layout`, `.renderer.pdf`, `.app`, `.cli`, `.gui`).
|
||||
16
app/build.gradle.kts
Normal file
16
app/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
||||
plugins {
|
||||
id("songbook-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":model"))
|
||||
implementation(project(":parser"))
|
||||
implementation(project(":layout"))
|
||||
implementation(project(":renderer-pdf"))
|
||||
|
||||
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||
implementation("ch.qos.logback:logback-classic:1.5.16")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package de.pfadfinder.songbook.app
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import de.pfadfinder.songbook.parser.*
|
||||
import de.pfadfinder.songbook.layout.*
|
||||
import de.pfadfinder.songbook.renderer.pdf.*
|
||||
import mu.KotlinLogging
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
data class BuildResult(
|
||||
val success: Boolean,
|
||||
val outputFile: File? = null,
|
||||
val errors: List<ValidationError> = emptyList(),
|
||||
val songCount: Int = 0,
|
||||
val pageCount: Int = 0
|
||||
)
|
||||
|
||||
class SongbookPipeline(private val projectDir: File) {
|
||||
|
||||
fun build(): BuildResult {
|
||||
// 1. Parse config
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
if (!configFile.exists()) {
|
||||
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
||||
}
|
||||
logger.info { "Parsing config: ${configFile.absolutePath}" }
|
||||
val config = ConfigParser.parse(configFile)
|
||||
|
||||
// Validate config
|
||||
val configErrors = Validator.validateConfig(config)
|
||||
if (configErrors.isNotEmpty()) {
|
||||
return BuildResult(false, errors = configErrors)
|
||||
}
|
||||
|
||||
// 2. Parse songs
|
||||
val songsDir = File(projectDir, config.songs.directory)
|
||||
if (!songsDir.exists() || !songsDir.isDirectory) {
|
||||
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "Songs directory not found")))
|
||||
}
|
||||
|
||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
if (songFiles.isEmpty()) {
|
||||
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found")))
|
||||
}
|
||||
|
||||
logger.info { "Found ${songFiles.size} song files" }
|
||||
|
||||
val songs = mutableListOf<Song>()
|
||||
val allErrors = mutableListOf<ValidationError>()
|
||||
|
||||
for (file in songFiles) {
|
||||
try {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
val songErrors = Validator.validateSong(song, file.name)
|
||||
if (songErrors.isNotEmpty()) {
|
||||
allErrors.addAll(songErrors)
|
||||
} else {
|
||||
songs.add(song)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
if (allErrors.isNotEmpty()) {
|
||||
return BuildResult(false, errors = allErrors)
|
||||
}
|
||||
|
||||
// Sort songs
|
||||
val sortedSongs = when (config.songs.order) {
|
||||
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
|
||||
else -> songs // manual order = file order
|
||||
}
|
||||
|
||||
logger.info { "Parsed ${sortedSongs.size} songs" }
|
||||
|
||||
// 3. Measure songs
|
||||
val fontMetrics = PdfFontMetrics()
|
||||
val measurementEngine = MeasurementEngine(fontMetrics, config)
|
||||
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
|
||||
|
||||
// 4. Generate TOC and paginate
|
||||
val tocGenerator = TocGenerator(config)
|
||||
val tocPages = tocGenerator.estimateTocPages(sortedSongs)
|
||||
|
||||
val paginationEngine = PaginationEngine(config)
|
||||
val pages = paginationEngine.paginate(measuredSongs, tocPages)
|
||||
|
||||
val tocEntries = tocGenerator.generate(pages, tocPages)
|
||||
|
||||
val layoutResult = LayoutResult(
|
||||
tocPages = tocPages,
|
||||
pages = pages,
|
||||
tocEntries = tocEntries
|
||||
)
|
||||
|
||||
logger.info { "Layout: ${tocPages} TOC pages, ${pages.size} content pages" }
|
||||
|
||||
// 5. Render PDF
|
||||
val outputDir = File(projectDir, config.output.directory)
|
||||
outputDir.mkdirs()
|
||||
val outputFile = File(outputDir, config.output.filename)
|
||||
|
||||
logger.info { "Rendering PDF: ${outputFile.absolutePath}" }
|
||||
|
||||
val renderer = PdfBookRenderer()
|
||||
FileOutputStream(outputFile).use { fos ->
|
||||
renderer.render(layoutResult, config, fos)
|
||||
}
|
||||
|
||||
logger.info { "Build complete: ${sortedSongs.size} songs, ${pages.size + tocPages} pages" }
|
||||
|
||||
return BuildResult(
|
||||
success = true,
|
||||
outputFile = outputFile,
|
||||
songCount = sortedSongs.size,
|
||||
pageCount = pages.size + tocPages
|
||||
)
|
||||
}
|
||||
|
||||
fun validate(): List<ValidationError> {
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
if (!configFile.exists()) {
|
||||
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
||||
}
|
||||
|
||||
val config = ConfigParser.parse(configFile)
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
errors.addAll(Validator.validateConfig(config))
|
||||
|
||||
val songsDir = File(projectDir, config.songs.directory)
|
||||
if (!songsDir.exists()) {
|
||||
errors.add(ValidationError(config.songs.directory, null, "Songs directory not found"))
|
||||
return errors
|
||||
}
|
||||
|
||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
for (file in songFiles) {
|
||||
try {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
errors.addAll(Validator.validateSong(song, file.name))
|
||||
} catch (e: Exception) {
|
||||
errors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package de.pfadfinder.songbook.app
|
||||
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.collections.shouldNotBeEmpty
|
||||
import io.kotest.matchers.ints.shouldBeGreaterThan
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
|
||||
class SongbookPipelineTest {
|
||||
|
||||
private fun createTempProject(): File {
|
||||
val dir = kotlin.io.path.createTempDirectory("songbook-test").toFile()
|
||||
dir.deleteOnExit()
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun writeConfig(projectDir: File, config: String = defaultConfig()) {
|
||||
File(projectDir, "songbook.yaml").writeText(config)
|
||||
}
|
||||
|
||||
private fun defaultConfig(
|
||||
songsDir: String = "./songs",
|
||||
outputDir: String = "./output",
|
||||
outputFilename: String = "liederbuch.pdf",
|
||||
order: String = "alphabetical"
|
||||
): String = """
|
||||
book:
|
||||
title: "Test Liederbuch"
|
||||
format: "A5"
|
||||
songs:
|
||||
directory: "$songsDir"
|
||||
order: "$order"
|
||||
fonts:
|
||||
lyrics:
|
||||
family: "Helvetica"
|
||||
size: 10
|
||||
chords:
|
||||
family: "Helvetica"
|
||||
size: 9
|
||||
title:
|
||||
family: "Helvetica"
|
||||
size: 14
|
||||
metadata:
|
||||
family: "Helvetica"
|
||||
size: 8
|
||||
toc:
|
||||
family: "Helvetica"
|
||||
size: 9
|
||||
layout:
|
||||
margins:
|
||||
top: 15
|
||||
bottom: 15
|
||||
inner: 20
|
||||
outer: 12
|
||||
images:
|
||||
directory: "./images"
|
||||
output:
|
||||
directory: "$outputDir"
|
||||
filename: "$outputFilename"
|
||||
""".trimIndent()
|
||||
|
||||
private fun writeSongFile(songsDir: File, filename: String, content: String) {
|
||||
songsDir.mkdirs()
|
||||
File(songsDir, filename).writeText(content)
|
||||
}
|
||||
|
||||
private fun sampleSong(title: String = "Test Song"): String = """
|
||||
{title: $title}
|
||||
{start_of_verse}
|
||||
[Am]Hello [C]world
|
||||
This is a test
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
|
||||
// --- build() tests ---
|
||||
|
||||
@Test
|
||||
fun `build returns error when songbook yaml is missing`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors shouldHaveSize 1
|
||||
result.errors[0].message shouldContain "songbook.yaml not found"
|
||||
result.outputFile.shouldBeNull()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build returns error when songs directory does not exist`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(songsDir = "./nonexistent"))
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors shouldHaveSize 1
|
||||
result.errors[0].message shouldContain "Songs directory not found"
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build returns error when songs directory is empty`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
File(projectDir, "songs").mkdirs()
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors shouldHaveSize 1
|
||||
result.errors[0].message shouldContain "No song files found"
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build returns error for invalid config with zero margins`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
val config = """
|
||||
book:
|
||||
title: "Test"
|
||||
layout:
|
||||
margins:
|
||||
top: 0
|
||||
bottom: 15
|
||||
inner: 20
|
||||
outer: 12
|
||||
""".trimIndent()
|
||||
writeConfig(projectDir, config)
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors.shouldNotBeEmpty()
|
||||
result.errors.any { it.message.contains("margin", ignoreCase = true) }.shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build returns error for song with missing title`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "bad_song.chopro", """
|
||||
{start_of_verse}
|
||||
[Am]Hello world
|
||||
{end_of_verse}
|
||||
""".trimIndent())
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors.shouldNotBeEmpty()
|
||||
result.errors.any { it.message.contains("title", ignoreCase = true) }.shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build returns error for song with no sections`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "empty_song.chopro", "{title: Empty Song}")
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.errors.shouldNotBeEmpty()
|
||||
result.errors.any { it.message.contains("section", ignoreCase = true) }.shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build succeeds with valid project`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong("Alpha Song"))
|
||||
writeSongFile(songsDir, "song2.chopro", sampleSong("Beta Song"))
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.errors.shouldBeEmpty()
|
||||
result.outputFile.shouldNotBeNull()
|
||||
result.outputFile!!.exists().shouldBeTrue()
|
||||
result.songCount shouldBe 2
|
||||
result.pageCount shouldBeGreaterThan 0
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build creates output directory if it does not exist`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(outputDir = "./out/build"))
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
File(projectDir, "out/build").exists().shouldBeTrue()
|
||||
result.outputFile!!.exists().shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with alphabetical order sorts songs by title`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(order = "alphabetical"))
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "z_first.chopro", sampleSong("Zebra Song"))
|
||||
writeSongFile(songsDir, "a_second.chopro", sampleSong("Alpha Song"))
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.songCount shouldBe 2
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with manual order preserves file order`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(order = "manual"))
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "02_second.chopro", sampleSong("Second Song"))
|
||||
writeSongFile(songsDir, "01_first.chopro", sampleSong("First Song"))
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.songCount shouldBe 2
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build recognizes cho extension`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.cho", sampleSong("Cho Song"))
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.songCount shouldBe 1
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build recognizes crd extension`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.crd", sampleSong("Crd Song"))
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.songCount shouldBe 1
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build ignores non-song files in songs directory`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong("Real Song"))
|
||||
writeSongFile(songsDir, "readme.txt", "Not a song")
|
||||
writeSongFile(songsDir, "notes.md", "# Notes")
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.songCount shouldBe 1
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build output file has correct name`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(outputFilename = "my-book.pdf"))
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.outputFile!!.name shouldBe "my-book.pdf"
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build pageCount includes toc pages`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val result = pipeline.build()
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
// At least 1 content page + TOC pages (minimum 2 for even count)
|
||||
result.pageCount shouldBeGreaterThan 1
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
// --- validate() tests ---
|
||||
|
||||
@Test
|
||||
fun `validate returns error when songbook yaml is missing`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "songbook.yaml not found"
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate returns error when songs directory does not exist`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir, defaultConfig(songsDir = "./nonexistent"))
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors.shouldNotBeEmpty()
|
||||
errors.any { it.message.contains("Songs directory not found") }.shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate returns empty list for valid project`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors.shouldBeEmpty()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate reports config errors`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
val config = """
|
||||
layout:
|
||||
margins:
|
||||
top: 0
|
||||
bottom: 0
|
||||
inner: 0
|
||||
outer: 0
|
||||
""".trimIndent()
|
||||
writeConfig(projectDir, config)
|
||||
// Still need songs dir to exist for full validate
|
||||
File(projectDir, "./songs").mkdirs()
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors shouldHaveSize 4
|
||||
errors.all { it.message.contains("margin", ignoreCase = true) }.shouldBeTrue()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate reports song validation errors`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "bad_song.chopro", "{title: }")
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors.shouldNotBeEmpty()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate reports errors for multiple invalid songs`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
val songsDir = File(projectDir, "songs")
|
||||
writeSongFile(songsDir, "bad1.chopro", "{title: Good Title}") // no sections
|
||||
writeSongFile(songsDir, "bad2.chopro", "{title: Another Title}") // no sections
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
errors.shouldNotBeEmpty()
|
||||
errors.size shouldBeGreaterThan 1
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate with empty songs directory returns no song errors`() {
|
||||
val projectDir = createTempProject()
|
||||
try {
|
||||
writeConfig(projectDir)
|
||||
File(projectDir, "songs").mkdirs()
|
||||
|
||||
val pipeline = SongbookPipeline(projectDir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
// No errors because there are no song files to validate
|
||||
errors.shouldBeEmpty()
|
||||
} finally {
|
||||
projectDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
// --- BuildResult data class tests ---
|
||||
|
||||
@Test
|
||||
fun `BuildResult defaults are correct`() {
|
||||
val result = BuildResult(success = false)
|
||||
|
||||
result.success.shouldBeFalse()
|
||||
result.outputFile.shouldBeNull()
|
||||
result.errors.shouldBeEmpty()
|
||||
result.songCount shouldBe 0
|
||||
result.pageCount shouldBe 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BuildResult with all fields set`() {
|
||||
val file = File("/tmp/test.pdf")
|
||||
val errors = listOf(de.pfadfinder.songbook.parser.ValidationError("test", 1, "error"))
|
||||
val result = BuildResult(
|
||||
success = true,
|
||||
outputFile = file,
|
||||
errors = errors,
|
||||
songCount = 5,
|
||||
pageCount = 10
|
||||
)
|
||||
|
||||
result.success.shouldBeTrue()
|
||||
result.outputFile shouldBe file
|
||||
result.errors shouldHaveSize 1
|
||||
result.songCount shouldBe 5
|
||||
result.pageCount shouldBe 10
|
||||
}
|
||||
}
|
||||
4
build.gradle.kts
Normal file
4
build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("org.jetbrains.compose") version "1.7.3" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false
|
||||
}
|
||||
12
buildSrc/build.gradle.kts
Normal file
12
buildSrc/build.gradle.kts
Normal file
@@ -0,0 +1,12 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10")
|
||||
}
|
||||
11
buildSrc/src/main/kotlin/songbook-conventions.gradle.kts
Normal file
11
buildSrc/src/main/kotlin/songbook-conventions.gradle.kts
Normal file
@@ -0,0 +1,11 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
17
cli/build.gradle.kts
Normal file
17
cli/build.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
||||
plugins {
|
||||
id("songbook-conventions")
|
||||
application
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("de.pfadfinder.songbook.cli.MainKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":app"))
|
||||
implementation(project(":model"))
|
||||
implementation(project(":parser"))
|
||||
implementation("com.github.ajalt.clikt:clikt:5.0.3")
|
||||
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||
implementation("ch.qos.logback:logback-classic:1.5.16")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.pfadfinder.songbook.cli
|
||||
|
||||
import com.github.ajalt.clikt.core.CliktCommand
|
||||
import com.github.ajalt.clikt.core.Context
|
||||
import com.github.ajalt.clikt.core.ProgramResult
|
||||
import com.github.ajalt.clikt.parameters.options.default
|
||||
import com.github.ajalt.clikt.parameters.options.option
|
||||
import de.pfadfinder.songbook.app.SongbookPipeline
|
||||
import java.io.File
|
||||
|
||||
class BuildCommand : CliktCommand(name = "build") {
|
||||
override fun help(context: Context) = "Build the songbook PDF"
|
||||
|
||||
private val projectDir by option("-d", "--dir", help = "Project directory").default(".")
|
||||
|
||||
override fun run() {
|
||||
val dir = File(projectDir).absoluteFile
|
||||
echo("Building songbook from: ${dir.path}")
|
||||
|
||||
val pipeline = SongbookPipeline(dir)
|
||||
val result = pipeline.build()
|
||||
|
||||
if (result.success) {
|
||||
echo("Build successful!")
|
||||
echo(" Songs: ${result.songCount}")
|
||||
echo(" Pages: ${result.pageCount}")
|
||||
echo(" Output: ${result.outputFile?.absolutePath}")
|
||||
} else {
|
||||
echo("Build failed with ${result.errors.size} error(s):", err = true)
|
||||
for (error in result.errors) {
|
||||
val location = listOfNotNull(error.file, error.line?.toString()).joinToString(":")
|
||||
echo(" [$location] ${error.message}", err = true)
|
||||
}
|
||||
throw ProgramResult(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
cli/src/main/kotlin/de/pfadfinder/songbook/cli/Main.kt
Normal file
15
cli/src/main/kotlin/de/pfadfinder/songbook/cli/Main.kt
Normal file
@@ -0,0 +1,15 @@
|
||||
package de.pfadfinder.songbook.cli
|
||||
|
||||
import com.github.ajalt.clikt.core.CliktCommand
|
||||
import com.github.ajalt.clikt.core.main
|
||||
import com.github.ajalt.clikt.core.subcommands
|
||||
|
||||
class SongbookCli : CliktCommand(name = "songbook") {
|
||||
override fun run() = Unit
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
SongbookCli()
|
||||
.subcommands(BuildCommand(), ValidateCommand())
|
||||
.main(args)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.pfadfinder.songbook.cli
|
||||
|
||||
import com.github.ajalt.clikt.core.CliktCommand
|
||||
import com.github.ajalt.clikt.core.Context
|
||||
import com.github.ajalt.clikt.core.ProgramResult
|
||||
import com.github.ajalt.clikt.parameters.options.default
|
||||
import com.github.ajalt.clikt.parameters.options.option
|
||||
import de.pfadfinder.songbook.app.SongbookPipeline
|
||||
import java.io.File
|
||||
|
||||
class ValidateCommand : CliktCommand(name = "validate") {
|
||||
override fun help(context: Context) = "Validate all song files"
|
||||
|
||||
private val projectDir by option("-d", "--dir", help = "Project directory").default(".")
|
||||
|
||||
override fun run() {
|
||||
val dir = File(projectDir).absoluteFile
|
||||
echo("Validating songbook in: ${dir.path}")
|
||||
|
||||
val pipeline = SongbookPipeline(dir)
|
||||
val errors = pipeline.validate()
|
||||
|
||||
if (errors.isEmpty()) {
|
||||
echo("All songs are valid!")
|
||||
} else {
|
||||
echo("Found ${errors.size} error(s):", err = true)
|
||||
for (error in errors) {
|
||||
val location = listOfNotNull(error.file, error.line?.toString()).joinToString(":")
|
||||
echo(" [$location] ${error.message}", err = true)
|
||||
}
|
||||
throw ProgramResult(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
1
gradle.properties
Normal file
1
gradle.properties
Normal file
@@ -0,0 +1 @@
|
||||
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
|
||||
20
gui/build.gradle.kts
Normal file
20
gui/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
id("songbook-conventions")
|
||||
id("org.jetbrains.compose")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":app"))
|
||||
implementation(project(":model"))
|
||||
implementation(project(":parser"))
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||
implementation("ch.qos.logback:logback-classic:1.5.16")
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "de.pfadfinder.songbook.gui.AppKt"
|
||||
}
|
||||
}
|
||||
347
gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt
Normal file
347
gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt
Normal file
@@ -0,0 +1,347 @@
|
||||
package de.pfadfinder.songbook.gui
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import de.pfadfinder.songbook.app.BuildResult
|
||||
import de.pfadfinder.songbook.app.SongbookPipeline
|
||||
import de.pfadfinder.songbook.parser.ChordProParser
|
||||
import de.pfadfinder.songbook.parser.ValidationError
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.Desktop
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Songbook Builder"
|
||||
) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
|
||||
data class SongEntry(val fileName: String, val title: String)
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
var projectPath by remember { mutableStateOf("") }
|
||||
var songs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
|
||||
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
||||
var isRunning by remember { mutableStateOf(false) }
|
||||
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun loadSongs(path: String) {
|
||||
val projectDir = File(path)
|
||||
songs = emptyList()
|
||||
if (!projectDir.isDirectory) return
|
||||
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
val songsDir = if (configFile.exists()) {
|
||||
try {
|
||||
val config = de.pfadfinder.songbook.parser.ConfigParser.parse(configFile)
|
||||
File(projectDir, config.songs.directory)
|
||||
} catch (_: Exception) {
|
||||
File(projectDir, "songs")
|
||||
}
|
||||
} else {
|
||||
File(projectDir, "songs")
|
||||
}
|
||||
|
||||
if (!songsDir.isDirectory) return
|
||||
|
||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
songs = songFiles.mapNotNull { file ->
|
||||
try {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
|
||||
} catch (_: Exception) {
|
||||
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Project directory selection
|
||||
Text(
|
||||
text = "Songbook Builder",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Text("Projektverzeichnis:", fontWeight = FontWeight.Medium)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = projectPath,
|
||||
onValueChange = {
|
||||
projectPath = it
|
||||
loadSongs(it)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
placeholder = { Text("Pfad zum Projektverzeichnis...") }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val chooser = JFileChooser().apply {
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
dialogTitle = "Projektverzeichnis auswählen"
|
||||
if (projectPath.isNotBlank()) {
|
||||
currentDirectory = File(projectPath)
|
||||
}
|
||||
}
|
||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||
projectPath = chooser.selectedFile.absolutePath
|
||||
loadSongs(projectPath)
|
||||
}
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text("Durchsuchen...")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Song list
|
||||
Text(
|
||||
text = "Lieder (${songs.size}):",
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
||||
) {
|
||||
if (songs.isEmpty() && projectPath.isNotBlank()) {
|
||||
item {
|
||||
Text(
|
||||
"Keine Lieder gefunden. Bitte Projektverzeichnis prüfen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else if (projectPath.isBlank()) {
|
||||
item {
|
||||
Text(
|
||||
"Bitte ein Projektverzeichnis auswählen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(songs) { song ->
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp, horizontal = 8.dp)) {
|
||||
Text(song.title, modifier = Modifier.weight(1f))
|
||||
Text(song.fileName, color = Color.Gray, fontSize = 12.sp)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
adapter = rememberScrollbarAdapter(listState)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Action buttons
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (projectPath.isBlank()) return@Button
|
||||
isRunning = true
|
||||
lastBuildResult = null
|
||||
statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO))
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
SongbookPipeline(File(projectPath)).build()
|
||||
} catch (e: Exception) {
|
||||
BuildResult(
|
||||
success = false,
|
||||
errors = listOf(
|
||||
ValidationError(null, null, "Unerwarteter Fehler: ${e.message}")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
lastBuildResult = result
|
||||
statusMessages = if (result.success) {
|
||||
listOf(
|
||||
StatusMessage(
|
||||
"Buch erfolgreich erstellt! ${result.songCount} Lieder, ${result.pageCount} Seiten.",
|
||||
MessageType.SUCCESS
|
||||
),
|
||||
StatusMessage(
|
||||
"Ausgabedatei: ${result.outputFile?.absolutePath ?: "unbekannt"}",
|
||||
MessageType.INFO
|
||||
)
|
||||
)
|
||||
} else {
|
||||
result.errors.map { error ->
|
||||
val location = buildString {
|
||||
if (error.file != null) append(error.file)
|
||||
if (error.line != null) append(":${error.line}")
|
||||
}
|
||||
val prefix = if (location.isNotEmpty()) "[$location] " else ""
|
||||
StatusMessage("$prefix${error.message}", MessageType.ERROR)
|
||||
}
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && projectPath.isNotBlank()
|
||||
) {
|
||||
Text("Buch erstellen")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (projectPath.isBlank()) return@Button
|
||||
isRunning = true
|
||||
lastBuildResult = null
|
||||
statusMessages = listOf(StatusMessage("Validierung läuft...", MessageType.INFO))
|
||||
scope.launch {
|
||||
val errors = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
SongbookPipeline(File(projectPath)).validate()
|
||||
} catch (e: Exception) {
|
||||
listOf(
|
||||
ValidationError(null, null, "Unerwarteter Fehler: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
statusMessages = if (errors.isEmpty()) {
|
||||
listOf(StatusMessage("Validierung erfolgreich! Keine Fehler gefunden.", MessageType.SUCCESS))
|
||||
} else {
|
||||
errors.map { error ->
|
||||
val location = buildString {
|
||||
if (error.file != null) append(error.file)
|
||||
if (error.line != null) append(":${error.line}")
|
||||
}
|
||||
val prefix = if (location.isNotEmpty()) "[$location] " else ""
|
||||
StatusMessage("$prefix${error.message}", MessageType.ERROR)
|
||||
}
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && projectPath.isNotBlank()
|
||||
) {
|
||||
Text("Validieren")
|
||||
}
|
||||
|
||||
if (lastBuildResult?.success == true && lastBuildResult?.outputFile != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
lastBuildResult?.outputFile?.let { file ->
|
||||
try {
|
||||
Desktop.getDesktop().open(file)
|
||||
} catch (e: Exception) {
|
||||
statusMessages = statusMessages + StatusMessage(
|
||||
"PDF konnte nicht geöffnet werden: ${e.message}",
|
||||
MessageType.ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text("PDF öffnen")
|
||||
}
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp).align(Alignment.CenterVertically),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Status/log area
|
||||
Text("Status:", fontWeight = FontWeight.Medium)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
) {
|
||||
val logListState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = logListState,
|
||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
||||
) {
|
||||
if (statusMessages.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Bereit.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(statusMessages) { msg ->
|
||||
Text(
|
||||
text = msg.text,
|
||||
color = when (msg.type) {
|
||||
MessageType.ERROR -> MaterialTheme.colors.error
|
||||
MessageType.SUCCESS -> Color(0xFF2E7D32)
|
||||
MessageType.INFO -> Color.Unspecified
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
adapter = rememberScrollbarAdapter(logListState)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class MessageType {
|
||||
INFO, SUCCESS, ERROR
|
||||
}
|
||||
|
||||
data class StatusMessage(val text: String, val type: MessageType)
|
||||
10
layout/build.gradle.kts
Normal file
10
layout/build.gradle.kts
Normal file
@@ -0,0 +1,10 @@
|
||||
plugins {
|
||||
id("songbook-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":model"))
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.pfadfinder.songbook.layout
|
||||
|
||||
import java.io.File
|
||||
|
||||
object GapFiller {
|
||||
fun findImages(directory: String): List<String> {
|
||||
val dir = File(directory)
|
||||
if (!dir.exists() || !dir.isDirectory) return emptyList()
|
||||
return dir.listFiles { f ->
|
||||
f.extension.lowercase() in listOf("png", "jpg", "jpeg")
|
||||
}?.map { it.absolutePath }?.sorted() ?: emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.pfadfinder.songbook.layout
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
|
||||
class MeasurementEngine(
|
||||
private val fontMetrics: FontMetrics,
|
||||
private val config: BookConfig
|
||||
) {
|
||||
// A5 content height = 210mm - top margin - bottom margin
|
||||
private val contentHeightMm: Float = 210f - config.layout.margins.top - config.layout.margins.bottom
|
||||
|
||||
fun measure(song: Song): MeasuredSong {
|
||||
var heightMm = 0f
|
||||
|
||||
// Title height
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
|
||||
|
||||
// Metadata line (composer/lyricist)
|
||||
if (song.composer != null || song.lyricist != null) {
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
|
||||
}
|
||||
|
||||
// Key/capo line
|
||||
if (song.key != null || song.capo != null) {
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
|
||||
}
|
||||
|
||||
// Gap before sections
|
||||
heightMm += 1.5f // ~4pt in mm
|
||||
|
||||
// Sections
|
||||
for (section in song.sections) {
|
||||
// Section label
|
||||
if (section.label != null || section.type == SectionType.CHORUS) {
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
|
||||
}
|
||||
|
||||
// Chorus repeat reference (no lines)
|
||||
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
|
||||
continue
|
||||
}
|
||||
|
||||
// Lines in section
|
||||
for (line in section.lines) {
|
||||
val hasChords = line.segments.any { it.chord != null }
|
||||
val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size)
|
||||
if (hasChords) {
|
||||
val chordHeight = fontMetrics.measureLineHeight(config.fonts.chords, config.fonts.chords.size)
|
||||
heightMm += chordHeight + config.layout.chordLineSpacing + lyricHeight
|
||||
} else {
|
||||
heightMm += lyricHeight
|
||||
}
|
||||
heightMm += 0.35f // ~1pt gap between lines
|
||||
}
|
||||
|
||||
// Verse spacing
|
||||
heightMm += config.layout.verseSpacing
|
||||
}
|
||||
|
||||
// Notes at bottom
|
||||
if (song.notes.isNotEmpty()) {
|
||||
heightMm += 1.5f // gap
|
||||
for (note in song.notes) {
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
|
||||
}
|
||||
}
|
||||
|
||||
val pageCount = if (heightMm <= contentHeightMm) 1 else 2
|
||||
return MeasuredSong(song, heightMm, pageCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.pfadfinder.songbook.layout
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import java.io.File
|
||||
|
||||
class PaginationEngine(private val config: BookConfig) {
|
||||
|
||||
fun paginate(measuredSongs: List<MeasuredSong>, tocPages: Int): List<PageContent> {
|
||||
val pages = mutableListOf<PageContent>()
|
||||
// Current page number (1-based, after TOC)
|
||||
// TOC occupies pages 1..tocPages
|
||||
// Content starts at page tocPages + 1
|
||||
var currentPage = tocPages + 1
|
||||
|
||||
// Collect available filler images
|
||||
val imageDir = File(config.images.directory)
|
||||
val images = if (imageDir.exists() && imageDir.isDirectory) {
|
||||
imageDir.listFiles { f -> f.extension.lowercase() in listOf("png", "jpg", "jpeg", "svg") }
|
||||
?.map { it.absolutePath }
|
||||
?.shuffled()
|
||||
?.toMutableList()
|
||||
?: mutableListOf()
|
||||
} else {
|
||||
mutableListOf()
|
||||
}
|
||||
var imageIndex = 0
|
||||
|
||||
for (ms in measuredSongs) {
|
||||
if (ms.pageCount == 1) {
|
||||
pages.add(PageContent.SongPage(ms.song, 0))
|
||||
currentPage++
|
||||
} else {
|
||||
// 2-page song: must start on left page (even page number)
|
||||
val isLeftPage = currentPage % 2 == 0
|
||||
if (!isLeftPage) {
|
||||
// Insert filler on the right page
|
||||
if (images.isNotEmpty()) {
|
||||
pages.add(PageContent.FillerImage(images[imageIndex % images.size]))
|
||||
imageIndex++
|
||||
} else {
|
||||
pages.add(PageContent.BlankPage)
|
||||
}
|
||||
currentPage++
|
||||
}
|
||||
pages.add(PageContent.SongPage(ms.song, 0))
|
||||
pages.add(PageContent.SongPage(ms.song, 1))
|
||||
currentPage += 2
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.pfadfinder.songbook.layout
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
|
||||
class TocGenerator(private val config: BookConfig) {
|
||||
|
||||
fun generate(pages: List<PageContent>, tocStartPage: Int): List<TocEntry> {
|
||||
val entries = mutableListOf<TocEntry>()
|
||||
val refAbbreviations = config.referenceBooks.associate { it.id to it.abbreviation }
|
||||
|
||||
// Map songs to their page numbers
|
||||
val songPages = mutableMapOf<String, Int>() // song title -> first page number
|
||||
var currentPageNum = tocStartPage
|
||||
for (page in pages) {
|
||||
currentPageNum++
|
||||
if (page is PageContent.SongPage && page.pageIndex == 0) {
|
||||
songPages[page.song.title] = currentPageNum
|
||||
}
|
||||
}
|
||||
|
||||
// Create entries for each song
|
||||
for ((title, pageNumber) in songPages) {
|
||||
// Find the song to get aliases and references
|
||||
val song = pages.filterIsInstance<PageContent.SongPage>()
|
||||
.find { it.song.title == title && it.pageIndex == 0 }?.song
|
||||
?: continue
|
||||
|
||||
// Map references from book IDs to abbreviations
|
||||
val refs = song.references.mapKeys { (bookId, _) ->
|
||||
refAbbreviations[bookId] ?: bookId
|
||||
}
|
||||
|
||||
entries.add(TocEntry(title = title, pageNumber = pageNumber, references = refs))
|
||||
|
||||
// Add alias entries
|
||||
for (alias in song.aliases) {
|
||||
entries.add(TocEntry(title = alias, pageNumber = pageNumber, isAlias = true, references = refs))
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sortedBy { it.title.lowercase() }
|
||||
}
|
||||
|
||||
fun estimateTocPages(songs: List<Song>): Int {
|
||||
// Rough estimate: count total titles + aliases
|
||||
val totalEntries = songs.sumOf { 1 + it.aliases.size }
|
||||
// Assume ~40 entries per A5 page
|
||||
val pages = (totalEntries / 40) + 1
|
||||
// TOC should be even number of pages (for double-sided printing)
|
||||
return if (pages % 2 == 0) pages else pages + 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.pfadfinder.songbook.layout
|
||||
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlin.test.Test
|
||||
|
||||
class GapFillerTest {
|
||||
|
||||
@Test
|
||||
fun `findImages returns empty for nonexistent directory`() {
|
||||
val images = GapFiller.findImages("/nonexistent/path/to/images")
|
||||
images.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findImages returns empty for empty directory`() {
|
||||
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-empty").toFile()
|
||||
try {
|
||||
val images = GapFiller.findImages(tempDir.absolutePath)
|
||||
images.shouldBeEmpty()
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findImages returns image files sorted`() {
|
||||
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-images").toFile()
|
||||
try {
|
||||
java.io.File(tempDir, "c_image.png").writeText("fake")
|
||||
java.io.File(tempDir, "a_image.jpg").writeText("fake")
|
||||
java.io.File(tempDir, "b_image.jpeg").writeText("fake")
|
||||
|
||||
val images = GapFiller.findImages(tempDir.absolutePath)
|
||||
|
||||
images shouldHaveSize 3
|
||||
// Should be sorted by absolute path (which means sorted by filename here)
|
||||
images[0] shouldBe java.io.File(tempDir, "a_image.jpg").absolutePath
|
||||
images[1] shouldBe java.io.File(tempDir, "b_image.jpeg").absolutePath
|
||||
images[2] shouldBe java.io.File(tempDir, "c_image.png").absolutePath
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findImages ignores non-image files`() {
|
||||
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-nonimage").toFile()
|
||||
try {
|
||||
java.io.File(tempDir, "image.png").writeText("fake")
|
||||
java.io.File(tempDir, "document.txt").writeText("fake")
|
||||
java.io.File(tempDir, "data.json").writeText("fake")
|
||||
java.io.File(tempDir, "photo.jpg").writeText("fake")
|
||||
|
||||
val images = GapFiller.findImages(tempDir.absolutePath)
|
||||
|
||||
images shouldHaveSize 2
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findImages returns empty when directory is a file`() {
|
||||
val tempFile = kotlin.io.path.createTempFile("songbook-test-file").toFile()
|
||||
try {
|
||||
val images = GapFiller.findImages(tempFile.absolutePath)
|
||||
images.shouldBeEmpty()
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package de.pfadfinder.songbook.layout
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import io.kotest.matchers.floats.shouldBeGreaterThan
|
||||
import io.kotest.matchers.floats.shouldBeLessThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlin.test.Test
|
||||
|
||||
class MeasurementEngineTest {
|
||||
|
||||
private val fontMetrics = StubFontMetrics()
|
||||
private val config = BookConfig()
|
||||
private val engine = MeasurementEngine(fontMetrics, config)
|
||||
|
||||
// Content height = 210 - 15 (top) - 15 (bottom) = 180mm
|
||||
private val contentHeight = 210f - config.layout.margins.top - config.layout.margins.bottom
|
||||
|
||||
@Test
|
||||
fun `simple song with one verse and no chords fits on one page`() {
|
||||
val song = Song(
|
||||
title = "Simple Song",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
label = "Verse 1",
|
||||
lines = listOf(
|
||||
SongLine(listOf(LineSegment(text = "This is a simple line"))),
|
||||
SongLine(listOf(LineSegment(text = "Another simple line")))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = engine.measure(song)
|
||||
|
||||
result.pageCount shouldBe 1
|
||||
result.song shouldBe song
|
||||
result.totalHeightMm shouldBeGreaterThan 0f
|
||||
result.totalHeightMm shouldBeLessThan contentHeight
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `song with many sections exceeds one page`() {
|
||||
// Create a song with many sections to exceed content height
|
||||
val sections = (1..30).map { i ->
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
label = "Verse $i",
|
||||
lines = (1..5).map {
|
||||
SongLine(
|
||||
listOf(
|
||||
LineSegment(chord = "Am", text = "Some "),
|
||||
LineSegment(chord = "G", text = "text with chords")
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
val song = Song(title = "Long Song", sections = sections)
|
||||
|
||||
val result = engine.measure(song)
|
||||
|
||||
result.pageCount shouldBe 2
|
||||
result.totalHeightMm shouldBeGreaterThan contentHeight
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `font metrics is used for title measurement`() {
|
||||
val song = Song(title = "Title Only")
|
||||
val result = engine.measure(song)
|
||||
|
||||
// Title contributes: measureLineHeight(title font, 14f) * 1.5
|
||||
val expectedTitleHeight = fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
|
||||
// Plus gap before sections
|
||||
val expectedMinHeight = expectedTitleHeight + 1.5f
|
||||
|
||||
result.totalHeightMm shouldBeGreaterThan (expectedMinHeight - 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `composer and lyricist add metadata height`() {
|
||||
val songWithoutMeta = Song(title = "No Meta")
|
||||
val songWithMeta = Song(title = "With Meta", composer = "Bach", lyricist = "Goethe")
|
||||
|
||||
val heightWithout = engine.measure(songWithoutMeta).totalHeightMm
|
||||
val heightWith = engine.measure(songWithMeta).totalHeightMm
|
||||
|
||||
val metadataLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
|
||||
heightWith shouldBeGreaterThan heightWithout
|
||||
// The difference should be approximately the metadata line height
|
||||
val diff = heightWith - heightWithout
|
||||
diff shouldBeGreaterThan (metadataLineHeight - 0.01f)
|
||||
diff shouldBeLessThan (metadataLineHeight + 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `key and capo add metadata height`() {
|
||||
val songWithoutKeyCap = Song(title = "No Key")
|
||||
val songWithKey = Song(title = "With Key", key = "Am")
|
||||
|
||||
val heightWithout = engine.measure(songWithoutKeyCap).totalHeightMm
|
||||
val heightWith = engine.measure(songWithKey).totalHeightMm
|
||||
|
||||
heightWith shouldBeGreaterThan heightWithout
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capo alone adds metadata height`() {
|
||||
val songWithout = Song(title = "No Capo")
|
||||
val songWith = Song(title = "With Capo", capo = 2)
|
||||
|
||||
val heightWithout = engine.measure(songWithout).totalHeightMm
|
||||
val heightWith = engine.measure(songWith).totalHeightMm
|
||||
|
||||
heightWith shouldBeGreaterThan heightWithout
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chords add extra height compared to lyrics only`() {
|
||||
val songWithoutChords = Song(
|
||||
title = "No Chords",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(SongLine(listOf(LineSegment(text = "Just lyrics"))))
|
||||
)
|
||||
)
|
||||
)
|
||||
val songWithChords = Song(
|
||||
title = "With Chords",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(SongLine(listOf(LineSegment(chord = "Am", text = "With chords"))))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val heightWithout = engine.measure(songWithoutChords).totalHeightMm
|
||||
val heightWith = engine.measure(songWithChords).totalHeightMm
|
||||
|
||||
heightWith shouldBeGreaterThan heightWithout
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chorus section label adds height`() {
|
||||
val songWithChorus = Song(
|
||||
title = "Chorus Song",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.CHORUS,
|
||||
lines = listOf(SongLine(listOf(LineSegment(text = "Chorus line"))))
|
||||
)
|
||||
)
|
||||
)
|
||||
val songWithVerse = Song(
|
||||
title = "Verse Song",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
// No label, type is VERSE - no label height added
|
||||
lines = listOf(SongLine(listOf(LineSegment(text = "Verse line"))))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val chorusHeight = engine.measure(songWithChorus).totalHeightMm
|
||||
val verseHeight = engine.measure(songWithVerse).totalHeightMm
|
||||
|
||||
// Chorus always gets a section label, verse without label does not
|
||||
chorusHeight shouldBeGreaterThan verseHeight
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty chorus repeat reference adds height without lines`() {
|
||||
val song = Song(
|
||||
title = "Repeat Song",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.CHORUS,
|
||||
lines = emptyList() // chorus repeat reference
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = engine.measure(song)
|
||||
// Should have title + gap + chorus label height + chorus repeat height + verse spacing
|
||||
result.totalHeightMm shouldBeGreaterThan 0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notes add height at bottom`() {
|
||||
val songWithout = Song(title = "No Notes")
|
||||
val songWith = Song(title = "With Notes", notes = listOf("Note 1", "Note 2"))
|
||||
|
||||
val heightWithout = engine.measure(songWithout).totalHeightMm
|
||||
val heightWith = engine.measure(songWith).totalHeightMm
|
||||
|
||||
heightWith shouldBeGreaterThan heightWithout
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verse spacing is added per section`() {
|
||||
val oneSectionSong = Song(
|
||||
title = "One Section",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||
)
|
||||
)
|
||||
)
|
||||
val twoSectionSong = Song(
|
||||
title = "Two Sections",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||
),
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val oneHeight = engine.measure(oneSectionSong).totalHeightMm
|
||||
val twoHeight = engine.measure(twoSectionSong).totalHeightMm
|
||||
|
||||
twoHeight shouldBeGreaterThan oneHeight
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `section with label adds label height`() {
|
||||
val songWithLabel = Song(
|
||||
title = "Labeled",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
label = "Verse 1",
|
||||
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||
)
|
||||
)
|
||||
)
|
||||
val songWithoutLabel = Song(
|
||||
title = "Unlabeled",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
label = null,
|
||||
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val labeledHeight = engine.measure(songWithLabel).totalHeightMm
|
||||
val unlabeledHeight = engine.measure(songWithoutLabel).totalHeightMm
|
||||
|
||||
labeledHeight shouldBeGreaterThan unlabeledHeight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package de.pfadfinder.songbook.layout
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import kotlin.test.Test
|
||||
|
||||
class PaginationEngineTest {
|
||||
|
||||
private val config = BookConfig(images = ImagesConfig(directory = "/nonexistent/images"))
|
||||
private val engine = PaginationEngine(config)
|
||||
|
||||
private fun song(title: String) = Song(title = title)
|
||||
|
||||
private fun onePage(song: Song) = MeasuredSong(song, 100f, 1)
|
||||
private fun twoPage(song: Song) = MeasuredSong(song, 200f, 2)
|
||||
|
||||
@Test
|
||||
fun `single page songs are placed sequentially`() {
|
||||
val songs = listOf(
|
||||
onePage(song("Song A")),
|
||||
onePage(song("Song B")),
|
||||
onePage(song("Song C"))
|
||||
)
|
||||
|
||||
val pages = engine.paginate(songs, tocPages = 2)
|
||||
|
||||
pages shouldHaveSize 3
|
||||
pages.forEach { it.shouldBeInstanceOf<PageContent.SongPage>() }
|
||||
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
|
||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
|
||||
(pages[2] as PageContent.SongPage).song.title shouldBe "Song C"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single page songs all have pageIndex 0`() {
|
||||
val songs = listOf(
|
||||
onePage(song("Song A")),
|
||||
onePage(song("Song B"))
|
||||
)
|
||||
|
||||
val pages = engine.paginate(songs, tocPages = 2)
|
||||
|
||||
pages.forEach {
|
||||
(it as PageContent.SongPage).pageIndex shouldBe 0
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page song starting on left page has no filler`() {
|
||||
// tocPages = 2, so content starts at page 3 (odd/right page)
|
||||
// First one-page song occupies page 3, next page is 4 (even/left)
|
||||
val songs = listOf(
|
||||
onePage(song("Song A")),
|
||||
twoPage(song("Song B"))
|
||||
)
|
||||
|
||||
val pages = engine.paginate(songs, tocPages = 2)
|
||||
|
||||
// Song A at page 3, Song B starts at page 4 (even = left)
|
||||
pages shouldHaveSize 3
|
||||
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
|
||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
|
||||
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
|
||||
(pages[2] as PageContent.SongPage).song.title shouldBe "Song B"
|
||||
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page song on odd page gets blank filler before it`() {
|
||||
// tocPages = 2, content starts at page 3 (odd/right)
|
||||
// First 2-page song needs to start on even page, so filler at page 3
|
||||
val songs = listOf(
|
||||
twoPage(song("Song A"))
|
||||
)
|
||||
|
||||
val pages = engine.paginate(songs, tocPages = 2)
|
||||
|
||||
// Blank at page 3, Song A at pages 4-5
|
||||
pages shouldHaveSize 3
|
||||
pages[0].shouldBeInstanceOf<PageContent.BlankPage>()
|
||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
|
||||
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
|
||||
(pages[2] as PageContent.SongPage).song.title shouldBe "Song A"
|
||||
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page song after two single page songs does not need filler`() {
|
||||
// tocPages = 2, content starts at page 3
|
||||
// Song A at page 3, Song B at page 4, Song C (2-page) should start at page 5 (odd)
|
||||
// Page 5 is odd, so it needs filler
|
||||
val songs = listOf(
|
||||
onePage(song("Song A")),
|
||||
onePage(song("Song B")),
|
||||
twoPage(song("Song C"))
|
||||
)
|
||||
|
||||
val pages = engine.paginate(songs, tocPages = 2)
|
||||
|
||||
// Song A at 3, Song B at 4, filler at 5, Song C at 6-7
|
||||
pages shouldHaveSize 5
|
||||
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
|
||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
|
||||
pages[2].shouldBeInstanceOf<PageContent.BlankPage>()
|
||||
(pages[3] as PageContent.SongPage).song.title shouldBe "Song C"
|
||||
(pages[4] as PageContent.SongPage).song.title shouldBe "Song C"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two consecutive two-page songs are placed correctly`() {
|
||||
// tocPages = 2, content starts at page 3 (odd)
|
||||
// Song A (2-page): needs even start -> filler at 3, Song A at 4-5
|
||||
// Song B (2-page): next page is 6 (even/left) -> no filler, Song B at 6-7
|
||||
val songs = listOf(
|
||||
twoPage(song("Song A")),
|
||||
twoPage(song("Song B"))
|
||||
)
|
||||
|
||||
val pages = engine.paginate(songs, tocPages = 2)
|
||||
|
||||
pages shouldHaveSize 5
|
||||
pages[0].shouldBeInstanceOf<PageContent.BlankPage>()
|
||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
|
||||
(pages[2] as PageContent.SongPage).song.title shouldBe "Song A"
|
||||
(pages[3] as PageContent.SongPage).song.title shouldBe "Song B"
|
||||
(pages[4] as PageContent.SongPage).song.title shouldBe "Song B"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty input produces empty output`() {
|
||||
val pages = engine.paginate(emptyList(), tocPages = 2)
|
||||
pages shouldHaveSize 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tocPages affects page numbering for alignment`() {
|
||||
// tocPages = 3, content starts at page 4 (even/left)
|
||||
// 2-page song should start directly on page 4 (even) - no filler needed
|
||||
val songs = listOf(
|
||||
twoPage(song("Song A"))
|
||||
)
|
||||
|
||||
val pages = engine.paginate(songs, tocPages = 3)
|
||||
|
||||
// Page 4 is even -> no filler needed
|
||||
pages shouldHaveSize 2
|
||||
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
|
||||
(pages[0] as PageContent.SongPage).pageIndex shouldBe 0
|
||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
|
||||
(pages[1] as PageContent.SongPage).pageIndex shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filler uses image when images directory exists`() {
|
||||
// Create a temp directory with an image file
|
||||
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-images").toFile()
|
||||
try {
|
||||
val imageFile = java.io.File(tempDir, "filler.png")
|
||||
imageFile.writeText("fake image")
|
||||
|
||||
val configWithImages = BookConfig(images = ImagesConfig(directory = tempDir.absolutePath))
|
||||
val engineWithImages = PaginationEngine(configWithImages)
|
||||
|
||||
val songs = listOf(twoPage(song("Song A")))
|
||||
val pages = engineWithImages.paginate(songs, tocPages = 2)
|
||||
|
||||
// tocPages=2, start at page 3 (odd), needs filler
|
||||
pages shouldHaveSize 3
|
||||
val filler = pages[0]
|
||||
filler.shouldBeInstanceOf<PageContent.FillerImage>()
|
||||
(filler as PageContent.FillerImage).imagePath shouldBe imageFile.absolutePath
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mixed single and two-page songs layout correctly`() {
|
||||
// tocPages = 4, content starts at page 5 (odd)
|
||||
val songs = listOf(
|
||||
onePage(song("Song A")), // page 5
|
||||
twoPage(song("Song B")), // starts page 6 (even) - no filler
|
||||
onePage(song("Song C")), // page 8
|
||||
onePage(song("Song D")), // page 9
|
||||
twoPage(song("Song E")) // starts page 10 (even) - no filler
|
||||
)
|
||||
|
||||
val pages = engine.paginate(songs, tocPages = 4)
|
||||
|
||||
pages shouldHaveSize 7
|
||||
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
|
||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
|
||||
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
|
||||
(pages[2] as PageContent.SongPage).song.title shouldBe "Song B"
|
||||
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
|
||||
(pages[3] as PageContent.SongPage).song.title shouldBe "Song C"
|
||||
(pages[4] as PageContent.SongPage).song.title shouldBe "Song D"
|
||||
(pages[5] as PageContent.SongPage).song.title shouldBe "Song E"
|
||||
(pages[5] as PageContent.SongPage).pageIndex shouldBe 0
|
||||
(pages[6] as PageContent.SongPage).song.title shouldBe "Song E"
|
||||
(pages[6] as PageContent.SongPage).pageIndex shouldBe 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.pfadfinder.songbook.layout
|
||||
|
||||
import de.pfadfinder.songbook.model.FontMetrics
|
||||
import de.pfadfinder.songbook.model.FontSpec
|
||||
|
||||
class StubFontMetrics : FontMetrics {
|
||||
override fun measureTextWidth(text: String, font: FontSpec, size: Float): Float =
|
||||
text.length * size * 0.5f * 0.3528f
|
||||
|
||||
override fun measureLineHeight(font: FontSpec, size: Float): Float =
|
||||
size * 1.2f * 0.3528f
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package de.pfadfinder.songbook.layout
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlin.test.Test
|
||||
|
||||
class TocGeneratorTest {
|
||||
|
||||
private val config = BookConfig(
|
||||
referenceBooks = listOf(
|
||||
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO"),
|
||||
ReferenceBook(id = "kljb", name = "KLJB Liederbuch", abbreviation = "KLJB")
|
||||
)
|
||||
)
|
||||
private val generator = TocGenerator(config)
|
||||
|
||||
@Test
|
||||
fun `generate creates entries for songs sorted alphabetically`() {
|
||||
val pages = listOf(
|
||||
PageContent.SongPage(Song(title = "Zebra Song"), 0),
|
||||
PageContent.SongPage(Song(title = "Alpha Song"), 0),
|
||||
PageContent.SongPage(Song(title = "Middle Song"), 0)
|
||||
)
|
||||
|
||||
val entries = generator.generate(pages, tocStartPage = 0)
|
||||
|
||||
entries shouldHaveSize 3
|
||||
entries[0].title shouldBe "Alpha Song"
|
||||
entries[1].title shouldBe "Middle Song"
|
||||
entries[2].title shouldBe "Zebra Song"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate assigns correct page numbers`() {
|
||||
val pages = listOf(
|
||||
PageContent.SongPage(Song(title = "Song A"), 0), // page 1
|
||||
PageContent.SongPage(Song(title = "Song B"), 0), // page 2
|
||||
PageContent.SongPage(Song(title = "Song C"), 0) // page 3
|
||||
)
|
||||
|
||||
val entries = generator.generate(pages, tocStartPage = 0)
|
||||
|
||||
entries.find { it.title == "Song A" }!!.pageNumber shouldBe 1
|
||||
entries.find { it.title == "Song B" }!!.pageNumber shouldBe 2
|
||||
entries.find { it.title == "Song C" }!!.pageNumber shouldBe 3
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate with tocStartPage offsets page numbers`() {
|
||||
val pages = listOf(
|
||||
PageContent.SongPage(Song(title = "Song A"), 0)
|
||||
)
|
||||
|
||||
val entries = generator.generate(pages, tocStartPage = 4)
|
||||
|
||||
entries[0].pageNumber shouldBe 5
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate creates alias entries`() {
|
||||
val song = Song(title = "Original Title", aliases = listOf("Alias One", "Alias Two"))
|
||||
val pages = listOf(
|
||||
PageContent.SongPage(song, 0)
|
||||
)
|
||||
|
||||
val entries = generator.generate(pages, tocStartPage = 0)
|
||||
|
||||
entries shouldHaveSize 3
|
||||
// Sorted: Alias One, Alias Two, Original Title
|
||||
entries[0].title shouldBe "Alias One"
|
||||
entries[0].isAlias shouldBe true
|
||||
entries[0].pageNumber shouldBe 1
|
||||
entries[1].title shouldBe "Alias Two"
|
||||
entries[1].isAlias shouldBe true
|
||||
entries[1].pageNumber shouldBe 1
|
||||
entries[2].title shouldBe "Original Title"
|
||||
entries[2].isAlias shouldBe false
|
||||
entries[2].pageNumber shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate maps reference book IDs to abbreviations`() {
|
||||
val song = Song(
|
||||
title = "Referenced Song",
|
||||
references = mapOf("mundorgel" to 42, "kljb" to 117)
|
||||
)
|
||||
val pages = listOf(PageContent.SongPage(song, 0))
|
||||
|
||||
val entries = generator.generate(pages, tocStartPage = 0)
|
||||
|
||||
entries shouldHaveSize 1
|
||||
entries[0].references shouldBe mapOf("MO" to 42, "KLJB" to 117)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate keeps unknown reference book IDs as-is`() {
|
||||
val song = Song(
|
||||
title = "Song",
|
||||
references = mapOf("unknown_book" to 5)
|
||||
)
|
||||
val pages = listOf(PageContent.SongPage(song, 0))
|
||||
|
||||
val entries = generator.generate(pages, tocStartPage = 0)
|
||||
|
||||
entries[0].references shouldBe mapOf("unknown_book" to 5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate skips filler and blank pages for page numbering`() {
|
||||
val pages = listOf(
|
||||
PageContent.BlankPage, // page 1
|
||||
PageContent.SongPage(Song(title = "Song A"), 0), // page 2
|
||||
PageContent.FillerImage("/path/to/image.png"), // page 3
|
||||
PageContent.SongPage(Song(title = "Song B"), 0) // page 4
|
||||
)
|
||||
|
||||
val entries = generator.generate(pages, tocStartPage = 0)
|
||||
|
||||
entries shouldHaveSize 2
|
||||
entries.find { it.title == "Song A" }!!.pageNumber shouldBe 2
|
||||
entries.find { it.title == "Song B" }!!.pageNumber shouldBe 4
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate handles two-page songs correctly`() {
|
||||
val song = Song(title = "Long Song")
|
||||
val pages = listOf(
|
||||
PageContent.SongPage(song, 0), // page 1 - first page of song
|
||||
PageContent.SongPage(song, 1) // page 2 - second page of song
|
||||
)
|
||||
|
||||
val entries = generator.generate(pages, tocStartPage = 0)
|
||||
|
||||
// Should only have one entry pointing to the first page
|
||||
entries shouldHaveSize 1
|
||||
entries[0].title shouldBe "Long Song"
|
||||
entries[0].pageNumber shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate aliases share references with original song`() {
|
||||
val song = Song(
|
||||
title = "Main Song",
|
||||
aliases = listOf("Alt Name"),
|
||||
references = mapOf("mundorgel" to 10)
|
||||
)
|
||||
val pages = listOf(PageContent.SongPage(song, 0))
|
||||
|
||||
val entries = generator.generate(pages, tocStartPage = 0)
|
||||
|
||||
entries shouldHaveSize 2
|
||||
val alias = entries.find { it.isAlias }!!
|
||||
alias.references shouldBe mapOf("MO" to 10)
|
||||
val main = entries.find { !it.isAlias }!!
|
||||
main.references shouldBe mapOf("MO" to 10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate with empty pages produces empty entries`() {
|
||||
val entries = generator.generate(emptyList(), tocStartPage = 0)
|
||||
entries.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateTocPages returns even number`() {
|
||||
val songs = (1..10).map { Song(title = "Song $it") }
|
||||
val pages = generator.estimateTocPages(songs)
|
||||
(pages % 2) shouldBe 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateTocPages accounts for aliases`() {
|
||||
val songsWithoutAliases = (1..10).map { Song(title = "Song $it") }
|
||||
val songsWithAliases = (1..10).map { Song(title = "Song $it", aliases = listOf("Alias $it")) }
|
||||
|
||||
val pagesWithout = generator.estimateTocPages(songsWithoutAliases)
|
||||
val pagesWith = generator.estimateTocPages(songsWithAliases)
|
||||
|
||||
pagesWith shouldBe pagesWithout // both under 40 entries, same page count
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateTocPages with many songs returns more pages`() {
|
||||
val fewSongs = (1..10).map { Song(title = "Song $it") }
|
||||
val manySongs = (1..200).map { Song(title = "Song $it") }
|
||||
|
||||
val fewPages = generator.estimateTocPages(fewSongs)
|
||||
val manyPages = generator.estimateTocPages(manySongs)
|
||||
|
||||
// 200 songs / 40 per page = 5 + 1 = 6 pages (already even)
|
||||
manyPages shouldBe 6
|
||||
fewPages shouldBe 2 // (10/40)+1 = 1, rounded up to 2 for even
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate sorts case-insensitively`() {
|
||||
val pages = listOf(
|
||||
PageContent.SongPage(Song(title = "banana"), 0),
|
||||
PageContent.SongPage(Song(title = "Apple"), 0),
|
||||
PageContent.SongPage(Song(title = "cherry"), 0)
|
||||
)
|
||||
|
||||
val entries = generator.generate(pages, tocStartPage = 0)
|
||||
|
||||
entries[0].title shouldBe "Apple"
|
||||
entries[1].title shouldBe "banana"
|
||||
entries[2].title shouldBe "cherry"
|
||||
}
|
||||
}
|
||||
3
model/build.gradle.kts
Normal file
3
model/build.gradle.kts
Normal file
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("songbook-conventions")
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.pfadfinder.songbook.model
|
||||
|
||||
import java.io.OutputStream
|
||||
|
||||
interface BookRenderer {
|
||||
fun render(layout: LayoutResult, config: BookConfig, output: OutputStream)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
26
model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt
Normal file
26
model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt
Normal 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
|
||||
)
|
||||
31
model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt
Normal file
31
model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt
Normal 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
|
||||
)
|
||||
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"
|
||||
}
|
||||
}
|
||||
11
renderer-pdf/build.gradle.kts
Normal file
11
renderer-pdf/build.gradle.kts
Normal file
@@ -0,0 +1,11 @@
|
||||
plugins {
|
||||
id("songbook-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":model"))
|
||||
implementation("com.github.librepdf:openpdf:2.0.3")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import com.lowagie.text.pdf.PdfContentByte
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import java.awt.Color
|
||||
|
||||
class ChordLyricRenderer(
|
||||
private val fontMetrics: PdfFontMetrics,
|
||||
private val config: BookConfig
|
||||
) {
|
||||
// Renders a single SongLine (chord line above + lyric line below)
|
||||
// Returns the total height consumed in PDF points
|
||||
fun renderLine(
|
||||
cb: PdfContentByte,
|
||||
line: SongLine,
|
||||
x: Float, // left x position in points
|
||||
y: Float, // top y position in points (PDF coordinates, y goes up)
|
||||
maxWidth: Float // available width in points
|
||||
): Float {
|
||||
val hasChords = line.segments.any { it.chord != null }
|
||||
val chordFont = fontMetrics.getBaseFontBold(config.fonts.chords)
|
||||
val lyricFont = fontMetrics.getBaseFont(config.fonts.lyrics)
|
||||
val chordSize = config.fonts.chords.size
|
||||
val lyricSize = config.fonts.lyrics.size
|
||||
val chordLineHeight = chordSize * 1.2f
|
||||
val lyricLineHeight = lyricSize * 1.2f
|
||||
val chordLyricGap = config.layout.chordLineSpacing / 0.3528f // mm to points
|
||||
|
||||
var totalHeight = lyricLineHeight
|
||||
if (hasChords) {
|
||||
totalHeight += chordLineHeight + chordLyricGap
|
||||
}
|
||||
|
||||
val chordColor = parseColor(config.fonts.chords.color)
|
||||
|
||||
// Calculate x positions for each segment
|
||||
var currentX = x
|
||||
for (segment in line.segments) {
|
||||
if (hasChords && segment.chord != null) {
|
||||
// Draw chord above
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(chordFont, chordSize)
|
||||
cb.setColorFill(chordColor)
|
||||
cb.setTextMatrix(currentX, y - chordLineHeight)
|
||||
cb.showText(segment.chord)
|
||||
cb.endText()
|
||||
}
|
||||
|
||||
// Draw lyric text
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(lyricFont, lyricSize)
|
||||
cb.setColorFill(Color.BLACK)
|
||||
cb.setTextMatrix(currentX, y - totalHeight)
|
||||
cb.showText(segment.text)
|
||||
cb.endText()
|
||||
|
||||
currentX += lyricFont.getWidthPoint(segment.text, lyricSize)
|
||||
}
|
||||
|
||||
return totalHeight
|
||||
}
|
||||
|
||||
private fun parseColor(hex: String): Color {
|
||||
val clean = hex.removePrefix("#")
|
||||
val r = clean.substring(0, 2).toInt(16)
|
||||
val g = clean.substring(2, 4).toInt(16)
|
||||
val b = clean.substring(4, 6).toInt(16)
|
||||
return Color(r, g, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import com.lowagie.text.pdf.PdfContentByte
|
||||
import de.pfadfinder.songbook.model.BookConfig
|
||||
import java.awt.Color
|
||||
|
||||
class PageDecorator(
|
||||
private val fontMetrics: PdfFontMetrics,
|
||||
private val config: BookConfig
|
||||
) {
|
||||
fun addPageNumber(cb: PdfContentByte, pageNumber: Int, pageWidth: Float, pageHeight: Float) {
|
||||
val font = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val fontSize = config.fonts.metadata.size
|
||||
val text = pageNumber.toString()
|
||||
val textWidth = font.getWidthPoint(text, fontSize)
|
||||
|
||||
val marginBottom = config.layout.margins.bottom / 0.3528f // mm to points
|
||||
val marginOuter = config.layout.margins.outer / 0.3528f
|
||||
|
||||
val y = marginBottom / 2 // center in bottom margin
|
||||
|
||||
// Outer position: even pages -> left, odd pages -> right (for book binding)
|
||||
val isRightPage = pageNumber % 2 == 1
|
||||
val x = if (isRightPage) {
|
||||
pageWidth - marginOuter / 2 - textWidth / 2
|
||||
} else {
|
||||
marginOuter / 2 - textWidth / 2
|
||||
}
|
||||
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(font, fontSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
cb.setTextMatrix(x, y)
|
||||
cb.showText(text)
|
||||
cb.endText()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import com.lowagie.text.*
|
||||
import com.lowagie.text.pdf.*
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import java.awt.Color
|
||||
import java.io.OutputStream
|
||||
|
||||
class PdfBookRenderer : BookRenderer {
|
||||
override fun render(layout: LayoutResult, config: BookConfig, output: OutputStream) {
|
||||
val fontMetrics = PdfFontMetrics()
|
||||
val chordLyricRenderer = ChordLyricRenderer(fontMetrics, config)
|
||||
val tocRenderer = TocRenderer(fontMetrics, config)
|
||||
val pageDecorator = PageDecorator(fontMetrics, config)
|
||||
|
||||
// A5 page size in points: 148mm x 210mm -> 419.53 x 595.28 points
|
||||
val pageSize = if (config.book.format == "A5") PageSize.A5 else PageSize.A4
|
||||
|
||||
val marginInner = config.layout.margins.inner / 0.3528f
|
||||
val marginOuter = config.layout.margins.outer / 0.3528f
|
||||
val marginTop = config.layout.margins.top / 0.3528f
|
||||
val marginBottom = config.layout.margins.bottom / 0.3528f
|
||||
|
||||
// Start with right-page margins (page 1 is right/odd page)
|
||||
val document = Document(pageSize, marginInner, marginOuter, marginTop, marginBottom)
|
||||
val writer = PdfWriter.getInstance(document, output)
|
||||
document.open()
|
||||
|
||||
// Render TOC first
|
||||
if (layout.tocEntries.isNotEmpty()) {
|
||||
tocRenderer.render(document, writer, layout.tocEntries)
|
||||
// Add blank pages to fill TOC allocation
|
||||
repeat(layout.tocPages - 1) {
|
||||
document.newPage()
|
||||
// Force new page even if empty
|
||||
writer.directContent.let { cb ->
|
||||
cb.beginText()
|
||||
cb.endText()
|
||||
}
|
||||
}
|
||||
document.newPage()
|
||||
}
|
||||
|
||||
// Render content pages
|
||||
var currentPageNum = layout.tocPages + 1
|
||||
for (pageContent in layout.pages) {
|
||||
// Swap margins for left/right pages
|
||||
val isRightPage = currentPageNum % 2 == 1
|
||||
if (isRightPage) {
|
||||
document.setMargins(marginInner, marginOuter, marginTop, marginBottom)
|
||||
} else {
|
||||
document.setMargins(marginOuter, marginInner, marginTop, marginBottom)
|
||||
}
|
||||
document.newPage()
|
||||
|
||||
val cb = writer.directContent
|
||||
val contentWidth = pageSize.width - marginInner - marginOuter
|
||||
val contentTop = pageSize.height - marginTop
|
||||
|
||||
when (pageContent) {
|
||||
is PageContent.SongPage -> {
|
||||
val leftMargin = if (isRightPage) marginInner else marginOuter
|
||||
renderSongPage(
|
||||
cb, chordLyricRenderer, fontMetrics, config,
|
||||
pageContent.song, pageContent.pageIndex,
|
||||
contentTop, leftMargin, contentWidth
|
||||
)
|
||||
}
|
||||
is PageContent.FillerImage -> {
|
||||
renderFillerImage(document, pageContent.imagePath, pageSize)
|
||||
}
|
||||
is PageContent.BlankPage -> {
|
||||
// Empty page - just add invisible content to force page creation
|
||||
cb.beginText()
|
||||
cb.endText()
|
||||
}
|
||||
}
|
||||
|
||||
pageDecorator.addPageNumber(cb, currentPageNum, pageSize.width, pageSize.height)
|
||||
currentPageNum++
|
||||
}
|
||||
|
||||
document.close()
|
||||
}
|
||||
|
||||
private fun renderSongPage(
|
||||
cb: PdfContentByte,
|
||||
chordLyricRenderer: ChordLyricRenderer,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
song: Song,
|
||||
pageIndex: Int, // 0 for first page, 1 for second page of 2-page songs
|
||||
contentTop: Float,
|
||||
leftMargin: Float,
|
||||
contentWidth: Float
|
||||
) {
|
||||
var y = contentTop
|
||||
|
||||
if (pageIndex == 0) {
|
||||
// Render title
|
||||
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
|
||||
val titleSize = config.fonts.title.size
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(titleFont, titleSize)
|
||||
cb.setColorFill(Color.BLACK)
|
||||
cb.setTextMatrix(leftMargin, y - titleSize)
|
||||
cb.showText(song.title)
|
||||
cb.endText()
|
||||
y -= titleSize * 1.5f
|
||||
|
||||
// Render metadata line (composer/lyricist)
|
||||
val metaParts = mutableListOf<String>()
|
||||
song.composer?.let { metaParts.add("M: $it") }
|
||||
song.lyricist?.let { metaParts.add("T: $it") }
|
||||
if (metaParts.isNotEmpty()) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(metaParts.joinToString(" / "))
|
||||
cb.endText()
|
||||
y -= metaSize * 1.8f
|
||||
}
|
||||
|
||||
// Render key and capo
|
||||
val infoParts = mutableListOf<String>()
|
||||
song.key?.let { infoParts.add("Tonart: $it") }
|
||||
song.capo?.let { infoParts.add("Capo: $it") }
|
||||
if (infoParts.isNotEmpty()) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(infoParts.joinToString(" | "))
|
||||
cb.endText()
|
||||
y -= metaSize * 1.8f
|
||||
}
|
||||
|
||||
y -= 4f // gap before sections
|
||||
}
|
||||
|
||||
// Determine which sections to render on this page
|
||||
// For simplicity in this implementation, render all sections on pageIndex 0
|
||||
// A more sophisticated implementation would split sections across pages
|
||||
val sections = if (pageIndex == 0) song.sections else emptyList()
|
||||
|
||||
for (section in sections) {
|
||||
// Section label
|
||||
if (section.label != null || section.type == SectionType.CHORUS) {
|
||||
val labelText = section.label ?: when (section.type) {
|
||||
SectionType.CHORUS -> "Refrain"
|
||||
SectionType.REPEAT -> "Wiederholung"
|
||||
else -> null
|
||||
}
|
||||
if (labelText != null) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(labelText)
|
||||
cb.endText()
|
||||
y -= metaSize * 1.5f
|
||||
}
|
||||
}
|
||||
|
||||
// Chorus indication for repeat
|
||||
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText("(Refrain)")
|
||||
cb.endText()
|
||||
y -= metaSize * 1.8f
|
||||
continue
|
||||
}
|
||||
|
||||
// Render repeat markers for REPEAT sections
|
||||
if (section.type == SectionType.REPEAT) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText("\u2502:")
|
||||
cb.endText()
|
||||
}
|
||||
|
||||
// Render lines
|
||||
for (line in section.lines) {
|
||||
val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth)
|
||||
y -= height + 1f // 1pt gap between lines
|
||||
}
|
||||
|
||||
// End repeat marker
|
||||
if (section.type == SectionType.REPEAT) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.DARK_GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(":\u2502")
|
||||
cb.endText()
|
||||
y -= metaSize * 1.5f
|
||||
}
|
||||
|
||||
// Verse spacing
|
||||
y -= config.layout.verseSpacing / 0.3528f
|
||||
}
|
||||
|
||||
// Render notes at the bottom
|
||||
if (pageIndex == 0 && song.notes.isNotEmpty()) {
|
||||
y -= 4f
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
for (note in song.notes) {
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(note)
|
||||
cb.endText()
|
||||
y -= metaSize * 1.5f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) {
|
||||
try {
|
||||
val img = Image.getInstance(imagePath)
|
||||
img.scaleToFit(pageSize.width * 0.7f, pageSize.height * 0.7f)
|
||||
img.alignment = Image.ALIGN_CENTER or Image.ALIGN_MIDDLE
|
||||
document.add(img)
|
||||
} catch (_: Exception) {
|
||||
// If image can't be loaded, just leave the page blank
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import com.lowagie.text.pdf.BaseFont
|
||||
import de.pfadfinder.songbook.model.FontMetrics
|
||||
import de.pfadfinder.songbook.model.FontSpec
|
||||
|
||||
class PdfFontMetrics : FontMetrics {
|
||||
private val fontCache = mutableMapOf<String, BaseFont>()
|
||||
|
||||
fun getBaseFont(font: FontSpec): BaseFont {
|
||||
val key = font.file ?: font.family
|
||||
return fontCache.getOrPut(key) {
|
||||
if (font.file != null) {
|
||||
BaseFont.createFont(font.file, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
|
||||
} else {
|
||||
// Map common family names to built-in PDF fonts
|
||||
val pdfFontName = when (font.family.lowercase()) {
|
||||
"helvetica" -> BaseFont.HELVETICA
|
||||
"courier" -> BaseFont.COURIER
|
||||
"times", "times new roman" -> BaseFont.TIMES_ROMAN
|
||||
else -> BaseFont.HELVETICA
|
||||
}
|
||||
BaseFont.createFont(pdfFontName, BaseFont.CP1252, BaseFont.NOT_EMBEDDED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also provide bold variants for chord fonts
|
||||
fun getBaseFontBold(font: FontSpec): BaseFont {
|
||||
if (font.file != null) return getBaseFont(font)
|
||||
val key = "${font.family}_bold"
|
||||
return fontCache.getOrPut(key) {
|
||||
val pdfFontName = when (font.family.lowercase()) {
|
||||
"helvetica" -> BaseFont.HELVETICA_BOLD
|
||||
"courier" -> BaseFont.COURIER_BOLD
|
||||
"times", "times new roman" -> BaseFont.TIMES_BOLD
|
||||
else -> BaseFont.HELVETICA_BOLD
|
||||
}
|
||||
BaseFont.createFont(pdfFontName, BaseFont.CP1252, BaseFont.NOT_EMBEDDED)
|
||||
}
|
||||
}
|
||||
|
||||
override fun measureTextWidth(text: String, font: FontSpec, size: Float): Float {
|
||||
val baseFont = getBaseFont(font)
|
||||
// BaseFont.getWidthPoint returns width in PDF points
|
||||
// Convert to mm: 1 point = 0.3528 mm
|
||||
return baseFont.getWidthPoint(text, size) * 0.3528f
|
||||
}
|
||||
|
||||
override fun measureLineHeight(font: FontSpec, size: Float): Float {
|
||||
// Approximate line height as 1.2 * font size, converted to mm
|
||||
return size * 1.2f * 0.3528f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import com.lowagie.text.*
|
||||
import com.lowagie.text.pdf.*
|
||||
import de.pfadfinder.songbook.model.*
|
||||
|
||||
class TocRenderer(
|
||||
private val fontMetrics: PdfFontMetrics,
|
||||
private val config: BookConfig
|
||||
) {
|
||||
fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) {
|
||||
val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
|
||||
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
|
||||
val fontSize = config.fonts.toc.size
|
||||
|
||||
// Title "Inhaltsverzeichnis"
|
||||
val titleFont = Font(fontMetrics.getBaseFont(config.fonts.title), config.fonts.title.size, Font.BOLD)
|
||||
val title = Paragraph("Inhaltsverzeichnis", titleFont)
|
||||
title.alignment = Element.ALIGN_CENTER
|
||||
title.spacingAfter = 12f
|
||||
document.add(title)
|
||||
|
||||
// Determine columns: Title | Page | ref book abbreviations...
|
||||
val refBooks = config.referenceBooks
|
||||
val numCols = 2 + refBooks.size
|
||||
val table = PdfPTable(numCols)
|
||||
table.widthPercentage = 100f
|
||||
|
||||
// Set column widths: title takes most space
|
||||
val widths = FloatArray(numCols)
|
||||
widths[0] = 10f // title
|
||||
widths[1] = 1.5f // page
|
||||
for (i in refBooks.indices) {
|
||||
widths[2 + i] = 1.5f
|
||||
}
|
||||
table.setWidths(widths)
|
||||
|
||||
// Header row
|
||||
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
||||
table.addCell(headerCell("Titel", headerFont))
|
||||
table.addCell(headerCell("Seite", headerFont))
|
||||
for (book in refBooks) {
|
||||
table.addCell(headerCell(book.abbreviation, headerFont))
|
||||
}
|
||||
table.headerRows = 1
|
||||
|
||||
// TOC entries
|
||||
val entryFont = Font(tocFont, fontSize)
|
||||
val aliasFont = Font(tocFont, fontSize, Font.ITALIC)
|
||||
for (entry in tocEntries.sortedBy { it.title.lowercase() }) {
|
||||
val font = if (entry.isAlias) aliasFont else entryFont
|
||||
table.addCell(entryCell(entry.title, font))
|
||||
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT))
|
||||
for (book in refBooks) {
|
||||
val ref = entry.references[book.abbreviation]
|
||||
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT))
|
||||
}
|
||||
}
|
||||
|
||||
document.add(table)
|
||||
}
|
||||
|
||||
private fun headerCell(text: String, font: Font): PdfPCell {
|
||||
val cell = PdfPCell(Phrase(text, font))
|
||||
cell.borderWidth = 0f
|
||||
cell.borderWidthBottom = 0.5f
|
||||
cell.paddingBottom = 4f
|
||||
return cell
|
||||
}
|
||||
|
||||
private fun entryCell(text: String, font: Font, alignment: Int = Element.ALIGN_LEFT): PdfPCell {
|
||||
val cell = PdfPCell(Phrase(text, font))
|
||||
cell.borderWidth = 0f
|
||||
cell.horizontalAlignment = alignment
|
||||
cell.paddingTop = 1f
|
||||
cell.paddingBottom = 1f
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import com.lowagie.text.Document
|
||||
import com.lowagie.text.PageSize
|
||||
import com.lowagie.text.pdf.PdfWriter
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import io.kotest.matchers.floats.shouldBeGreaterThan
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.test.Test
|
||||
|
||||
class ChordLyricRendererTest {
|
||||
|
||||
private val fontMetrics = PdfFontMetrics()
|
||||
private val config = BookConfig()
|
||||
private val renderer = ChordLyricRenderer(fontMetrics, config)
|
||||
|
||||
private fun withPdfContentByte(block: (com.lowagie.text.pdf.PdfContentByte) -> Unit) {
|
||||
val baos = ByteArrayOutputStream()
|
||||
val document = Document(PageSize.A5)
|
||||
val writer = PdfWriter.getInstance(document, baos)
|
||||
document.open()
|
||||
val cb = writer.directContent
|
||||
block(cb)
|
||||
document.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renderLine returns positive height for lyric-only line`() {
|
||||
withPdfContentByte { cb ->
|
||||
val line = SongLine(listOf(LineSegment(text = "Hello world")))
|
||||
val height = renderer.renderLine(cb, line, 50f, 500f, 300f)
|
||||
height shouldBeGreaterThan 0f
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renderLine returns greater height for chord+lyric line than lyric-only`() {
|
||||
withPdfContentByte { cb ->
|
||||
val lyricOnly = SongLine(listOf(LineSegment(text = "Hello world")))
|
||||
val withChords = SongLine(listOf(LineSegment(chord = "Am", text = "Hello world")))
|
||||
|
||||
val lyricHeight = renderer.renderLine(cb, lyricOnly, 50f, 500f, 300f)
|
||||
val chordHeight = renderer.renderLine(cb, withChords, 50f, 500f, 300f)
|
||||
|
||||
chordHeight shouldBeGreaterThan lyricHeight
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renderLine handles multiple segments`() {
|
||||
withPdfContentByte { cb ->
|
||||
val line = SongLine(
|
||||
listOf(
|
||||
LineSegment(chord = "C", text = "Amazing "),
|
||||
LineSegment(chord = "G", text = "Grace, how "),
|
||||
LineSegment(chord = "Am", text = "sweet the "),
|
||||
LineSegment(chord = "F", text = "sound")
|
||||
)
|
||||
)
|
||||
val height = renderer.renderLine(cb, line, 50f, 500f, 300f)
|
||||
height shouldBeGreaterThan 0f
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renderLine handles segments with mixed chords and no-chords`() {
|
||||
withPdfContentByte { cb ->
|
||||
val line = SongLine(
|
||||
listOf(
|
||||
LineSegment(chord = "C", text = "Hello "),
|
||||
LineSegment(text = "world"),
|
||||
LineSegment(chord = "G", text = " today")
|
||||
)
|
||||
)
|
||||
val height = renderer.renderLine(cb, line, 50f, 500f, 300f)
|
||||
height shouldBeGreaterThan 0f
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renderLine handles empty text segments`() {
|
||||
withPdfContentByte { cb ->
|
||||
val line = SongLine(listOf(LineSegment(chord = "Am", text = "")))
|
||||
val height = renderer.renderLine(cb, line, 50f, 500f, 300f)
|
||||
height shouldBeGreaterThan 0f
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renderLine handles custom chord color from config`() {
|
||||
val customConfig = BookConfig(
|
||||
fonts = FontsConfig(
|
||||
chords = FontSpec(family = "Helvetica", size = 9f, color = "#FF0000")
|
||||
)
|
||||
)
|
||||
val customRenderer = ChordLyricRenderer(fontMetrics, customConfig)
|
||||
withPdfContentByte { cb ->
|
||||
val line = SongLine(listOf(LineSegment(chord = "Am", text = "Hello")))
|
||||
val height = customRenderer.renderLine(cb, line, 50f, 500f, 300f)
|
||||
height shouldBeGreaterThan 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import com.lowagie.text.Document
|
||||
import com.lowagie.text.PageSize
|
||||
import com.lowagie.text.pdf.PdfWriter
|
||||
import de.pfadfinder.songbook.model.BookConfig
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.test.Test
|
||||
|
||||
class PageDecoratorTest {
|
||||
|
||||
private val fontMetrics = PdfFontMetrics()
|
||||
private val config = BookConfig()
|
||||
private val decorator = PageDecorator(fontMetrics, config)
|
||||
|
||||
private fun withPdfContentByte(block: (com.lowagie.text.pdf.PdfContentByte) -> Unit) {
|
||||
val baos = ByteArrayOutputStream()
|
||||
val document = Document(PageSize.A5)
|
||||
val writer = PdfWriter.getInstance(document, baos)
|
||||
document.open()
|
||||
val cb = writer.directContent
|
||||
block(cb)
|
||||
document.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addPageNumber renders odd page number on right side`() {
|
||||
// Odd page = right side of book spread
|
||||
withPdfContentByte { cb ->
|
||||
decorator.addPageNumber(cb, 1, PageSize.A5.width, PageSize.A5.height)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addPageNumber renders even page number on left side`() {
|
||||
// Even page = left side of book spread
|
||||
withPdfContentByte { cb ->
|
||||
decorator.addPageNumber(cb, 2, PageSize.A5.width, PageSize.A5.height)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addPageNumber handles large page numbers`() {
|
||||
withPdfContentByte { cb ->
|
||||
decorator.addPageNumber(cb, 999, PageSize.A5.width, PageSize.A5.height)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addPageNumber works with A4 page size`() {
|
||||
withPdfContentByte { cb ->
|
||||
decorator.addPageNumber(cb, 5, PageSize.A4.width, PageSize.A4.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import io.kotest.matchers.ints.shouldBeGreaterThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFails
|
||||
|
||||
class PdfBookRendererTest {
|
||||
|
||||
private val renderer = PdfBookRenderer()
|
||||
|
||||
private fun createSimpleSong(title: String = "Test Song"): Song {
|
||||
return Song(
|
||||
title = title,
|
||||
composer = "Test Composer",
|
||||
lyricist = "Test Lyricist",
|
||||
key = "Am",
|
||||
capo = 2,
|
||||
notes = listOf("Play gently"),
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
label = "Verse 1",
|
||||
lines = listOf(
|
||||
SongLine(
|
||||
listOf(
|
||||
LineSegment(chord = "Am", text = "Hello "),
|
||||
LineSegment(chord = "C", text = "World")
|
||||
)
|
||||
),
|
||||
SongLine(
|
||||
listOf(
|
||||
LineSegment(text = "This is a test line")
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
SongSection(
|
||||
type = SectionType.CHORUS,
|
||||
lines = listOf(
|
||||
SongLine(
|
||||
listOf(
|
||||
LineSegment(chord = "F", text = "Chorus "),
|
||||
LineSegment(chord = "G", text = "line")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render produces valid PDF with single song`() {
|
||||
val song = createSimpleSong()
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
// Check PDF header
|
||||
val bytes = baos.toByteArray()
|
||||
val header = String(bytes.sliceArray(0..4))
|
||||
header shouldBe "%PDF-"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render produces valid PDF with TOC`() {
|
||||
val song = createSimpleSong()
|
||||
val layout = LayoutResult(
|
||||
tocPages = 2,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = listOf(
|
||||
TocEntry(title = "Test Song", pageNumber = 3)
|
||||
)
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles blank pages`() {
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.BlankPage),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles mixed page types`() {
|
||||
val song = createSimpleSong()
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(
|
||||
PageContent.SongPage(song, 0),
|
||||
PageContent.BlankPage,
|
||||
PageContent.SongPage(song, 0)
|
||||
),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles A4 format`() {
|
||||
val song = createSimpleSong()
|
||||
val config = BookConfig(book = BookMeta(format = "A4"))
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, config, baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles song with all section types`() {
|
||||
val song = Song(
|
||||
title = "Full Song",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
label = "Verse 1",
|
||||
lines = listOf(
|
||||
SongLine(listOf(LineSegment(chord = "C", text = "Verse line")))
|
||||
)
|
||||
),
|
||||
SongSection(
|
||||
type = SectionType.CHORUS,
|
||||
lines = listOf(
|
||||
SongLine(listOf(LineSegment(chord = "G", text = "Chorus line")))
|
||||
)
|
||||
),
|
||||
SongSection(
|
||||
type = SectionType.BRIDGE,
|
||||
label = "Bridge",
|
||||
lines = listOf(
|
||||
SongLine(listOf(LineSegment(text = "Bridge line")))
|
||||
)
|
||||
),
|
||||
SongSection(
|
||||
type = SectionType.REPEAT,
|
||||
lines = listOf(
|
||||
SongLine(listOf(LineSegment(chord = "Am", text = "Repeat line")))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles empty chorus section (chorus reference)`() {
|
||||
val song = Song(
|
||||
title = "Song with chorus ref",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.CHORUS,
|
||||
lines = emptyList() // empty = just a reference
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles song without metadata`() {
|
||||
val song = Song(
|
||||
title = "Minimal Song",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(
|
||||
SongLine(listOf(LineSegment(text = "Just lyrics")))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles second page of two-page song`() {
|
||||
val song = createSimpleSong()
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(
|
||||
PageContent.SongPage(song, 0),
|
||||
PageContent.SongPage(song, 1)
|
||||
),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles filler image with nonexistent path gracefully`() {
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.FillerImage("/nonexistent/image.png")),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles TOC with reference books`() {
|
||||
val config = BookConfig(
|
||||
referenceBooks = listOf(
|
||||
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO")
|
||||
)
|
||||
)
|
||||
val song = createSimpleSong()
|
||||
val layout = LayoutResult(
|
||||
tocPages = 2,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = listOf(
|
||||
TocEntry(title = "Test Song", pageNumber = 3, references = mapOf("MO" to 42))
|
||||
)
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, config, baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles multiple songs with proper page numbering`() {
|
||||
val song1 = createSimpleSong("Song One")
|
||||
val song2 = createSimpleSong("Song Two")
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(
|
||||
PageContent.SongPage(song1, 0),
|
||||
PageContent.SongPage(song2, 0)
|
||||
),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles song with multiple notes`() {
|
||||
val song = Song(
|
||||
title = "Song with Notes",
|
||||
notes = listOf("Note 1: Play slowly", "Note 2: Repeat chorus twice"),
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(
|
||||
SongLine(listOf(LineSegment(text = "A simple line")))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render with custom margins`() {
|
||||
val config = BookConfig(
|
||||
layout = LayoutConfig(
|
||||
margins = Margins(top = 20f, bottom = 20f, inner = 25f, outer = 15f)
|
||||
)
|
||||
)
|
||||
val song = createSimpleSong()
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, config, baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render throws on empty layout with no content`() {
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = emptyList(),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
// OpenPDF requires at least one page of content
|
||||
assertFails {
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles song with only key no capo`() {
|
||||
val song = Song(
|
||||
title = "Key Only Song",
|
||||
key = "G",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles song with only capo no key`() {
|
||||
val song = Song(
|
||||
title = "Capo Only Song",
|
||||
capo = 3,
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val layout = LayoutResult(
|
||||
tocPages = 0,
|
||||
pages = listOf(PageContent.SongPage(song, 0)),
|
||||
tocEntries = emptyList()
|
||||
)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
renderer.render(layout, BookConfig(), baos)
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import de.pfadfinder.songbook.model.FontSpec
|
||||
import io.kotest.matchers.floats.shouldBeGreaterThan
|
||||
import io.kotest.matchers.floats.shouldBeLessThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeSameInstanceAs
|
||||
import kotlin.test.Test
|
||||
|
||||
class PdfFontMetricsTest {
|
||||
|
||||
private val metrics = PdfFontMetrics()
|
||||
|
||||
@Test
|
||||
fun `getBaseFont returns Helvetica for default font spec`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val baseFont = metrics.getBaseFont(font)
|
||||
// Helvetica built-in returns a non-null BaseFont
|
||||
baseFont.postscriptFontName shouldBe "Helvetica"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFont returns Courier for courier family`() {
|
||||
val font = FontSpec(family = "Courier", size = 10f)
|
||||
val baseFont = metrics.getBaseFont(font)
|
||||
baseFont.postscriptFontName shouldBe "Courier"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFont returns Times-Roman for times family`() {
|
||||
val font = FontSpec(family = "Times", size = 10f)
|
||||
val baseFont = metrics.getBaseFont(font)
|
||||
baseFont.postscriptFontName shouldBe "Times-Roman"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFont returns Times-Roman for times new roman family`() {
|
||||
val font = FontSpec(family = "Times New Roman", size = 10f)
|
||||
val baseFont = metrics.getBaseFont(font)
|
||||
baseFont.postscriptFontName shouldBe "Times-Roman"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFont falls back to Helvetica for unknown family`() {
|
||||
val font = FontSpec(family = "UnknownFont", size = 10f)
|
||||
val baseFont = metrics.getBaseFont(font)
|
||||
baseFont.postscriptFontName shouldBe "Helvetica"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFont caches fonts by family name`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val first = metrics.getBaseFont(font)
|
||||
val second = metrics.getBaseFont(font)
|
||||
first shouldBeSameInstanceAs second
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFontBold returns Helvetica-Bold for Helvetica`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val boldFont = metrics.getBaseFontBold(font)
|
||||
boldFont.postscriptFontName shouldBe "Helvetica-Bold"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFontBold returns Courier-Bold for Courier`() {
|
||||
val font = FontSpec(family = "Courier", size = 10f)
|
||||
val boldFont = metrics.getBaseFontBold(font)
|
||||
boldFont.postscriptFontName shouldBe "Courier-Bold"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFontBold returns Times-Bold for Times`() {
|
||||
val font = FontSpec(family = "Times", size = 10f)
|
||||
val boldFont = metrics.getBaseFontBold(font)
|
||||
boldFont.postscriptFontName shouldBe "Times-Bold"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFontBold falls back to Helvetica-Bold for unknown family`() {
|
||||
val font = FontSpec(family = "UnknownFont", size = 10f)
|
||||
val boldFont = metrics.getBaseFontBold(font)
|
||||
boldFont.postscriptFontName shouldBe "Helvetica-Bold"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFontBold returns regular font when file is specified`() {
|
||||
// When a file is specified, bold should return the same as regular
|
||||
// (custom fonts don't have bold variants auto-resolved)
|
||||
// We can't test with a real file here, but verify the logic path:
|
||||
// file != null -> delegates to getBaseFont
|
||||
// Since we don't have a real font file, we test with family-based fonts
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val bold1 = metrics.getBaseFontBold(font)
|
||||
val bold2 = metrics.getBaseFontBold(font)
|
||||
bold1 shouldBeSameInstanceAs bold2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureTextWidth returns positive value for non-empty text`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val width = metrics.measureTextWidth("Hello World", font, 10f)
|
||||
width shouldBeGreaterThan 0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureTextWidth returns zero for empty text`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val width = metrics.measureTextWidth("", font, 10f)
|
||||
width shouldBe 0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureTextWidth wider text returns larger width`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val shortWidth = metrics.measureTextWidth("Hi", font, 10f)
|
||||
val longWidth = metrics.measureTextWidth("Hello World, this is longer", font, 10f)
|
||||
longWidth shouldBeGreaterThan shortWidth
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureTextWidth scales with font size`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val smallWidth = metrics.measureTextWidth("Test", font, 10f)
|
||||
val largeWidth = metrics.measureTextWidth("Test", font, 20f)
|
||||
largeWidth shouldBeGreaterThan smallWidth
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureTextWidth returns value in mm`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val width = metrics.measureTextWidth("M", font, 10f)
|
||||
// A single 'M' at 10pt should be roughly 2-4mm
|
||||
width shouldBeGreaterThan 1f
|
||||
width shouldBeLessThan 10f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureLineHeight returns positive value`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val height = metrics.measureLineHeight(font, 10f)
|
||||
height shouldBeGreaterThan 0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureLineHeight scales with font size`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val smallHeight = metrics.measureLineHeight(font, 10f)
|
||||
val largeHeight = metrics.measureLineHeight(font, 20f)
|
||||
largeHeight shouldBeGreaterThan smallHeight
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureLineHeight returns value in mm`() {
|
||||
val font = FontSpec(family = "Helvetica", size = 10f)
|
||||
val height = metrics.measureLineHeight(font, 10f)
|
||||
// 10pt * 1.2 * 0.3528 = ~4.23mm
|
||||
height shouldBeGreaterThan 3f
|
||||
height shouldBeLessThan 6f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import com.lowagie.text.Document
|
||||
import com.lowagie.text.PageSize
|
||||
import com.lowagie.text.pdf.PdfWriter
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import io.kotest.matchers.ints.shouldBeGreaterThan
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.test.Test
|
||||
|
||||
class TocRendererTest {
|
||||
|
||||
private val fontMetrics = PdfFontMetrics()
|
||||
private val config = BookConfig()
|
||||
private val renderer = TocRenderer(fontMetrics, config)
|
||||
|
||||
@Test
|
||||
fun `render creates TOC with entries`() {
|
||||
val baos = ByteArrayOutputStream()
|
||||
val document = Document(PageSize.A5)
|
||||
val writer = PdfWriter.getInstance(document, baos)
|
||||
document.open()
|
||||
|
||||
val entries = listOf(
|
||||
TocEntry(title = "Amazing Grace", pageNumber = 3),
|
||||
TocEntry(title = "Blowin' in the Wind", pageNumber = 5),
|
||||
TocEntry(title = "Country Roads", pageNumber = 7)
|
||||
)
|
||||
|
||||
renderer.render(document, writer, entries)
|
||||
document.close()
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles alias entries in italics`() {
|
||||
val baos = ByteArrayOutputStream()
|
||||
val document = Document(PageSize.A5)
|
||||
val writer = PdfWriter.getInstance(document, baos)
|
||||
document.open()
|
||||
|
||||
val entries = listOf(
|
||||
TocEntry(title = "Amazing Grace", pageNumber = 3),
|
||||
TocEntry(title = "Grace (Amazing)", pageNumber = 3, isAlias = true)
|
||||
)
|
||||
|
||||
renderer.render(document, writer, entries)
|
||||
document.close()
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render includes reference book columns`() {
|
||||
val configWithRefs = BookConfig(
|
||||
referenceBooks = listOf(
|
||||
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO"),
|
||||
ReferenceBook(id = "pfadfinder", name = "Pfadfinderliederbuch", abbreviation = "PL")
|
||||
)
|
||||
)
|
||||
val rendererWithRefs = TocRenderer(fontMetrics, configWithRefs)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
val document = Document(PageSize.A5)
|
||||
val writer = PdfWriter.getInstance(document, baos)
|
||||
document.open()
|
||||
|
||||
val entries = listOf(
|
||||
TocEntry(
|
||||
title = "Amazing Grace",
|
||||
pageNumber = 3,
|
||||
references = mapOf("MO" to 42, "PL" to 15)
|
||||
),
|
||||
TocEntry(
|
||||
title = "Country Roads",
|
||||
pageNumber = 7,
|
||||
references = mapOf("MO" to 88)
|
||||
)
|
||||
)
|
||||
|
||||
rendererWithRefs.render(document, writer, entries)
|
||||
document.close()
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render sorts entries alphabetically`() {
|
||||
val baos = ByteArrayOutputStream()
|
||||
val document = Document(PageSize.A5)
|
||||
val writer = PdfWriter.getInstance(document, baos)
|
||||
document.open()
|
||||
|
||||
// Entries given out of order
|
||||
val entries = listOf(
|
||||
TocEntry(title = "Zzz Last", pageNumber = 10),
|
||||
TocEntry(title = "Aaa First", pageNumber = 1),
|
||||
TocEntry(title = "Mmm Middle", pageNumber = 5)
|
||||
)
|
||||
|
||||
renderer.render(document, writer, entries)
|
||||
document.close()
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render handles empty reference books list`() {
|
||||
val baos = ByteArrayOutputStream()
|
||||
val document = Document(PageSize.A5)
|
||||
val writer = PdfWriter.getInstance(document, baos)
|
||||
document.open()
|
||||
|
||||
val entries = listOf(
|
||||
TocEntry(title = "Test Song", pageNumber = 1)
|
||||
)
|
||||
|
||||
renderer.render(document, writer, entries)
|
||||
document.close()
|
||||
|
||||
baos.size() shouldBeGreaterThan 0
|
||||
}
|
||||
}
|
||||
25
settings.gradle.kts
Normal file
25
settings.gradle.kts
Normal file
@@ -0,0 +1,25 @@
|
||||
rootProject.name = "songbook"
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
google()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
google()
|
||||
}
|
||||
}
|
||||
|
||||
include("model")
|
||||
include("parser")
|
||||
include("layout")
|
||||
include("renderer-pdf")
|
||||
include("app")
|
||||
include("cli")
|
||||
include("gui")
|
||||
37
songbook.yaml
Normal file
37
songbook.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
book:
|
||||
title: "Pfadfinder Liederbuch"
|
||||
subtitle: "Beispiel-Ausgabe"
|
||||
edition: "1. Auflage, 2026"
|
||||
format: A5
|
||||
|
||||
songs:
|
||||
directory: "./songs"
|
||||
order: alphabetical
|
||||
|
||||
fonts:
|
||||
lyrics: { family: "Helvetica", size: 10 }
|
||||
chords: { family: "Helvetica", size: 9, color: "#333333" }
|
||||
title: { family: "Helvetica", size: 14 }
|
||||
metadata: { family: "Helvetica", size: 8 }
|
||||
toc: { family: "Helvetica", 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: pfadfinderliederbuch
|
||||
name: "Pfadfinderliederbuch"
|
||||
abbreviation: "PfLB"
|
||||
|
||||
output:
|
||||
directory: "./output"
|
||||
filename: "liederbuch.pdf"
|
||||
27
songs/abend-wird-es-wieder.chopro
Normal file
27
songs/abend-wird-es-wieder.chopro
Normal file
@@ -0,0 +1,27 @@
|
||||
{title: Abend wird es wieder}
|
||||
{lyricist: Christian Gottlob Barth, 1836}
|
||||
{composer: Volksweise}
|
||||
{key: C}
|
||||
{tags: Abendlied}
|
||||
{ref: pfadfinderliederbuch 12}
|
||||
|
||||
{start_of_verse: Strophe 1}
|
||||
[C]Abend wird es [G]wieder,
|
||||
[G]über Wald und [C]Feld
|
||||
säuselt [F]Frieden [C]nieder,
|
||||
und es [G]ruht die [C]Welt.
|
||||
{end_of_verse}
|
||||
|
||||
{start_of_verse: Strophe 2}
|
||||
[C]Nur der Bach er[G]gießet
|
||||
[G]sich am Felsen [C]dort,
|
||||
und er [F]braust und [C]fließet
|
||||
immer, [G]immer [C]fort.
|
||||
{end_of_verse}
|
||||
|
||||
{start_of_verse: Strophe 3}
|
||||
[C]Und kein Abend [G]bringet
|
||||
[G]Frieden ihm und [C]Ruh,
|
||||
keine [F]Glocke [C]klinget
|
||||
ihm ein [G]Rastlied [C]zu.
|
||||
{end_of_verse}
|
||||
26
songs/auf-auf-zum-froehlichen-jagen.chopro
Normal file
26
songs/auf-auf-zum-froehlichen-jagen.chopro
Normal file
@@ -0,0 +1,26 @@
|
||||
{title: Auf, auf zum fröhlichen Jagen}
|
||||
{lyricist: Traditionell, 18. Jahrhundert}
|
||||
{composer: Volksweise}
|
||||
{key: F}
|
||||
{tags: Volkslied, Jagd}
|
||||
|
||||
{start_of_verse: Strophe 1}
|
||||
[F]Auf, auf zum fröhlichen [C]Jagen,
|
||||
auf [C]in die grüne [F]Heid'!
|
||||
Es [F]gibt nichts Schönres [Bb]auf Erden,
|
||||
als [C]jetzt zur Herbstes[F]zeit.
|
||||
{end_of_verse}
|
||||
|
||||
{start_of_chorus}
|
||||
Halli, hallo, halli, hallo,
|
||||
auf [C]in die grüne [F]Heid'!
|
||||
{end_of_chorus}
|
||||
|
||||
{start_of_verse: Strophe 2}
|
||||
[F]Der Hirsch, der springt im [C]Walde,
|
||||
das [C]Reh steht auf der [F]Flur,
|
||||
die [F]Vöglein singen [Bb]alle
|
||||
zur [C]schönen Jägerei[F]natur.
|
||||
{end_of_verse}
|
||||
|
||||
{chorus}
|
||||
42
songs/die-gedanken-sind-frei.chopro
Normal file
42
songs/die-gedanken-sind-frei.chopro
Normal file
@@ -0,0 +1,42 @@
|
||||
{title: Die Gedanken sind frei}
|
||||
{alias: Gedankenfreiheit}
|
||||
{lyricist: Deutsches Volkslied}
|
||||
{composer: Deutsches Volkslied, ca. 1810}
|
||||
{key: G}
|
||||
{tags: Volkslied, Freiheit}
|
||||
{note: Eines der bekanntesten deutschen Volkslieder. Text erstmals 1780.}
|
||||
{ref: mundorgel 42}
|
||||
{ref: pfadfinderliederbuch 118}
|
||||
|
||||
{start_of_verse: Strophe 1}
|
||||
Die Ge[G]danken sind [D]frei,
|
||||
wer [D]kann sie er[G]raten?
|
||||
Sie [G]fliehen vor[C]bei
|
||||
wie [D]nächtliche [G]Schatten.
|
||||
Kein [C]Mensch kann sie [G]wissen,
|
||||
kein [Am]Jäger er[D]schießen.
|
||||
Es [G]bleibet da[C]bei:
|
||||
Die Ge[D]danken sind [G]frei!
|
||||
{end_of_verse}
|
||||
|
||||
{start_of_verse: Strophe 2}
|
||||
Ich [G]denke, was ich [D]will
|
||||
und [D]was mich be[G]glücket,
|
||||
doch [G]alles in der [C]Still',
|
||||
und [D]wie es sich [G]schicket.
|
||||
Mein [C]Wunsch und Be[G]gehren
|
||||
kann [Am]niemand ver[D]wehren,
|
||||
es [G]bleibet da[C]bei:
|
||||
Die Ge[D]danken sind [G]frei!
|
||||
{end_of_verse}
|
||||
|
||||
{start_of_verse: Strophe 3}
|
||||
Und [G]sperrt man mich [D]ein
|
||||
im [D]finsteren [G]Kerker,
|
||||
das [G]alles sind rein [C]
|
||||
ver[D]gebliche [G]Werke.
|
||||
Denn [C]meine Ge[G]danken
|
||||
zer[Am]reißen die [D]Schranken
|
||||
und [G]Mauern ent[C]zwei:
|
||||
Die Ge[D]danken sind [G]frei!
|
||||
{end_of_verse}
|
||||
18
songs/hejo-spann-den-wagen-an.chopro
Normal file
18
songs/hejo-spann-den-wagen-an.chopro
Normal file
@@ -0,0 +1,18 @@
|
||||
{title: Hejo, spann den Wagen an}
|
||||
{lyricist: Traditionell}
|
||||
{composer: Traditionell}
|
||||
{key: Am}
|
||||
{tags: Kanon, Fahrt}
|
||||
{ref: mundorgel 15}
|
||||
|
||||
{start_of_verse: Kanon}
|
||||
[Am]Hejo, spann den Wagen an,
|
||||
denn der [G]Wind treibt [Am]Regen übers Land.
|
||||
[Am]Hol die goldnen Garben rein,
|
||||
denn der [G]Wind treibt [Am]Regen übers Land.
|
||||
{end_of_verse}
|
||||
|
||||
{start_of_verse: 2. Stimme}
|
||||
[Am]Hejo, spann den Wagen an,
|
||||
denn der [G]Wind treibt [Am]Regen übers Land.
|
||||
{end_of_verse}
|
||||
33
songs/kein-schoener-land.chopro
Normal file
33
songs/kein-schoener-land.chopro
Normal file
@@ -0,0 +1,33 @@
|
||||
{title: Kein schöner Land}
|
||||
{alias: Kein schöner Land in dieser Zeit}
|
||||
{lyricist: Anton Wilhelm von Zuccalmaglio}
|
||||
{composer: Volksweise, 1840}
|
||||
{key: D}
|
||||
{tags: Volkslied, Abendlied}
|
||||
{note: Veröffentlicht 1840 in "Deutsche Volkslieder mit ihren Original-Weisen".}
|
||||
{ref: mundorgel 88}
|
||||
{ref: pfadfinderliederbuch 65}
|
||||
|
||||
{start_of_verse: Strophe 1}
|
||||
Kein [D]schöner Land in [A]dieser Zeit,
|
||||
als [A]hier das unsre [D]weit und breit,
|
||||
wo [D]wir uns [G]finden
|
||||
wohl [D]unter [A]Linden
|
||||
zur [D]Abend[A]zeit.
|
||||
{end_of_verse}
|
||||
|
||||
{start_of_verse: Strophe 2}
|
||||
Da [D]haben wir so [A]manche Stund'
|
||||
ge[A]sessen da in [D]froher Rund'
|
||||
und [D]taten [G]singen,
|
||||
die [D]Lieder [A]klingen
|
||||
im [D]Eichen[A]grund.
|
||||
{end_of_verse}
|
||||
|
||||
{start_of_verse: Strophe 3}
|
||||
Dass [D]wir uns hier in [A]diesem Tal
|
||||
noch [A]treffen so viel [D]hundertmal,
|
||||
Gott [D]mag es [G]schenken,
|
||||
Gott [D]mag es [A]lenken,
|
||||
er [D]hat die [A]Gnad'.
|
||||
{end_of_verse}
|
||||
Reference in New Issue
Block a user