From 4024d0e421f58552a5c957953f1bed6d2b83954c Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Wed, 1 Apr 2026 08:23:57 +0200 Subject: [PATCH] Rewrite songbook as pure LaTeX project (Carmina Leonis style) Replace the Kotlin/Gradle multi-module pipeline with a pure LaTeX songbook using the leadsheets package and LuaLaTeX. Style matches the Carmina Leonis (CL6) scout songbook: Fraktur titles, chords above lyrics, metadata at page bottom, reference book footer. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 34 +- CLAUDE.md | 118 ++-- Makefile | 23 + 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 -- fonts/UnifrakturMaguntia-Book.ttf | Bin 0 -> 88508 bytes 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-style.sty | 100 ++++ songbook.tex | 31 + songbook.yaml | 37 -- songs/abend-wird-es-wieder.chopro | 27 - songs/abend-wird-es-wieder.tex | 31 + songs/auf-auf-zum-froehlichen-jagen.chopro | 26 - songs/auf-auf-zum-froehlichen-jagen.tex | 29 + songs/die-gedanken-sind-frei.chopro | 42 -- songs/die-gedanken-sind-frei.tex | 46 ++ songs/hejo-spann-den-wagen-an.chopro | 18 - songs/hejo-spann-den-wagen-an.tex | 24 + songs/kein-schoener-land.chopro | 33 -- songs/kein-schoener-land.tex | 37 ++ 65 files changed, 402 insertions(+), 5121 deletions(-) create mode 100644 Makefile delete mode 100644 app/build.gradle.kts delete mode 100644 app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt delete mode 100644 app/src/test/kotlin/de/pfadfinder/songbook/app/SongbookPipelineTest.kt delete mode 100644 build.gradle.kts delete mode 100644 buildSrc/build.gradle.kts delete mode 100644 buildSrc/src/main/kotlin/songbook-conventions.gradle.kts delete mode 100644 cli/build.gradle.kts delete mode 100644 cli/src/main/kotlin/de/pfadfinder/songbook/cli/BuildCommand.kt delete mode 100644 cli/src/main/kotlin/de/pfadfinder/songbook/cli/Main.kt delete mode 100644 cli/src/main/kotlin/de/pfadfinder/songbook/cli/ValidateCommand.kt create mode 100644 fonts/UnifrakturMaguntia-Book.ttf delete mode 100644 gradle.properties delete mode 100644 gui/build.gradle.kts delete mode 100644 gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt delete mode 100644 layout/build.gradle.kts delete mode 100644 layout/src/main/kotlin/de/pfadfinder/songbook/layout/GapFiller.kt delete mode 100644 layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt delete mode 100644 layout/src/main/kotlin/de/pfadfinder/songbook/layout/PaginationEngine.kt delete mode 100644 layout/src/main/kotlin/de/pfadfinder/songbook/layout/TocGenerator.kt delete mode 100644 layout/src/test/kotlin/de/pfadfinder/songbook/layout/GapFillerTest.kt delete mode 100644 layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt delete mode 100644 layout/src/test/kotlin/de/pfadfinder/songbook/layout/PaginationEngineTest.kt delete mode 100644 layout/src/test/kotlin/de/pfadfinder/songbook/layout/StubFontMetrics.kt delete mode 100644 layout/src/test/kotlin/de/pfadfinder/songbook/layout/TocGeneratorTest.kt delete mode 100644 model/build.gradle.kts delete mode 100644 model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt delete mode 100644 model/src/main/kotlin/de/pfadfinder/songbook/model/BookRenderer.kt delete mode 100644 model/src/main/kotlin/de/pfadfinder/songbook/model/FontMetrics.kt delete mode 100644 model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt delete mode 100644 model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt delete mode 100644 parser/build.gradle.kts delete mode 100644 parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt delete mode 100644 parser/src/main/kotlin/de/pfadfinder/songbook/parser/ConfigParser.kt delete mode 100644 parser/src/main/kotlin/de/pfadfinder/songbook/parser/Validator.kt delete mode 100644 parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt delete mode 100644 parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt delete mode 100644 parser/src/test/kotlin/de/pfadfinder/songbook/parser/ValidatorTest.kt delete mode 100644 renderer-pdf/build.gradle.kts delete mode 100644 renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRenderer.kt delete mode 100644 renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecorator.kt delete mode 100644 renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt delete mode 100644 renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetrics.kt delete mode 100644 renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRenderer.kt delete mode 100644 renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRendererTest.kt delete mode 100644 renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecoratorTest.kt delete mode 100644 renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRendererTest.kt delete mode 100644 renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetricsTest.kt delete mode 100644 renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRendererTest.kt delete mode 100644 settings.gradle.kts create mode 100644 songbook-style.sty create mode 100644 songbook.tex delete mode 100644 songbook.yaml delete mode 100644 songs/abend-wird-es-wieder.chopro create mode 100644 songs/abend-wird-es-wieder.tex delete mode 100644 songs/auf-auf-zum-froehlichen-jagen.chopro create mode 100644 songs/auf-auf-zum-froehlichen-jagen.tex delete mode 100644 songs/die-gedanken-sind-frei.chopro create mode 100644 songs/die-gedanken-sind-frei.tex delete mode 100644 songs/hejo-spann-den-wagen-an.chopro create mode 100644 songs/hejo-spann-den-wagen-an.tex delete mode 100644 songs/kein-schoener-land.chopro create mode 100644 songs/kein-schoener-land.tex diff --git a/.gitignore b/.gitignore index ac0e1b5..02c805f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,28 @@ -# Gradle -.gradle/ -build/ -buildSrc/build/ +# LaTeX build artifacts +*.aux +*.log +*.out +*.toc +*.fls +*.fdb_latexmk +*.synctex.gz +*.synctex(busy) +*.sxd +*.sxc -# IDE -.idea/ -*.iml -.vscode/ +# Output directory +output/ -# OS +# OS files .DS_Store Thumbs.db -# Output -output/ - -# Kotlin -*.class +# Editor files +.idea/ +*.iml +.vscode/ +*~ +*.swp # Claude .claude/ diff --git a/CLAUDE.md b/CLAUDE.md index 93877e8..fa054c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,79 +2,83 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Build & Test Commands +## Build Commands ```bash -# Build everything -gradle build +# Build the songbook PDF (two-pass for TOC) +make -# Run all tests -gradle test +# Remove auxiliary files +make clean -# 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 +# Remove everything including PDF +make distclean ``` -Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.1. +Requires LuaLaTeX (TeX Live) and the `leadsheets` package. -## Architecture +## Project Structure -**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) +songbook.tex # Main document (title page, TOC, song inputs) +songbook-style.sty # Style package (geometry, fonts, leadsheets config) +songs/ # One .tex file per song +fonts/ # Font files (UnifrakturMaguntia for titles) +images/ # Filler images (empty for now) +Makefile # Build rules (lualatex, two passes) +output/ # Generated PDF (gitignored) ``` -`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. +## How It Works -**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 +Pure LaTeX songbook using the `leadsheets` package with LuaLaTeX. The style matches the Carmina Leonis songbook format: +- Song titles in Fraktur/blackletter font (UnifrakturMaguntia) +- Chords above lyrics in regular weight, black +- No verse labels (verses separated by blank lines) +- Metadata (Worte/Weise) at bottom of each song page +- Reference book cross-references (MO, PfLB) in footer +- Each song starts on a new page +- A5 twoside format with page numbers at bottom-outer ## Song Format -ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples. +Each song uses the `leadsheets` `song` environment: -## Test Patterns +```latex +\begin{song}{ + title = Song Title, + lyrics = Lyricist, + composer = Composer, + key = G, + mundorgel = 42, + pfadfinderliederbuch = 118, + note = {Optional note text.}, +} -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. +\begin{verse} +\chord{G}Lyrics with \chord{D}chords above. \\ +Next \chord{C}line here. +\end{verse} -## Package +\begin{verse} +Second verse without chords (or with). +\end{verse} -All code under `de.pfadfinder.songbook.*` — subpackages match module names (`.model`, `.parser`, `.layout`, `.renderer.pdf`, `.app`, `.cli`, `.gui`). +\end{song} +``` + +**Important constraints:** +- Use `\\` for line breaks within verses (not blank lines) +- Never place two `\chord{}` commands without a space between them — split compound words with a hyphen: `\chord{D}Abend- \chord{A}zeit.` +- Custom properties: `alias`, `note`, `mundorgel`, `pfadfinderliederbuch` +- Verse types: `verse` (no label), `verse*` (for custom-labeled sections like Kanon, Ref.) +- `musicsymbols` library skipped (requires `musix11` font not installed) + +## Style Details (songbook-style.sty) + +- Page geometry: A5, margins (top 15mm, bottom 20mm, inner 20mm, outer 12mm) +- Body font: TeX Gyre Heros (Helvetica clone) +- Title font: UnifrakturMaguntia (Fraktur/blackletter, from `fonts/` directory) +- Chord format: small, regular weight, black +- Song title template: Fraktur title only (metadata rendered at bottom via `after-song` hook) +- Reference style based on Carmina Leonis (Pfadfinder scout songbook) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6832aa7 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +MAIN = songbook +ENGINE = lualatex +OUTDIR = output +FLAGS = --output-directory=$(OUTDIR) --interaction=nonstopmode + +.PHONY: all clean distclean + +all: $(OUTDIR)/$(MAIN).pdf + +$(OUTDIR): + mkdir -p $(OUTDIR) + +$(OUTDIR)/$(MAIN).pdf: $(MAIN).tex songbook-style.sty songs/*.tex | $(OUTDIR) + TEXINPUTS=.:$(shell pwd): $(ENGINE) $(FLAGS) $(MAIN).tex + TEXINPUTS=.:$(shell pwd): $(ENGINE) $(FLAGS) $(MAIN).tex + +clean: + rm -f $(OUTDIR)/*.aux $(OUTDIR)/*.log $(OUTDIR)/*.out \ + $(OUTDIR)/*.toc $(OUTDIR)/*.fls $(OUTDIR)/*.fdb_latexmk \ + $(OUTDIR)/*.sxd $(OUTDIR)/*.sxc + +distclean: clean + rm -f $(OUTDIR)/$(MAIN).pdf diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index 54c1c55..0000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 5557a96..0000000 --- a/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index 924f06d..0000000 --- a/app/src/test/kotlin/de/pfadfinder/songbook/app/SongbookPipelineTest.kt +++ /dev/null @@ -1,534 +0,0 @@ -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 deleted file mode 100644 index 760cc36..0000000 --- a/build.gradle.kts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index ba0cdf1..0000000 --- a/buildSrc/build.gradle.kts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 749c1f1..0000000 --- a/buildSrc/src/main/kotlin/songbook-conventions.gradle.kts +++ /dev/null @@ -1,11 +0,0 @@ -plugins { - kotlin("jvm") -} - -kotlin { - jvmToolchain(21) -} - -tasks.withType { - useJUnitPlatform() -} diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts deleted file mode 100644 index d959309..0000000 --- a/cli/build.gradle.kts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 0d9c87a..0000000 --- a/cli/src/main/kotlin/de/pfadfinder/songbook/cli/BuildCommand.kt +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 158a84c..0000000 --- a/cli/src/main/kotlin/de/pfadfinder/songbook/cli/Main.kt +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 90ed786..0000000 --- a/cli/src/main/kotlin/de/pfadfinder/songbook/cli/ValidateCommand.kt +++ /dev/null @@ -1,34 +0,0 @@ -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/fonts/UnifrakturMaguntia-Book.ttf b/fonts/UnifrakturMaguntia-Book.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6429ce25445be1568534f5bd7f2b42f42fb6ff17 GIT binary patch literal 88508 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
`8oy4qQYvH@mP5 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⪼49w=fBYWn zC(!;C*8N8~{xIUsTOiX*km?#ZK2JE{Fpt~dI*;V)oJF2;%unFE4!+KTtDnTT@bhCD zudhh?Y-;}#*FU29{a?ByIPj&ahQkja20nr~cLC#XK;O3@_pb)0v6~lsoCjUx;XJ5n z+V|^Y1AOFp9sK8U8$6rC@iX{tf}hl-Pvg6u^5|12Uj?i=`xLl72lzI)BTsBdWYj!^ zr01Zg4{2ae3Q1zv)Wo{mwZ1U{!NuwCDvv=|Jk4U TYo0v+%Wu8K;|s5T;j#Y)cz@ZB literal 0 HcmV?d00001 diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index eb30735..0000000 --- a/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.java.home=/usr/lib/jvm/java-21-openjdk diff --git a/gui/build.gradle.kts b/gui/build.gradle.kts deleted file mode 100644 index 6bd309b..0000000 --- a/gui/build.gradle.kts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 87bf169..0000000 --- a/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt +++ /dev/null @@ -1,347 +0,0 @@ -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 deleted file mode 100644 index 3fb3afb..0000000 --- a/layout/build.gradle.kts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 47d451e..0000000 --- a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/GapFiller.kt +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 0c615d7..0000000 --- a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/MeasurementEngine.kt +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index d81b780..0000000 --- a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/PaginationEngine.kt +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index ecc6390..0000000 --- a/layout/src/main/kotlin/de/pfadfinder/songbook/layout/TocGenerator.kt +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index cb9f598..0000000 --- a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/GapFillerTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 7d40215..0000000 --- a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/MeasurementEngineTest.kt +++ /dev/null @@ -1,261 +0,0 @@ -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 deleted file mode 100644 index 1387eac..0000000 --- a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/PaginationEngineTest.kt +++ /dev/null @@ -1,205 +0,0 @@ -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 deleted file mode 100644 index cfb8d92..0000000 --- a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/StubFontMetrics.kt +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index dca009b..0000000 --- a/layout/src/test/kotlin/de/pfadfinder/songbook/layout/TocGeneratorTest.kt +++ /dev/null @@ -1,211 +0,0 @@ -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 deleted file mode 100644 index 3bb1d9c..0000000 --- a/model/build.gradle.kts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index f5b0692..0000000 --- a/model/src/main/kotlin/de/pfadfinder/songbook/model/BookConfig.kt +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 3dc2f18..0000000 --- a/model/src/main/kotlin/de/pfadfinder/songbook/model/BookRenderer.kt +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 071c703..0000000 --- a/model/src/main/kotlin/de/pfadfinder/songbook/model/FontMetrics.kt +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 4df1d44..0000000 --- a/model/src/main/kotlin/de/pfadfinder/songbook/model/Layout.kt +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 6dabb6c..0000000 --- a/model/src/main/kotlin/de/pfadfinder/songbook/model/Song.kt +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 7e1f6d0..0000000 --- a/parser/build.gradle.kts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index a386b3c..0000000 --- a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ChordProParser.kt +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index bf32b7e..0000000 --- a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/ConfigParser.kt +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index eb9f3b6..0000000 --- a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/Validator.kt +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 48c0e80..0000000 --- a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ChordProParserTest.kt +++ /dev/null @@ -1,488 +0,0 @@ -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 deleted file mode 100644 index 84e1029..0000000 --- a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -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 deleted file mode 100644 index b416cae..0000000 --- a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ValidatorTest.kt +++ /dev/null @@ -1,209 +0,0 @@ -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 deleted file mode 100644 index e9f3c06..0000000 --- a/renderer-pdf/build.gradle.kts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 07a56dc..0000000 --- a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRenderer.kt +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index e1b3dbb..0000000 --- a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecorator.kt +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index d397a9e..0000000 --- a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRenderer.kt +++ /dev/null @@ -1,248 +0,0 @@ -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 deleted file mode 100644 index 6bbd82f..0000000 --- a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetrics.kt +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index b3f69bc..0000000 --- a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRenderer.kt +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 106d7de..0000000 --- a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/ChordLyricRendererTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 5d9aca1..0000000 --- a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PageDecoratorTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 08709c8..0000000 --- a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfBookRendererTest.kt +++ /dev/null @@ -1,420 +0,0 @@ -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 deleted file mode 100644 index af9cfe5..0000000 --- a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetricsTest.kt +++ /dev/null @@ -1,161 +0,0 @@ -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 deleted file mode 100644 index 8d84843..0000000 --- a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/TocRendererTest.kt +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index d733059..0000000 --- a/settings.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -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-style.sty b/songbook-style.sty new file mode 100644 index 0000000..7943769 --- /dev/null +++ b/songbook-style.sty @@ -0,0 +1,100 @@ +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{songbook-style}[2026/04/01 Pfadfinder Liederbuch Style] + +% --- Core packages --- +\RequirePackage{fontspec} +\RequirePackage[ngerman]{babel} +\RequirePackage[ + a5paper, + top=15mm, + bottom=20mm, + inner=20mm, + outer=12mm +]{geometry} +\RequirePackage[hidelinks]{hyperref} +\RequirePackage{fancyhdr} +\RequirePackage{xcolor} +\RequirePackage[minimal]{leadsheets} +% Pre-mark musicsymbols as loaded to avoid missing musix11 font error +\ExplSyntaxOn +\cs_new:cpn {leadsheets-library-musicsymbols-loaded} {} +\ExplSyntaxOff +\useleadsheetslibraries{chordnames,chords,shorthands,properties,templates,translations,songs} + +% --- Font setup --- +\setmainfont{TeX Gyre Heros} +\newfontfamily\frakfont{UnifrakturMaguntia-Book}[Path=fonts/,Extension=.ttf] + +% --- Page style: page numbers at bottom-outer, large --- +\pagestyle{fancy} +\fancyhf{} +\fancyfoot[LE]{\large\bfseries\thepage} +\fancyfoot[RO]{\large\bfseries\thepage} +\renewcommand{\headrulewidth}{0pt} +\renewcommand{\footrulewidth}{0pt} + +% --- Custom song properties --- +\definesongproperty{alias} +\definesongproperty{note} +\definesongproperty{mundorgel} +\definesongproperty{pfadfinderliederbuch} + +% --- leadsheets settings --- +\setleadsheets{ + title-template = songbook, + verse/numbered = false, + verse/named = false, + chorus/named = false, + chorus/numbered = false, + after-song = \songendsection, +} + +% --- Chord appearance: regular weight, black (matching reference style) --- +\setchords{ + format = \small, +} + +% --- Command to render metadata at bottom of song --- +\newcommand{\songendsection}{% + \vfill + \ifsongproperty{note}{% + {\footnotesize\songproperty{note}\par\smallskip}% + }{}% + \begingroup\footnotesize + \ifsongproperty{lyrics}{% + \ifsongproperty{composer}{% + Worte: \songproperty{lyrics}\par + Weise: \songproperty{composer}\par + }{% + Worte und Weise: \songproperty{lyrics}\par + }% + }{% + \ifsongproperty{composer}{% + Weise: \songproperty{composer}\par + }{}% + }% + \endgroup + % Reference book footer + \vspace{3mm}% + \begingroup\footnotesize + \noindent + \makebox[0.18\textwidth][c]{MO}% + \makebox[0.18\textwidth][c]{PfLB}% + \hfill\par\noindent + \makebox[0.18\textwidth][c]{% + \ifsongproperty{mundorgel}{\songproperty{mundorgel}}{}% + }% + \makebox[0.18\textwidth][c]{% + \ifsongproperty{pfadfinderliederbuch}{\songproperty{pfadfinderliederbuch}}{}% + }% + \hfill + \endgroup + \newpage +} + +% --- Song title template: Fraktur title, metadata at bottom --- +\definesongtitletemplate{songbook}{% + {\LARGE\frakfont\songproperty{title}\par}% + \addcontentsline{toc}{section}{\songproperty{title}}% + \vspace{4mm}% +} diff --git a/songbook.tex b/songbook.tex new file mode 100644 index 0000000..da1b606 --- /dev/null +++ b/songbook.tex @@ -0,0 +1,31 @@ +% songbook.tex - Pfadfinder Liederbuch +\documentclass[a5paper, 10pt, twoside]{article} + +\usepackage{songbook-style} + +\begin{document} + +% --- Title page --- +\begin{titlepage} + \centering + \vspace*{3cm} + {\Huge\bfseries Pfadfinder Liederbuch\par} + \vspace{1cm} + {\Large Beispiel-Ausgabe\par} + \vspace{2cm} + {\large 1. Auflage, 2026\par} + \vfill +\end{titlepage} + +% --- Table of Contents --- +\tableofcontents +\clearpage + +% --- Songs (alphabetical) --- +\input{songs/abend-wird-es-wieder} +\input{songs/auf-auf-zum-froehlichen-jagen} +\input{songs/die-gedanken-sind-frei} +\input{songs/hejo-spann-den-wagen-an} +\input{songs/kein-schoener-land} + +\end{document} diff --git a/songbook.yaml b/songbook.yaml deleted file mode 100644 index 5cc7c7c..0000000 --- a/songbook.yaml +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 595e45e..0000000 --- a/songs/abend-wird-es-wieder.chopro +++ /dev/null @@ -1,27 +0,0 @@ -{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/abend-wird-es-wieder.tex b/songs/abend-wird-es-wieder.tex new file mode 100644 index 0000000..92d2c1f --- /dev/null +++ b/songs/abend-wird-es-wieder.tex @@ -0,0 +1,31 @@ +\begin{song}{ + title = Abend wird es wieder, + lyrics = {Christian Gottlob Barth, 1836}, + composer = Volksweise, + key = C, + tags = Abendlied, + pfadfinderliederbuch = 12, +} + +\begin{verse} +\chord{C}Abend wird es \chord{G}wieder, \\ +\chord{G}über Wald und \chord{C}Feld \\ +säuselt \chord{F}Frieden \chord{C}nieder, \\ +und es \chord{G}ruht die \chord{C}Welt. +\end{verse} + +\begin{verse} +\chord{C}Nur der Bach er\chord{G}gießet \\ +\chord{G}sich am Felsen \chord{C}dort, \\ +und er \chord{F}braust und \chord{C}fließet \\ +immer, \chord{G}immer \chord{C}fort. +\end{verse} + +\begin{verse} +\chord{C}Und kein Abend \chord{G}bringet \\ +\chord{G}Frieden ihm und \chord{C}Ruh, \\ +keine \chord{F}Glocke \chord{C}klinget \\ +ihm ein \chord{G}Rastlied \chord{C}zu. +\end{verse} + +\end{song} diff --git a/songs/auf-auf-zum-froehlichen-jagen.chopro b/songs/auf-auf-zum-froehlichen-jagen.chopro deleted file mode 100644 index c14c0b4..0000000 --- a/songs/auf-auf-zum-froehlichen-jagen.chopro +++ /dev/null @@ -1,26 +0,0 @@ -{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/auf-auf-zum-froehlichen-jagen.tex b/songs/auf-auf-zum-froehlichen-jagen.tex new file mode 100644 index 0000000..fb5c2e8 --- /dev/null +++ b/songs/auf-auf-zum-froehlichen-jagen.tex @@ -0,0 +1,29 @@ +\begin{song}{ + title = {Auf, auf zum fröhlichen Jagen}, + lyrics = {Traditionell, 18. Jahrhundert}, + composer = Volksweise, + key = F, + tags = {Volkslied, Jagd}, +} + +\begin{verse} +\chord{F}Auf, auf zum fröhlichen \chord{C}Jagen, \\ +auf \chord{C}in die grüne \chord{F}Heid'! \\ +Es \chord{F}gibt nichts Schönres \chord{Bb}auf Erden, \\ +als \chord{C}jetzt zur Herbstes\chord{F}zeit. +\end{verse} + +\begin{verse*} +Ref.: \\ +Halli, hallo, halli, hallo, \\ +auf \chord{C}in die grüne \chord{F}Heid'! +\end{verse*} + +\begin{verse} +\chord{F}Der Hirsch, der springt im \chord{C}Walde, \\ +das \chord{C}Reh steht auf der \chord{F}Flur, \\ +die \chord{F}Vöglein singen \chord{Bb}alle \\ +zur \chord{C}schönen Jägerei\chord{F}natur. +\end{verse} + +\end{song} diff --git a/songs/die-gedanken-sind-frei.chopro b/songs/die-gedanken-sind-frei.chopro deleted file mode 100644 index afcecac..0000000 --- a/songs/die-gedanken-sind-frei.chopro +++ /dev/null @@ -1,42 +0,0 @@ -{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/die-gedanken-sind-frei.tex b/songs/die-gedanken-sind-frei.tex new file mode 100644 index 0000000..eb5d00b --- /dev/null +++ b/songs/die-gedanken-sind-frei.tex @@ -0,0 +1,46 @@ +\begin{song}{ + title = Die Gedanken sind frei, + alias = Gedankenfreiheit, + lyrics = Deutsches Volkslied, + composer = {Deutsches Volkslied, ca. 1810}, + key = G, + tags = {Volkslied, Freiheit}, + note = {Eines der bekanntesten deutschen Volkslieder. Text erstmals 1780.}, + mundorgel = 42, + pfadfinderliederbuch = 118, +} + +\begin{verse} +Die Ge\chord{G}danken sind \chord{D}frei, \\ +wer \chord{D}kann sie er\chord{G}raten? \\ +Sie \chord{G}fliehen vor\chord{C}bei \\ +wie \chord{D}nächtliche \chord{G}Schatten. \\ +Kein \chord{C}Mensch kann sie \chord{G}wissen, \\ +kein \chord{Am}Jäger er\chord{D}schießen. \\ +Es \chord{G}bleibet da\chord{C}bei: \\ +Die Ge\chord{D}danken sind \chord{G}frei! +\end{verse} + +\begin{verse} +Ich \chord{G}denke, was ich \chord{D}will \\ +und \chord{D}was mich be\chord{G}glücket, \\ +doch \chord{G}alles in der \chord{C}Still', \\ +und \chord{D}wie es sich \chord{G}schicket. \\ +Mein \chord{C}Wunsch und Be\chord{G}gehren \\ +kann \chord{Am}niemand ver\chord{D}wehren, \\ +es \chord{G}bleibet da\chord{C}bei: \\ +Die Ge\chord{D}danken sind \chord{G}frei! +\end{verse} + +\begin{verse} +Und \chord{G}sperrt man mich \chord{D}ein \\ +im \chord{D}finsteren \chord{G}Kerker, \\ +das \chord{G}alles sind rein \\ +\chord{C}ver- \chord{D}gebliche \chord{G}Werke. \\ +Denn \chord{C}meine Ge\chord{G}danken \\ +zer\chord{Am}reißen die \chord{D}Schranken \\ +und \chord{G}Mauern ent\chord{C}zwei: \\ +Die Ge\chord{D}danken sind \chord{G}frei! +\end{verse} + +\end{song} diff --git a/songs/hejo-spann-den-wagen-an.chopro b/songs/hejo-spann-den-wagen-an.chopro deleted file mode 100644 index 915aa79..0000000 --- a/songs/hejo-spann-den-wagen-an.chopro +++ /dev/null @@ -1,18 +0,0 @@ -{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/hejo-spann-den-wagen-an.tex b/songs/hejo-spann-den-wagen-an.tex new file mode 100644 index 0000000..9e8c471 --- /dev/null +++ b/songs/hejo-spann-den-wagen-an.tex @@ -0,0 +1,24 @@ +\begin{song}{ + title = {Hejo, spann den Wagen an}, + lyrics = Traditionell, + composer = Traditionell, + key = Am, + tags = {Kanon, Fahrt}, + mundorgel = 15, +} + +\begin{verse*} +Kanon: \\ +\chord{Am}Hejo, spann den Wagen an, \\ +denn der \chord{G}Wind treibt \chord{Am}Regen übers Land. \\ +\chord{Am}Hol die goldnen Garben rein, \\ +denn der \chord{G}Wind treibt \chord{Am}Regen übers Land. +\end{verse*} + +\begin{verse*} +2. Stimme: \\ +\chord{Am}Hejo, spann den Wagen an, \\ +denn der \chord{G}Wind treibt \chord{Am}Regen übers Land. +\end{verse*} + +\end{song} diff --git a/songs/kein-schoener-land.chopro b/songs/kein-schoener-land.chopro deleted file mode 100644 index f5fac41..0000000 --- a/songs/kein-schoener-land.chopro +++ /dev/null @@ -1,33 +0,0 @@ -{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} diff --git a/songs/kein-schoener-land.tex b/songs/kein-schoener-land.tex new file mode 100644 index 0000000..271eedf --- /dev/null +++ b/songs/kein-schoener-land.tex @@ -0,0 +1,37 @@ +\begin{song}{ + title = Kein schöner Land, + alias = {Kein schöner Land in dieser Zeit}, + lyrics = {Anton Wilhelm von Zuccalmaglio}, + composer = {Volksweise, 1840}, + key = D, + tags = {Volkslied, Abendlied}, + note = {Veröffentlicht 1840 in ``Deutsche Volkslieder mit ihren Original-Weisen''.}, + mundorgel = 88, + pfadfinderliederbuch = 65, +} + +\begin{verse} +Kein \chord{D}schöner Land in \chord{A}dieser Zeit, \\ +als \chord{A}hier das unsre \chord{D}weit und breit, \\ +wo \chord{D}wir uns \chord{G}finden \\ +wohl \chord{D}unter \chord{A}Linden \\ +zur \chord{D}Abend- \chord{A}zeit. +\end{verse} + +\begin{verse} +Da \chord{D}haben wir so \chord{A}manche Stund' \\ +ge\chord{A}sessen da in \chord{D}froher Rund' \\ +und \chord{D}taten \chord{G}singen, \\ +die \chord{D}Lieder \chord{A}klingen \\ +im \chord{D}Eichen- \chord{A}grund. +\end{verse} + +\begin{verse} +Dass \chord{D}wir uns hier in \chord{A}diesem Tal \\ +noch \chord{A}treffen so viel \chord{D}hundertmal, \\ +Gott \chord{D}mag es \chord{G}schenken, \\ +Gott \chord{D}mag es \chord{A}lenken, \\ +er \chord{D}hat die \chord{A}Gnad'. +\end{verse} + +\end{song}