From e386501b57d43292cee4ef4b6c5390d24e60c191 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Tue, 17 Mar 2026 08:35:42 +0100 Subject: [PATCH] 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) --- .gitignore | 22 + CLAUDE.md | 80 +++ app/build.gradle.kts | 16 + .../songbook/app/SongbookPipeline.kt | 158 ++++++ .../songbook/app/SongbookPipelineTest.kt | 534 ++++++++++++++++++ build.gradle.kts | 4 + buildSrc/build.gradle.kts | 12 + .../kotlin/songbook-conventions.gradle.kts | 11 + cli/build.gradle.kts | 17 + .../pfadfinder/songbook/cli/BuildCommand.kt | 37 ++ .../kotlin/de/pfadfinder/songbook/cli/Main.kt | 15 + .../songbook/cli/ValidateCommand.kt | 34 ++ gradle.properties | 1 + gui/build.gradle.kts | 20 + .../kotlin/de/pfadfinder/songbook/gui/App.kt | 347 ++++++++++++ layout/build.gradle.kts | 10 + .../pfadfinder/songbook/layout/GapFiller.kt | 13 + .../songbook/layout/MeasurementEngine.kt | 72 +++ .../songbook/layout/PaginationEngine.kt | 53 ++ .../songbook/layout/TocGenerator.kt | 52 ++ .../songbook/layout/GapFillerTest.kt | 74 +++ .../songbook/layout/MeasurementEngineTest.kt | 261 +++++++++ .../songbook/layout/PaginationEngineTest.kt | 205 +++++++ .../songbook/layout/StubFontMetrics.kt | 12 + .../songbook/layout/TocGeneratorTest.kt | 211 +++++++ model/build.gradle.kts | 3 + .../pfadfinder/songbook/model/BookConfig.kt | 67 +++ .../pfadfinder/songbook/model/BookRenderer.kt | 7 + .../pfadfinder/songbook/model/FontMetrics.kt | 6 + .../de/pfadfinder/songbook/model/Layout.kt | 26 + .../de/pfadfinder/songbook/model/Song.kt | 31 + parser/build.gradle.kts | 13 + .../songbook/parser/ChordProParser.kt | 199 +++++++ .../songbook/parser/ConfigParser.kt | 25 + .../pfadfinder/songbook/parser/Validator.kt | 55 ++ .../songbook/parser/ChordProParserTest.kt | 488 ++++++++++++++++ .../songbook/parser/ConfigParserTest.kt | 182 ++++++ .../songbook/parser/ValidatorTest.kt | 209 +++++++ renderer-pdf/build.gradle.kts | 11 + .../renderer/pdf/ChordLyricRenderer.kt | 70 +++ .../songbook/renderer/pdf/PageDecorator.kt | 37 ++ .../songbook/renderer/pdf/PdfBookRenderer.kt | 248 ++++++++ .../songbook/renderer/pdf/PdfFontMetrics.kt | 54 ++ .../songbook/renderer/pdf/TocRenderer.kt | 79 +++ .../renderer/pdf/ChordLyricRendererTest.kt | 103 ++++ .../renderer/pdf/PageDecoratorTest.kt | 55 ++ .../renderer/pdf/PdfBookRendererTest.kt | 420 ++++++++++++++ .../renderer/pdf/PdfFontMetricsTest.kt | 161 ++++++ .../songbook/renderer/pdf/TocRendererTest.kt | 124 ++++ settings.gradle.kts | 25 + songbook.yaml | 37 ++ songs/abend-wird-es-wieder.chopro | 27 + songs/auf-auf-zum-froehlichen-jagen.chopro | 26 + songs/die-gedanken-sind-frei.chopro | 42 ++ songs/hejo-spann-den-wagen-an.chopro | 18 + songs/kein-schoener-land.chopro | 33 ++ 56 files changed, 5152 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt create mode 100644 app/src/test/kotlin/de/pfadfinder/songbook/app/SongbookPipelineTest.kt create mode 100644 build.gradle.kts create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/songbook-conventions.gradle.kts create mode 100644 cli/build.gradle.kts create mode 100644 cli/src/main/kotlin/de/pfadfinder/songbook/cli/BuildCommand.kt create mode 100644 cli/src/main/kotlin/de/pfadfinder/songbook/cli/Main.kt create mode 100644 cli/src/main/kotlin/de/pfadfinder/songbook/cli/ValidateCommand.kt create mode 100644 gradle.properties create mode 100644 gui/build.gradle.kts create mode 100644 gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt create mode 100644 layout/build.gradle.kts create mode 100644 layout/src/main/kotlin/de/pfadfinder/songbook/layout/GapFiller.kt create mode 100644 layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt create mode 100644 layout/src/main/kotlin/de/pfadfinder/songbook/layout/PaginationEngine.kt create mode 100644 layout/src/main/kotlin/de/pfadfinder/songbook/layout/TocGenerator.kt create mode 100644 layout/src/test/kotlin/de/pfadfinder/songbook/layout/GapFillerTest.kt create mode 100644 layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt create mode 100644 layout/src/test/kotlin/de/pfadfinder/songbook/layout/PaginationEngineTest.kt create mode 100644 layout/src/test/kotlin/de/pfadfinder/songbook/layout/StubFontMetrics.kt create mode 100644 layout/src/test/kotlin/de/pfadfinder/songbook/layout/TocGeneratorTest.kt create mode 100644 model/build.gradle.kts create mode 100644 model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt create mode 100644 model/src/main/kotlin/de/pfadfinder/songbook/model/BookRenderer.kt create mode 100644 model/src/main/kotlin/de/pfadfinder/songbook/model/FontMetrics.kt create mode 100644 model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt create mode 100644 model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt create mode 100644 parser/build.gradle.kts create mode 100644 parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt create mode 100644 parser/src/main/kotlin/de/pfadfinder/songbook/parser/ConfigParser.kt create mode 100644 parser/src/main/kotlin/de/pfadfinder/songbook/parser/Validator.kt create mode 100644 parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt create mode 100644 parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt create mode 100644 parser/src/test/kotlin/de/pfadfinder/songbook/parser/ValidatorTest.kt create mode 100644 renderer-pdf/build.gradle.kts create mode 100644 renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRenderer.kt create mode 100644 renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecorator.kt create mode 100644 renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt create mode 100644 renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetrics.kt create mode 100644 renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRenderer.kt create mode 100644 renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRendererTest.kt create mode 100644 renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecoratorTest.kt create mode 100644 renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRendererTest.kt create mode 100644 renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetricsTest.kt create mode 100644 renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRendererTest.kt create mode 100644 settings.gradle.kts create mode 100644 songbook.yaml create mode 100644 songs/abend-wird-es-wieder.chopro create mode 100644 songs/auf-auf-zum-froehlichen-jagen.chopro create mode 100644 songs/die-gedanken-sind-frei.chopro create mode 100644 songs/hejo-spann-den-wagen-an.chopro create mode 100644 songs/kein-schoener-land.chopro diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac0e1b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Gradle +.gradle/ +build/ +buildSrc/build/ + +# IDE +.idea/ +*.iml +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Output +output/ + +# Kotlin +*.class + +# Claude +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..93877e8 --- /dev/null +++ b/CLAUDE.md @@ -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`). diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..54c1c55 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt b/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt new file mode 100644 index 0000000..5557a96 --- /dev/null +++ b/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt @@ -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 = 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() + val allErrors = mutableListOf() + + 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 { + 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() + 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 + } +} diff --git a/app/src/test/kotlin/de/pfadfinder/songbook/app/SongbookPipelineTest.kt b/app/src/test/kotlin/de/pfadfinder/songbook/app/SongbookPipelineTest.kt new file mode 100644 index 0000000..924f06d --- /dev/null +++ b/app/src/test/kotlin/de/pfadfinder/songbook/app/SongbookPipelineTest.kt @@ -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 + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..760cc36 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..ba0cdf1 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + gradlePluginPortal() +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10") +} diff --git a/buildSrc/src/main/kotlin/songbook-conventions.gradle.kts b/buildSrc/src/main/kotlin/songbook-conventions.gradle.kts new file mode 100644 index 0000000..749c1f1 --- /dev/null +++ b/buildSrc/src/main/kotlin/songbook-conventions.gradle.kts @@ -0,0 +1,11 @@ +plugins { + kotlin("jvm") +} + +kotlin { + jvmToolchain(21) +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts new file mode 100644 index 0000000..d959309 --- /dev/null +++ b/cli/build.gradle.kts @@ -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") +} diff --git a/cli/src/main/kotlin/de/pfadfinder/songbook/cli/BuildCommand.kt b/cli/src/main/kotlin/de/pfadfinder/songbook/cli/BuildCommand.kt new file mode 100644 index 0000000..0d9c87a --- /dev/null +++ b/cli/src/main/kotlin/de/pfadfinder/songbook/cli/BuildCommand.kt @@ -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) + } + } +} diff --git a/cli/src/main/kotlin/de/pfadfinder/songbook/cli/Main.kt b/cli/src/main/kotlin/de/pfadfinder/songbook/cli/Main.kt new file mode 100644 index 0000000..158a84c --- /dev/null +++ b/cli/src/main/kotlin/de/pfadfinder/songbook/cli/Main.kt @@ -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) { + SongbookCli() + .subcommands(BuildCommand(), ValidateCommand()) + .main(args) +} diff --git a/cli/src/main/kotlin/de/pfadfinder/songbook/cli/ValidateCommand.kt b/cli/src/main/kotlin/de/pfadfinder/songbook/cli/ValidateCommand.kt new file mode 100644 index 0000000..90ed786 --- /dev/null +++ b/cli/src/main/kotlin/de/pfadfinder/songbook/cli/ValidateCommand.kt @@ -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) + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..eb30735 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.java.home=/usr/lib/jvm/java-21-openjdk diff --git a/gui/build.gradle.kts b/gui/build.gradle.kts new file mode 100644 index 0000000..6bd309b --- /dev/null +++ b/gui/build.gradle.kts @@ -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" + } +} diff --git a/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt new file mode 100644 index 0000000..87bf169 --- /dev/null +++ b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt @@ -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>(emptyList()) } + var statusMessages by remember { mutableStateOf>(emptyList()) } + var isRunning by remember { mutableStateOf(false) } + var lastBuildResult by remember { mutableStateOf(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) diff --git a/layout/build.gradle.kts b/layout/build.gradle.kts new file mode 100644 index 0000000..3fb3afb --- /dev/null +++ b/layout/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("songbook-conventions") +} + +dependencies { + implementation(project(":model")) + + testImplementation(kotlin("test")) + testImplementation("io.kotest:kotest-assertions-core:5.9.1") +} diff --git a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/GapFiller.kt b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/GapFiller.kt new file mode 100644 index 0000000..47d451e --- /dev/null +++ b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/GapFiller.kt @@ -0,0 +1,13 @@ +package de.pfadfinder.songbook.layout + +import java.io.File + +object GapFiller { + fun findImages(directory: String): List { + 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() + } +} diff --git a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt new file mode 100644 index 0000000..0c615d7 --- /dev/null +++ b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt @@ -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) + } +} diff --git a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/PaginationEngine.kt b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/PaginationEngine.kt new file mode 100644 index 0000000..d81b780 --- /dev/null +++ b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/PaginationEngine.kt @@ -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, tocPages: Int): List { + val pages = mutableListOf() + // 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 + } +} diff --git a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/TocGenerator.kt b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/TocGenerator.kt new file mode 100644 index 0000000..ecc6390 --- /dev/null +++ b/layout/src/main/kotlin/de/pfadfinder/songbook/layout/TocGenerator.kt @@ -0,0 +1,52 @@ +package de.pfadfinder.songbook.layout + +import de.pfadfinder.songbook.model.* + +class TocGenerator(private val config: BookConfig) { + + fun generate(pages: List, tocStartPage: Int): List { + val entries = mutableListOf() + val refAbbreviations = config.referenceBooks.associate { it.id to it.abbreviation } + + // Map songs to their page numbers + val songPages = mutableMapOf() // 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() + .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): 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 + } +} diff --git a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/GapFillerTest.kt b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/GapFillerTest.kt new file mode 100644 index 0000000..cb9f598 --- /dev/null +++ b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/GapFillerTest.kt @@ -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() + } + } +} diff --git a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt new file mode 100644 index 0000000..7d40215 --- /dev/null +++ b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt @@ -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 + } +} diff --git a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/PaginationEngineTest.kt b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/PaginationEngineTest.kt new file mode 100644 index 0000000..1387eac --- /dev/null +++ b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/PaginationEngineTest.kt @@ -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() } + (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() + (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() + (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() + (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() + (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 + } +} diff --git a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/StubFontMetrics.kt b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/StubFontMetrics.kt new file mode 100644 index 0000000..cfb8d92 --- /dev/null +++ b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/StubFontMetrics.kt @@ -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 +} diff --git a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/TocGeneratorTest.kt b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/TocGeneratorTest.kt new file mode 100644 index 0000000..dca009b --- /dev/null +++ b/layout/src/test/kotlin/de/pfadfinder/songbook/layout/TocGeneratorTest.kt @@ -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" + } +} diff --git a/model/build.gradle.kts b/model/build.gradle.kts new file mode 100644 index 0000000..3bb1d9c --- /dev/null +++ b/model/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("songbook-conventions") +} diff --git a/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt b/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt new file mode 100644 index 0000000..f5b0692 --- /dev/null +++ b/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt @@ -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 = 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" +) diff --git a/model/src/main/kotlin/de/pfadfinder/songbook/model/BookRenderer.kt b/model/src/main/kotlin/de/pfadfinder/songbook/model/BookRenderer.kt new file mode 100644 index 0000000..3dc2f18 --- /dev/null +++ b/model/src/main/kotlin/de/pfadfinder/songbook/model/BookRenderer.kt @@ -0,0 +1,7 @@ +package de.pfadfinder.songbook.model + +import java.io.OutputStream + +interface BookRenderer { + fun render(layout: LayoutResult, config: BookConfig, output: OutputStream) +} diff --git a/model/src/main/kotlin/de/pfadfinder/songbook/model/FontMetrics.kt b/model/src/main/kotlin/de/pfadfinder/songbook/model/FontMetrics.kt new file mode 100644 index 0000000..071c703 --- /dev/null +++ b/model/src/main/kotlin/de/pfadfinder/songbook/model/FontMetrics.kt @@ -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 +} diff --git a/model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt b/model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt new file mode 100644 index 0000000..4df1d44 --- /dev/null +++ b/model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt @@ -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, + val tocEntries: List +) + +data class TocEntry( + val title: String, + val pageNumber: Int, + val isAlias: Boolean = false, + val references: Map = emptyMap() // bookAbbrev → page +) diff --git a/model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt b/model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt new file mode 100644 index 0000000..6dabb6c --- /dev/null +++ b/model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt @@ -0,0 +1,31 @@ +package de.pfadfinder.songbook.model + +data class Song( + val title: String, + val aliases: List = emptyList(), + val lyricist: String? = null, + val composer: String? = null, + val key: String? = null, + val tags: List = emptyList(), + val notes: List = emptyList(), + val references: Map = emptyMap(), // bookId → page number + val capo: Int? = null, + val sections: List = emptyList() +) + +data class SongSection( + val type: SectionType, + val label: String? = null, + val lines: List = emptyList() +) + +enum class SectionType { + VERSE, CHORUS, BRIDGE, REPEAT +} + +data class SongLine(val segments: List) + +data class LineSegment( + val chord: String? = null, // null = no chord above this segment + val text: String +) diff --git a/parser/build.gradle.kts b/parser/build.gradle.kts new file mode 100644 index 0000000..7e1f6d0 --- /dev/null +++ b/parser/build.gradle.kts @@ -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") +} diff --git a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt new file mode 100644 index 0000000..a386b3c --- /dev/null +++ b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt @@ -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() + var lyricist: String? = null + var composer: String? = null + var key: String? = null + val tags = mutableListOf() + val notes = mutableListOf() + val references = mutableMapOf() + var capo: Int? = null + + val sections = mutableListOf() + + // Current section being built + var currentType: SectionType? = null + var currentLabel: String? = null + var currentLines = mutableListOf() + + 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() + 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? { + 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 + } +} diff --git a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ConfigParser.kt b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ConfigParser.kt new file mode 100644 index 0000000..bf32b7e --- /dev/null +++ b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ConfigParser.kt @@ -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) + } +} diff --git a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/Validator.kt b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/Validator.kt new file mode 100644 index 0000000..eb9f3b6 --- /dev/null +++ b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/Validator.kt @@ -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 { + val errors = mutableListOf() + + 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 { + 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 { + val errors = mutableListOf() + + 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 + } +} diff --git a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt new file mode 100644 index 0000000..48c0e80 --- /dev/null +++ b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt @@ -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" + } +} diff --git a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt new file mode 100644 index 0000000..84e1029 --- /dev/null +++ b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt @@ -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" + } +} diff --git a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ValidatorTest.kt b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ValidatorTest.kt new file mode 100644 index 0000000..b416cae --- /dev/null +++ b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ValidatorTest.kt @@ -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" + } +} diff --git a/renderer-pdf/build.gradle.kts b/renderer-pdf/build.gradle.kts new file mode 100644 index 0000000..e9f3c06 --- /dev/null +++ b/renderer-pdf/build.gradle.kts @@ -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") +} diff --git a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRenderer.kt b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRenderer.kt new file mode 100644 index 0000000..07a56dc --- /dev/null +++ b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRenderer.kt @@ -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) + } +} diff --git a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecorator.kt b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecorator.kt new file mode 100644 index 0000000..e1b3dbb --- /dev/null +++ b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecorator.kt @@ -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() + } +} diff --git a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt new file mode 100644 index 0000000..d397a9e --- /dev/null +++ b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt @@ -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() + 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() + 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 + } + } +} diff --git a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetrics.kt b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetrics.kt new file mode 100644 index 0000000..6bbd82f --- /dev/null +++ b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetrics.kt @@ -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() + + 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 + } +} diff --git a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRenderer.kt b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRenderer.kt new file mode 100644 index 0000000..b3f69bc --- /dev/null +++ b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRenderer.kt @@ -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) { + 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 + } +} diff --git a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRendererTest.kt b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRendererTest.kt new file mode 100644 index 0000000..106d7de --- /dev/null +++ b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRendererTest.kt @@ -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 + } + } +} diff --git a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecoratorTest.kt b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecoratorTest.kt new file mode 100644 index 0000000..5d9aca1 --- /dev/null +++ b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecoratorTest.kt @@ -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) + } + } +} diff --git a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRendererTest.kt b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRendererTest.kt new file mode 100644 index 0000000..08709c8 --- /dev/null +++ b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRendererTest.kt @@ -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 + } +} diff --git a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetricsTest.kt b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetricsTest.kt new file mode 100644 index 0000000..af9cfe5 --- /dev/null +++ b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetricsTest.kt @@ -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 + } +} diff --git a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRendererTest.kt b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRendererTest.kt new file mode 100644 index 0000000..8d84843 --- /dev/null +++ b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRendererTest.kt @@ -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 + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d733059 --- /dev/null +++ b/settings.gradle.kts @@ -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") diff --git a/songbook.yaml b/songbook.yaml new file mode 100644 index 0000000..5cc7c7c --- /dev/null +++ b/songbook.yaml @@ -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" diff --git a/songs/abend-wird-es-wieder.chopro b/songs/abend-wird-es-wieder.chopro new file mode 100644 index 0000000..595e45e --- /dev/null +++ b/songs/abend-wird-es-wieder.chopro @@ -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} diff --git a/songs/auf-auf-zum-froehlichen-jagen.chopro b/songs/auf-auf-zum-froehlichen-jagen.chopro new file mode 100644 index 0000000..c14c0b4 --- /dev/null +++ b/songs/auf-auf-zum-froehlichen-jagen.chopro @@ -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} diff --git a/songs/die-gedanken-sind-frei.chopro b/songs/die-gedanken-sind-frei.chopro new file mode 100644 index 0000000..afcecac --- /dev/null +++ b/songs/die-gedanken-sind-frei.chopro @@ -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} diff --git a/songs/hejo-spann-den-wagen-an.chopro b/songs/hejo-spann-den-wagen-an.chopro new file mode 100644 index 0000000..915aa79 --- /dev/null +++ b/songs/hejo-spann-den-wagen-an.chopro @@ -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} diff --git a/songs/kein-schoener-land.chopro b/songs/kein-schoener-land.chopro new file mode 100644 index 0000000..f5fac41 --- /dev/null +++ b/songs/kein-schoener-land.chopro @@ -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}