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 0000000..6429ce2 Binary files /dev/null and b/fonts/UnifrakturMaguntia-Book.ttf differ 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}