Rewrite songbook as pure LaTeX project (Carmina Leonis style)
Replace the Kotlin/Gradle multi-module pipeline with a pure LaTeX songbook using the leadsheets package and LuaLaTeX. Style matches the Carmina Leonis (CL6) scout songbook: Fraktur titles, chords above lyrics, metadata at page bottom, reference book footer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
34
.gitignore
vendored
34
.gitignore
vendored
@@ -1,22 +1,28 @@
|
|||||||
# Gradle
|
# LaTeX build artifacts
|
||||||
.gradle/
|
*.aux
|
||||||
build/
|
*.log
|
||||||
buildSrc/build/
|
*.out
|
||||||
|
*.toc
|
||||||
|
*.fls
|
||||||
|
*.fdb_latexmk
|
||||||
|
*.synctex.gz
|
||||||
|
*.synctex(busy)
|
||||||
|
*.sxd
|
||||||
|
*.sxc
|
||||||
|
|
||||||
# IDE
|
# Output directory
|
||||||
.idea/
|
output/
|
||||||
*.iml
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
# OS
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Output
|
# Editor files
|
||||||
output/
|
.idea/
|
||||||
|
*.iml
|
||||||
# Kotlin
|
.vscode/
|
||||||
*.class
|
*~
|
||||||
|
*.swp
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
118
CLAUDE.md
118
CLAUDE.md
@@ -2,79 +2,83 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Build & Test Commands
|
## Build Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build everything
|
# Build the songbook PDF (two-pass for TOC)
|
||||||
gradle build
|
make
|
||||||
|
|
||||||
# Run all tests
|
# Remove auxiliary files
|
||||||
gradle test
|
make clean
|
||||||
|
|
||||||
# Run tests for a specific module
|
# Remove everything including PDF
|
||||||
gradle :parser:test
|
make distclean
|
||||||
gradle :layout:test
|
|
||||||
gradle :renderer-pdf:test
|
|
||||||
gradle :app:test
|
|
||||||
|
|
||||||
# Run a single test class
|
|
||||||
gradle :parser:test --tests ChordProParserTest
|
|
||||||
|
|
||||||
# Run a single test method
|
|
||||||
gradle :parser:test --tests "ChordProParserTest.parse complete song"
|
|
||||||
|
|
||||||
# Build and run CLI
|
|
||||||
gradle :cli:run --args="build -d /path/to/project"
|
|
||||||
gradle :cli:run --args="validate -d /path/to/project"
|
|
||||||
|
|
||||||
# Launch GUI
|
|
||||||
gradle :gui:run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.1.
|
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
|
songbook.tex # Main document (title page, TOC, song inputs)
|
||||||
model ← layout
|
songbook-style.sty # Style package (geometry, fonts, leadsheets config)
|
||||||
model ← renderer-pdf
|
songs/ # One .tex file per song
|
||||||
parser, layout, renderer-pdf ← app
|
fonts/ # Font files (UnifrakturMaguntia for titles)
|
||||||
app ← cli (Clikt)
|
images/ # Filler images (empty for now)
|
||||||
app, parser ← gui (Compose Desktop)
|
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.
|
Pure LaTeX songbook using the `leadsheets` package with LuaLaTeX. The style matches the Carmina Leonis songbook format:
|
||||||
|
- Song titles in Fraktur/blackletter font (UnifrakturMaguntia)
|
||||||
## Key Types
|
- Chords above lyrics in regular weight, black
|
||||||
|
- No verse labels (verses separated by blank lines)
|
||||||
- `Song` → sections → `SongLine` → `LineSegment(chord?, text)` — chord is placed above the text segment
|
- Metadata (Worte/Weise) at bottom of each song page
|
||||||
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`
|
- Reference book cross-references (MO, PfLB) in footer
|
||||||
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
|
- Each song starts on a new page
|
||||||
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
|
- A5 twoside format with page numbers at bottom-outer
|
||||||
|
|
||||||
## Song Format
|
## 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)
|
||||||
|
|||||||
23
Makefile
Normal file
23
Makefile
Normal file
@@ -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
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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<ValidationError> = emptyList(),
|
|
||||||
val songCount: Int = 0,
|
|
||||||
val pageCount: Int = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
class SongbookPipeline(private val projectDir: File) {
|
|
||||||
|
|
||||||
fun build(): BuildResult {
|
|
||||||
// 1. Parse config
|
|
||||||
val configFile = File(projectDir, "songbook.yaml")
|
|
||||||
if (!configFile.exists()) {
|
|
||||||
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
|
||||||
}
|
|
||||||
logger.info { "Parsing config: ${configFile.absolutePath}" }
|
|
||||||
val config = ConfigParser.parse(configFile)
|
|
||||||
|
|
||||||
// Validate config
|
|
||||||
val configErrors = Validator.validateConfig(config)
|
|
||||||
if (configErrors.isNotEmpty()) {
|
|
||||||
return BuildResult(false, errors = configErrors)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Parse songs
|
|
||||||
val songsDir = File(projectDir, config.songs.directory)
|
|
||||||
if (!songsDir.exists() || !songsDir.isDirectory) {
|
|
||||||
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "Songs directory not found")))
|
|
||||||
}
|
|
||||||
|
|
||||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
|
||||||
?.sortedBy { it.name }
|
|
||||||
?: emptyList()
|
|
||||||
|
|
||||||
if (songFiles.isEmpty()) {
|
|
||||||
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found")))
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info { "Found ${songFiles.size} song files" }
|
|
||||||
|
|
||||||
val songs = mutableListOf<Song>()
|
|
||||||
val allErrors = mutableListOf<ValidationError>()
|
|
||||||
|
|
||||||
for (file in songFiles) {
|
|
||||||
try {
|
|
||||||
val song = ChordProParser.parseFile(file)
|
|
||||||
val songErrors = Validator.validateSong(song, file.name)
|
|
||||||
if (songErrors.isNotEmpty()) {
|
|
||||||
allErrors.addAll(songErrors)
|
|
||||||
} else {
|
|
||||||
songs.add(song)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allErrors.isNotEmpty()) {
|
|
||||||
return BuildResult(false, errors = allErrors)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort songs
|
|
||||||
val sortedSongs = when (config.songs.order) {
|
|
||||||
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
|
|
||||||
else -> songs // manual order = file order
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info { "Parsed ${sortedSongs.size} songs" }
|
|
||||||
|
|
||||||
// 3. Measure songs
|
|
||||||
val fontMetrics = PdfFontMetrics()
|
|
||||||
val measurementEngine = MeasurementEngine(fontMetrics, config)
|
|
||||||
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
|
|
||||||
|
|
||||||
// 4. Generate TOC and paginate
|
|
||||||
val tocGenerator = TocGenerator(config)
|
|
||||||
val tocPages = tocGenerator.estimateTocPages(sortedSongs)
|
|
||||||
|
|
||||||
val paginationEngine = PaginationEngine(config)
|
|
||||||
val pages = paginationEngine.paginate(measuredSongs, tocPages)
|
|
||||||
|
|
||||||
val tocEntries = tocGenerator.generate(pages, tocPages)
|
|
||||||
|
|
||||||
val layoutResult = LayoutResult(
|
|
||||||
tocPages = tocPages,
|
|
||||||
pages = pages,
|
|
||||||
tocEntries = tocEntries
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info { "Layout: ${tocPages} TOC pages, ${pages.size} content pages" }
|
|
||||||
|
|
||||||
// 5. Render PDF
|
|
||||||
val outputDir = File(projectDir, config.output.directory)
|
|
||||||
outputDir.mkdirs()
|
|
||||||
val outputFile = File(outputDir, config.output.filename)
|
|
||||||
|
|
||||||
logger.info { "Rendering PDF: ${outputFile.absolutePath}" }
|
|
||||||
|
|
||||||
val renderer = PdfBookRenderer()
|
|
||||||
FileOutputStream(outputFile).use { fos ->
|
|
||||||
renderer.render(layoutResult, config, fos)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info { "Build complete: ${sortedSongs.size} songs, ${pages.size + tocPages} pages" }
|
|
||||||
|
|
||||||
return BuildResult(
|
|
||||||
success = true,
|
|
||||||
outputFile = outputFile,
|
|
||||||
songCount = sortedSongs.size,
|
|
||||||
pageCount = pages.size + tocPages
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validate(): List<ValidationError> {
|
|
||||||
val configFile = File(projectDir, "songbook.yaml")
|
|
||||||
if (!configFile.exists()) {
|
|
||||||
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
|
||||||
}
|
|
||||||
|
|
||||||
val config = ConfigParser.parse(configFile)
|
|
||||||
val errors = mutableListOf<ValidationError>()
|
|
||||||
errors.addAll(Validator.validateConfig(config))
|
|
||||||
|
|
||||||
val songsDir = File(projectDir, config.songs.directory)
|
|
||||||
if (!songsDir.exists()) {
|
|
||||||
errors.add(ValidationError(config.songs.directory, null, "Songs directory not found"))
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
|
||||||
?.sortedBy { it.name }
|
|
||||||
?: emptyList()
|
|
||||||
|
|
||||||
for (file in songFiles) {
|
|
||||||
try {
|
|
||||||
val song = ChordProParser.parseFile(file)
|
|
||||||
errors.addAll(Validator.validateSong(song, file.name))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
errors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
plugins {
|
|
||||||
`kotlin-dsl`
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10")
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
plugins {
|
|
||||||
kotlin("jvm")
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
jvmToolchain(21)
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Test> {
|
|
||||||
useJUnitPlatform()
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String>) {
|
|
||||||
SongbookCli()
|
|
||||||
.subcommands(BuildCommand(), ValidateCommand())
|
|
||||||
.main(args)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
fonts/UnifrakturMaguntia-Book.ttf
Normal file
BIN
fonts/UnifrakturMaguntia-Book.ttf
Normal file
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<List<SongEntry>>(emptyList()) }
|
|
||||||
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
|
||||||
var isRunning by remember { mutableStateOf(false) }
|
|
||||||
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
fun loadSongs(path: String) {
|
|
||||||
val projectDir = File(path)
|
|
||||||
songs = emptyList()
|
|
||||||
if (!projectDir.isDirectory) return
|
|
||||||
|
|
||||||
val configFile = File(projectDir, "songbook.yaml")
|
|
||||||
val songsDir = if (configFile.exists()) {
|
|
||||||
try {
|
|
||||||
val config = de.pfadfinder.songbook.parser.ConfigParser.parse(configFile)
|
|
||||||
File(projectDir, config.songs.directory)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
File(projectDir, "songs")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
File(projectDir, "songs")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!songsDir.isDirectory) return
|
|
||||||
|
|
||||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
|
||||||
?.sortedBy { it.name }
|
|
||||||
?: emptyList()
|
|
||||||
|
|
||||||
songs = songFiles.mapNotNull { file ->
|
|
||||||
try {
|
|
||||||
val song = ChordProParser.parseFile(file)
|
|
||||||
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
|
|
||||||
} catch (_: Exception) {
|
|
||||||
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme {
|
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
// Project directory selection
|
|
||||||
Text(
|
|
||||||
text = "Songbook Builder",
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text("Projektverzeichnis:", fontWeight = FontWeight.Medium)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = projectPath,
|
|
||||||
onValueChange = {
|
|
||||||
projectPath = it
|
|
||||||
loadSongs(it)
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
singleLine = true,
|
|
||||||
placeholder = { Text("Pfad zum Projektverzeichnis...") }
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val chooser = JFileChooser().apply {
|
|
||||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
|
||||||
dialogTitle = "Projektverzeichnis auswählen"
|
|
||||||
if (projectPath.isNotBlank()) {
|
|
||||||
currentDirectory = File(projectPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
|
||||||
projectPath = chooser.selectedFile.absolutePath
|
|
||||||
loadSongs(projectPath)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isRunning
|
|
||||||
) {
|
|
||||||
Text("Durchsuchen...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Song list
|
|
||||||
Text(
|
|
||||||
text = "Lieder (${songs.size}):",
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
|
||||||
) {
|
|
||||||
if (songs.isEmpty() && projectPath.isNotBlank()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Keine Lieder gefunden. Bitte Projektverzeichnis prüfen.",
|
|
||||||
color = Color.Gray,
|
|
||||||
modifier = Modifier.padding(8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (projectPath.isBlank()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Bitte ein Projektverzeichnis auswählen.",
|
|
||||||
color = Color.Gray,
|
|
||||||
modifier = Modifier.padding(8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items(songs) { song ->
|
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp, horizontal = 8.dp)) {
|
|
||||||
Text(song.title, modifier = Modifier.weight(1f))
|
|
||||||
Text(song.fileName, color = Color.Gray, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalScrollbar(
|
|
||||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
|
||||||
adapter = rememberScrollbarAdapter(listState)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (projectPath.isBlank()) return@Button
|
|
||||||
isRunning = true
|
|
||||||
lastBuildResult = null
|
|
||||||
statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO))
|
|
||||||
scope.launch {
|
|
||||||
val result = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
SongbookPipeline(File(projectPath)).build()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
BuildResult(
|
|
||||||
success = false,
|
|
||||||
errors = listOf(
|
|
||||||
ValidationError(null, null, "Unerwarteter Fehler: ${e.message}")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastBuildResult = result
|
|
||||||
statusMessages = if (result.success) {
|
|
||||||
listOf(
|
|
||||||
StatusMessage(
|
|
||||||
"Buch erfolgreich erstellt! ${result.songCount} Lieder, ${result.pageCount} Seiten.",
|
|
||||||
MessageType.SUCCESS
|
|
||||||
),
|
|
||||||
StatusMessage(
|
|
||||||
"Ausgabedatei: ${result.outputFile?.absolutePath ?: "unbekannt"}",
|
|
||||||
MessageType.INFO
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
result.errors.map { error ->
|
|
||||||
val location = buildString {
|
|
||||||
if (error.file != null) append(error.file)
|
|
||||||
if (error.line != null) append(":${error.line}")
|
|
||||||
}
|
|
||||||
val prefix = if (location.isNotEmpty()) "[$location] " else ""
|
|
||||||
StatusMessage("$prefix${error.message}", MessageType.ERROR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isRunning = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isRunning && projectPath.isNotBlank()
|
|
||||||
) {
|
|
||||||
Text("Buch erstellen")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (projectPath.isBlank()) return@Button
|
|
||||||
isRunning = true
|
|
||||||
lastBuildResult = null
|
|
||||||
statusMessages = listOf(StatusMessage("Validierung läuft...", MessageType.INFO))
|
|
||||||
scope.launch {
|
|
||||||
val errors = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
SongbookPipeline(File(projectPath)).validate()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
listOf(
|
|
||||||
ValidationError(null, null, "Unerwarteter Fehler: ${e.message}")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
statusMessages = if (errors.isEmpty()) {
|
|
||||||
listOf(StatusMessage("Validierung erfolgreich! Keine Fehler gefunden.", MessageType.SUCCESS))
|
|
||||||
} else {
|
|
||||||
errors.map { error ->
|
|
||||||
val location = buildString {
|
|
||||||
if (error.file != null) append(error.file)
|
|
||||||
if (error.line != null) append(":${error.line}")
|
|
||||||
}
|
|
||||||
val prefix = if (location.isNotEmpty()) "[$location] " else ""
|
|
||||||
StatusMessage("$prefix${error.message}", MessageType.ERROR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isRunning = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isRunning && projectPath.isNotBlank()
|
|
||||||
) {
|
|
||||||
Text("Validieren")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastBuildResult?.success == true && lastBuildResult?.outputFile != null) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
lastBuildResult?.outputFile?.let { file ->
|
|
||||||
try {
|
|
||||||
Desktop.getDesktop().open(file)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
statusMessages = statusMessages + StatusMessage(
|
|
||||||
"PDF konnte nicht geöffnet werden: ${e.message}",
|
|
||||||
MessageType.ERROR
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isRunning
|
|
||||||
) {
|
|
||||||
Text("PDF öffnen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRunning) {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp).align(Alignment.CenterVertically),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Status/log area
|
|
||||||
Text("Status:", fontWeight = FontWeight.Medium)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(150.dp)
|
|
||||||
) {
|
|
||||||
val logListState = rememberLazyListState()
|
|
||||||
LazyColumn(
|
|
||||||
state = logListState,
|
|
||||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
|
||||||
) {
|
|
||||||
if (statusMessages.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Bereit.",
|
|
||||||
color = Color.Gray,
|
|
||||||
modifier = Modifier.padding(4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items(statusMessages) { msg ->
|
|
||||||
Text(
|
|
||||||
text = msg.text,
|
|
||||||
color = when (msg.type) {
|
|
||||||
MessageType.ERROR -> MaterialTheme.colors.error
|
|
||||||
MessageType.SUCCESS -> Color(0xFF2E7D32)
|
|
||||||
MessageType.INFO -> Color.Unspecified
|
|
||||||
},
|
|
||||||
fontSize = 13.sp,
|
|
||||||
modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalScrollbar(
|
|
||||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
|
||||||
adapter = rememberScrollbarAdapter(logListState)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class MessageType {
|
|
||||||
INFO, SUCCESS, ERROR
|
|
||||||
}
|
|
||||||
|
|
||||||
data class StatusMessage(val text: String, val type: MessageType)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("songbook-conventions")
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(project(":model"))
|
|
||||||
|
|
||||||
testImplementation(kotlin("test"))
|
|
||||||
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package de.pfadfinder.songbook.layout
|
|
||||||
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
object GapFiller {
|
|
||||||
fun findImages(directory: String): List<String> {
|
|
||||||
val dir = File(directory)
|
|
||||||
if (!dir.exists() || !dir.isDirectory) return emptyList()
|
|
||||||
return dir.listFiles { f ->
|
|
||||||
f.extension.lowercase() in listOf("png", "jpg", "jpeg")
|
|
||||||
}?.map { it.absolutePath }?.sorted() ?: emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<MeasuredSong>, tocPages: Int): List<PageContent> {
|
|
||||||
val pages = mutableListOf<PageContent>()
|
|
||||||
// Current page number (1-based, after TOC)
|
|
||||||
// TOC occupies pages 1..tocPages
|
|
||||||
// Content starts at page tocPages + 1
|
|
||||||
var currentPage = tocPages + 1
|
|
||||||
|
|
||||||
// Collect available filler images
|
|
||||||
val imageDir = File(config.images.directory)
|
|
||||||
val images = if (imageDir.exists() && imageDir.isDirectory) {
|
|
||||||
imageDir.listFiles { f -> f.extension.lowercase() in listOf("png", "jpg", "jpeg", "svg") }
|
|
||||||
?.map { it.absolutePath }
|
|
||||||
?.shuffled()
|
|
||||||
?.toMutableList()
|
|
||||||
?: mutableListOf()
|
|
||||||
} else {
|
|
||||||
mutableListOf()
|
|
||||||
}
|
|
||||||
var imageIndex = 0
|
|
||||||
|
|
||||||
for (ms in measuredSongs) {
|
|
||||||
if (ms.pageCount == 1) {
|
|
||||||
pages.add(PageContent.SongPage(ms.song, 0))
|
|
||||||
currentPage++
|
|
||||||
} else {
|
|
||||||
// 2-page song: must start on left page (even page number)
|
|
||||||
val isLeftPage = currentPage % 2 == 0
|
|
||||||
if (!isLeftPage) {
|
|
||||||
// Insert filler on the right page
|
|
||||||
if (images.isNotEmpty()) {
|
|
||||||
pages.add(PageContent.FillerImage(images[imageIndex % images.size]))
|
|
||||||
imageIndex++
|
|
||||||
} else {
|
|
||||||
pages.add(PageContent.BlankPage)
|
|
||||||
}
|
|
||||||
currentPage++
|
|
||||||
}
|
|
||||||
pages.add(PageContent.SongPage(ms.song, 0))
|
|
||||||
pages.add(PageContent.SongPage(ms.song, 1))
|
|
||||||
currentPage += 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package de.pfadfinder.songbook.layout
|
|
||||||
|
|
||||||
import de.pfadfinder.songbook.model.*
|
|
||||||
|
|
||||||
class TocGenerator(private val config: BookConfig) {
|
|
||||||
|
|
||||||
fun generate(pages: List<PageContent>, tocStartPage: Int): List<TocEntry> {
|
|
||||||
val entries = mutableListOf<TocEntry>()
|
|
||||||
val refAbbreviations = config.referenceBooks.associate { it.id to it.abbreviation }
|
|
||||||
|
|
||||||
// Map songs to their page numbers
|
|
||||||
val songPages = mutableMapOf<String, Int>() // song title -> first page number
|
|
||||||
var currentPageNum = tocStartPage
|
|
||||||
for (page in pages) {
|
|
||||||
currentPageNum++
|
|
||||||
if (page is PageContent.SongPage && page.pageIndex == 0) {
|
|
||||||
songPages[page.song.title] = currentPageNum
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create entries for each song
|
|
||||||
for ((title, pageNumber) in songPages) {
|
|
||||||
// Find the song to get aliases and references
|
|
||||||
val song = pages.filterIsInstance<PageContent.SongPage>()
|
|
||||||
.find { it.song.title == title && it.pageIndex == 0 }?.song
|
|
||||||
?: continue
|
|
||||||
|
|
||||||
// Map references from book IDs to abbreviations
|
|
||||||
val refs = song.references.mapKeys { (bookId, _) ->
|
|
||||||
refAbbreviations[bookId] ?: bookId
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.add(TocEntry(title = title, pageNumber = pageNumber, references = refs))
|
|
||||||
|
|
||||||
// Add alias entries
|
|
||||||
for (alias in song.aliases) {
|
|
||||||
entries.add(TocEntry(title = alias, pageNumber = pageNumber, isAlias = true, references = refs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries.sortedBy { it.title.lowercase() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun estimateTocPages(songs: List<Song>): Int {
|
|
||||||
// Rough estimate: count total titles + aliases
|
|
||||||
val totalEntries = songs.sumOf { 1 + it.aliases.size }
|
|
||||||
// Assume ~40 entries per A5 page
|
|
||||||
val pages = (totalEntries / 40) + 1
|
|
||||||
// TOC should be even number of pages (for double-sided printing)
|
|
||||||
return if (pages % 2 == 0) pages else pages + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<PageContent.SongPage>() }
|
|
||||||
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
|
|
||||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
|
|
||||||
(pages[2] as PageContent.SongPage).song.title shouldBe "Song C"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `single page songs all have pageIndex 0`() {
|
|
||||||
val songs = listOf(
|
|
||||||
onePage(song("Song A")),
|
|
||||||
onePage(song("Song B"))
|
|
||||||
)
|
|
||||||
|
|
||||||
val pages = engine.paginate(songs, tocPages = 2)
|
|
||||||
|
|
||||||
pages.forEach {
|
|
||||||
(it as PageContent.SongPage).pageIndex shouldBe 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `two page song starting on left page has no filler`() {
|
|
||||||
// tocPages = 2, so content starts at page 3 (odd/right page)
|
|
||||||
// First one-page song occupies page 3, next page is 4 (even/left)
|
|
||||||
val songs = listOf(
|
|
||||||
onePage(song("Song A")),
|
|
||||||
twoPage(song("Song B"))
|
|
||||||
)
|
|
||||||
|
|
||||||
val pages = engine.paginate(songs, tocPages = 2)
|
|
||||||
|
|
||||||
// Song A at page 3, Song B starts at page 4 (even = left)
|
|
||||||
pages shouldHaveSize 3
|
|
||||||
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
|
|
||||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
|
|
||||||
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
|
|
||||||
(pages[2] as PageContent.SongPage).song.title shouldBe "Song B"
|
|
||||||
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `two page song on odd page gets blank filler before it`() {
|
|
||||||
// tocPages = 2, content starts at page 3 (odd/right)
|
|
||||||
// First 2-page song needs to start on even page, so filler at page 3
|
|
||||||
val songs = listOf(
|
|
||||||
twoPage(song("Song A"))
|
|
||||||
)
|
|
||||||
|
|
||||||
val pages = engine.paginate(songs, tocPages = 2)
|
|
||||||
|
|
||||||
// Blank at page 3, Song A at pages 4-5
|
|
||||||
pages shouldHaveSize 3
|
|
||||||
pages[0].shouldBeInstanceOf<PageContent.BlankPage>()
|
|
||||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
|
|
||||||
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
|
|
||||||
(pages[2] as PageContent.SongPage).song.title shouldBe "Song A"
|
|
||||||
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `two page song after two single page songs does not need filler`() {
|
|
||||||
// tocPages = 2, content starts at page 3
|
|
||||||
// Song A at page 3, Song B at page 4, Song C (2-page) should start at page 5 (odd)
|
|
||||||
// Page 5 is odd, so it needs filler
|
|
||||||
val songs = listOf(
|
|
||||||
onePage(song("Song A")),
|
|
||||||
onePage(song("Song B")),
|
|
||||||
twoPage(song("Song C"))
|
|
||||||
)
|
|
||||||
|
|
||||||
val pages = engine.paginate(songs, tocPages = 2)
|
|
||||||
|
|
||||||
// Song A at 3, Song B at 4, filler at 5, Song C at 6-7
|
|
||||||
pages shouldHaveSize 5
|
|
||||||
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
|
|
||||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
|
|
||||||
pages[2].shouldBeInstanceOf<PageContent.BlankPage>()
|
|
||||||
(pages[3] as PageContent.SongPage).song.title shouldBe "Song C"
|
|
||||||
(pages[4] as PageContent.SongPage).song.title shouldBe "Song C"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `two consecutive two-page songs are placed correctly`() {
|
|
||||||
// tocPages = 2, content starts at page 3 (odd)
|
|
||||||
// Song A (2-page): needs even start -> filler at 3, Song A at 4-5
|
|
||||||
// Song B (2-page): next page is 6 (even/left) -> no filler, Song B at 6-7
|
|
||||||
val songs = listOf(
|
|
||||||
twoPage(song("Song A")),
|
|
||||||
twoPage(song("Song B"))
|
|
||||||
)
|
|
||||||
|
|
||||||
val pages = engine.paginate(songs, tocPages = 2)
|
|
||||||
|
|
||||||
pages shouldHaveSize 5
|
|
||||||
pages[0].shouldBeInstanceOf<PageContent.BlankPage>()
|
|
||||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
|
|
||||||
(pages[2] as PageContent.SongPage).song.title shouldBe "Song A"
|
|
||||||
(pages[3] as PageContent.SongPage).song.title shouldBe "Song B"
|
|
||||||
(pages[4] as PageContent.SongPage).song.title shouldBe "Song B"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty input produces empty output`() {
|
|
||||||
val pages = engine.paginate(emptyList(), tocPages = 2)
|
|
||||||
pages shouldHaveSize 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `tocPages affects page numbering for alignment`() {
|
|
||||||
// tocPages = 3, content starts at page 4 (even/left)
|
|
||||||
// 2-page song should start directly on page 4 (even) - no filler needed
|
|
||||||
val songs = listOf(
|
|
||||||
twoPage(song("Song A"))
|
|
||||||
)
|
|
||||||
|
|
||||||
val pages = engine.paginate(songs, tocPages = 3)
|
|
||||||
|
|
||||||
// Page 4 is even -> no filler needed
|
|
||||||
pages shouldHaveSize 2
|
|
||||||
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
|
|
||||||
(pages[0] as PageContent.SongPage).pageIndex shouldBe 0
|
|
||||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
|
|
||||||
(pages[1] as PageContent.SongPage).pageIndex shouldBe 1
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `filler uses image when images directory exists`() {
|
|
||||||
// Create a temp directory with an image file
|
|
||||||
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-images").toFile()
|
|
||||||
try {
|
|
||||||
val imageFile = java.io.File(tempDir, "filler.png")
|
|
||||||
imageFile.writeText("fake image")
|
|
||||||
|
|
||||||
val configWithImages = BookConfig(images = ImagesConfig(directory = tempDir.absolutePath))
|
|
||||||
val engineWithImages = PaginationEngine(configWithImages)
|
|
||||||
|
|
||||||
val songs = listOf(twoPage(song("Song A")))
|
|
||||||
val pages = engineWithImages.paginate(songs, tocPages = 2)
|
|
||||||
|
|
||||||
// tocPages=2, start at page 3 (odd), needs filler
|
|
||||||
pages shouldHaveSize 3
|
|
||||||
val filler = pages[0]
|
|
||||||
filler.shouldBeInstanceOf<PageContent.FillerImage>()
|
|
||||||
(filler as PageContent.FillerImage).imagePath shouldBe imageFile.absolutePath
|
|
||||||
} finally {
|
|
||||||
tempDir.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `mixed single and two-page songs layout correctly`() {
|
|
||||||
// tocPages = 4, content starts at page 5 (odd)
|
|
||||||
val songs = listOf(
|
|
||||||
onePage(song("Song A")), // page 5
|
|
||||||
twoPage(song("Song B")), // starts page 6 (even) - no filler
|
|
||||||
onePage(song("Song C")), // page 8
|
|
||||||
onePage(song("Song D")), // page 9
|
|
||||||
twoPage(song("Song E")) // starts page 10 (even) - no filler
|
|
||||||
)
|
|
||||||
|
|
||||||
val pages = engine.paginate(songs, tocPages = 4)
|
|
||||||
|
|
||||||
pages shouldHaveSize 7
|
|
||||||
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
|
|
||||||
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
|
|
||||||
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
|
|
||||||
(pages[2] as PageContent.SongPage).song.title shouldBe "Song B"
|
|
||||||
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
|
|
||||||
(pages[3] as PageContent.SongPage).song.title shouldBe "Song C"
|
|
||||||
(pages[4] as PageContent.SongPage).song.title shouldBe "Song D"
|
|
||||||
(pages[5] as PageContent.SongPage).song.title shouldBe "Song E"
|
|
||||||
(pages[5] as PageContent.SongPage).pageIndex shouldBe 0
|
|
||||||
(pages[6] as PageContent.SongPage).song.title shouldBe "Song E"
|
|
||||||
(pages[6] as PageContent.SongPage).pageIndex shouldBe 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("songbook-conventions")
|
|
||||||
}
|
|
||||||
@@ -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<ReferenceBook> = emptyList(),
|
|
||||||
val output: OutputConfig = OutputConfig()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BookMeta(
|
|
||||||
val title: String = "Liederbuch",
|
|
||||||
val subtitle: String? = null,
|
|
||||||
val edition: String? = null,
|
|
||||||
val format: String = "A5"
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SongsConfig(
|
|
||||||
val directory: String = "./songs",
|
|
||||||
val order: String = "alphabetical" // "alphabetical" or "manual"
|
|
||||||
)
|
|
||||||
|
|
||||||
data class FontsConfig(
|
|
||||||
val lyrics: FontSpec = FontSpec(family = "Helvetica", size = 10f),
|
|
||||||
val chords: FontSpec = FontSpec(family = "Helvetica", size = 9f, color = "#333333"),
|
|
||||||
val title: FontSpec = FontSpec(family = "Helvetica", size = 14f),
|
|
||||||
val metadata: FontSpec = FontSpec(family = "Helvetica", size = 8f),
|
|
||||||
val toc: FontSpec = FontSpec(family = "Helvetica", size = 9f)
|
|
||||||
)
|
|
||||||
|
|
||||||
data class FontSpec(
|
|
||||||
val family: String = "Helvetica",
|
|
||||||
val file: String? = null,
|
|
||||||
val size: Float = 10f,
|
|
||||||
val color: String = "#000000"
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LayoutConfig(
|
|
||||||
val margins: Margins = Margins(),
|
|
||||||
val chordLineSpacing: Float = 3f, // mm
|
|
||||||
val verseSpacing: Float = 4f, // mm
|
|
||||||
val pageNumberPosition: String = "bottom-outer"
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Margins(
|
|
||||||
val top: Float = 15f,
|
|
||||||
val bottom: Float = 15f,
|
|
||||||
val inner: Float = 20f,
|
|
||||||
val outer: Float = 12f
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ImagesConfig(
|
|
||||||
val directory: String = "./images"
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ReferenceBook(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val abbreviation: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class OutputConfig(
|
|
||||||
val directory: String = "./output",
|
|
||||||
val filename: String = "liederbuch.pdf"
|
|
||||||
)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package de.pfadfinder.songbook.model
|
|
||||||
|
|
||||||
import java.io.OutputStream
|
|
||||||
|
|
||||||
interface BookRenderer {
|
|
||||||
fun render(layout: LayoutResult, config: BookConfig, output: OutputStream)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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<PageContent>,
|
|
||||||
val tocEntries: List<TocEntry>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TocEntry(
|
|
||||||
val title: String,
|
|
||||||
val pageNumber: Int,
|
|
||||||
val isAlias: Boolean = false,
|
|
||||||
val references: Map<String, Int> = emptyMap() // bookAbbrev → page
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package de.pfadfinder.songbook.model
|
|
||||||
|
|
||||||
data class Song(
|
|
||||||
val title: String,
|
|
||||||
val aliases: List<String> = emptyList(),
|
|
||||||
val lyricist: String? = null,
|
|
||||||
val composer: String? = null,
|
|
||||||
val key: String? = null,
|
|
||||||
val tags: List<String> = emptyList(),
|
|
||||||
val notes: List<String> = emptyList(),
|
|
||||||
val references: Map<String, Int> = emptyMap(), // bookId → page number
|
|
||||||
val capo: Int? = null,
|
|
||||||
val sections: List<SongSection> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SongSection(
|
|
||||||
val type: SectionType,
|
|
||||||
val label: String? = null,
|
|
||||||
val lines: List<SongLine> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class SectionType {
|
|
||||||
VERSE, CHORUS, BRIDGE, REPEAT
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SongLine(val segments: List<LineSegment>)
|
|
||||||
|
|
||||||
data class LineSegment(
|
|
||||||
val chord: String? = null, // null = no chord above this segment
|
|
||||||
val text: String
|
|
||||||
)
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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<String>()
|
|
||||||
var lyricist: String? = null
|
|
||||||
var composer: String? = null
|
|
||||||
var key: String? = null
|
|
||||||
val tags = mutableListOf<String>()
|
|
||||||
val notes = mutableListOf<String>()
|
|
||||||
val references = mutableMapOf<String, Int>()
|
|
||||||
var capo: Int? = null
|
|
||||||
|
|
||||||
val sections = mutableListOf<SongSection>()
|
|
||||||
|
|
||||||
// Current section being built
|
|
||||||
var currentType: SectionType? = null
|
|
||||||
var currentLabel: String? = null
|
|
||||||
var currentLines = mutableListOf<SongLine>()
|
|
||||||
|
|
||||||
fun flushSection() {
|
|
||||||
if (currentType != null) {
|
|
||||||
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
|
|
||||||
currentType = null
|
|
||||||
currentLabel = null
|
|
||||||
currentLines = mutableListOf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (rawLine in lines) {
|
|
||||||
val line = rawLine.trimEnd()
|
|
||||||
|
|
||||||
// Skip comments
|
|
||||||
if (line.trimStart().startsWith("#")) continue
|
|
||||||
|
|
||||||
// Skip empty lines
|
|
||||||
if (line.isBlank()) continue
|
|
||||||
|
|
||||||
// Directive line
|
|
||||||
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
|
|
||||||
val inner = line.trim().removePrefix("{").removeSuffix("}").trim()
|
|
||||||
val colonIndex = inner.indexOf(':')
|
|
||||||
val directive: String
|
|
||||||
val value: String?
|
|
||||||
if (colonIndex >= 0) {
|
|
||||||
directive = inner.substring(0, colonIndex).trim().lowercase()
|
|
||||||
value = inner.substring(colonIndex + 1).trim()
|
|
||||||
} else {
|
|
||||||
directive = inner.trim().lowercase()
|
|
||||||
value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
when (directive) {
|
|
||||||
"title", "t" -> title = value
|
|
||||||
"alias" -> if (value != null) aliases.add(value)
|
|
||||||
"lyricist" -> lyricist = value
|
|
||||||
"composer" -> composer = value
|
|
||||||
"key" -> key = value
|
|
||||||
"tags" -> if (value != null) {
|
|
||||||
tags.addAll(value.split(",").map { it.trim() }.filter { it.isNotEmpty() })
|
|
||||||
}
|
|
||||||
"note" -> if (value != null) notes.add(value)
|
|
||||||
"capo" -> capo = value?.toIntOrNull()
|
|
||||||
"ref" -> if (value != null) {
|
|
||||||
parseReference(value)?.let { (bookId, page) ->
|
|
||||||
references[bookId] = page
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"start_of_verse", "sov" -> {
|
|
||||||
flushSection()
|
|
||||||
currentType = SectionType.VERSE
|
|
||||||
currentLabel = value
|
|
||||||
}
|
|
||||||
"end_of_verse", "eov" -> {
|
|
||||||
flushSection()
|
|
||||||
}
|
|
||||||
"start_of_chorus", "soc" -> {
|
|
||||||
flushSection()
|
|
||||||
currentType = SectionType.CHORUS
|
|
||||||
currentLabel = value
|
|
||||||
}
|
|
||||||
"end_of_chorus", "eoc" -> {
|
|
||||||
flushSection()
|
|
||||||
}
|
|
||||||
"start_of_repeat", "sor" -> {
|
|
||||||
flushSection()
|
|
||||||
currentType = SectionType.REPEAT
|
|
||||||
currentLabel = value
|
|
||||||
}
|
|
||||||
"end_of_repeat", "eor" -> {
|
|
||||||
flushSection()
|
|
||||||
}
|
|
||||||
"chorus" -> {
|
|
||||||
flushSection()
|
|
||||||
sections.add(SongSection(type = SectionType.CHORUS))
|
|
||||||
}
|
|
||||||
"repeat" -> {
|
|
||||||
// Store repeat count as label on current section or create a new section
|
|
||||||
if (currentType != null) {
|
|
||||||
currentLabel = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text/chord line: if we're not inside a section, start an implicit VERSE
|
|
||||||
if (currentType == null) {
|
|
||||||
currentType = SectionType.VERSE
|
|
||||||
}
|
|
||||||
|
|
||||||
val songLine = parseChordLine(line)
|
|
||||||
currentLines.add(songLine)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush any remaining section
|
|
||||||
flushSection()
|
|
||||||
|
|
||||||
return Song(
|
|
||||||
title = title ?: "",
|
|
||||||
aliases = aliases.toList(),
|
|
||||||
lyricist = lyricist,
|
|
||||||
composer = composer,
|
|
||||||
key = key,
|
|
||||||
tags = tags.toList(),
|
|
||||||
notes = notes.toList(),
|
|
||||||
references = references.toMap(),
|
|
||||||
capo = capo,
|
|
||||||
sections = sections.toList()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseFile(file: File): Song = parse(file.readText())
|
|
||||||
|
|
||||||
internal fun parseChordLine(line: String): SongLine {
|
|
||||||
val segments = mutableListOf<LineSegment>()
|
|
||||||
var i = 0
|
|
||||||
val len = line.length
|
|
||||||
|
|
||||||
// Check if line starts with text before any chord
|
|
||||||
if (len > 0 && line[0] != '[') {
|
|
||||||
val nextBracket = line.indexOf('[')
|
|
||||||
if (nextBracket < 0) {
|
|
||||||
// No chords at all, entire line is text
|
|
||||||
segments.add(LineSegment(chord = null, text = line))
|
|
||||||
return SongLine(segments)
|
|
||||||
}
|
|
||||||
segments.add(LineSegment(chord = null, text = line.substring(0, nextBracket)))
|
|
||||||
i = nextBracket
|
|
||||||
}
|
|
||||||
|
|
||||||
while (i < len) {
|
|
||||||
if (line[i] == '[') {
|
|
||||||
val closeBracket = line.indexOf(']', i)
|
|
||||||
if (closeBracket < 0) {
|
|
||||||
// Malformed: treat rest as text
|
|
||||||
segments.add(LineSegment(chord = null, text = line.substring(i)))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
val chord = line.substring(i + 1, closeBracket)
|
|
||||||
val textStart = closeBracket + 1
|
|
||||||
val nextBracket = line.indexOf('[', textStart)
|
|
||||||
val text = if (nextBracket < 0) {
|
|
||||||
line.substring(textStart)
|
|
||||||
} else {
|
|
||||||
line.substring(textStart, nextBracket)
|
|
||||||
}
|
|
||||||
segments.add(LineSegment(chord = chord, text = text))
|
|
||||||
i = if (nextBracket < 0) len else nextBracket
|
|
||||||
} else {
|
|
||||||
// Should not happen if logic is correct, but handle gracefully
|
|
||||||
val nextBracket = line.indexOf('[', i)
|
|
||||||
if (nextBracket < 0) {
|
|
||||||
segments.add(LineSegment(chord = null, text = line.substring(i)))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
segments.add(LineSegment(chord = null, text = line.substring(i, nextBracket)))
|
|
||||||
i = nextBracket
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SongLine(segments)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun parseReference(value: String): Pair<String, Int>? {
|
|
||||||
val parts = value.trim().split("\\s+".toRegex())
|
|
||||||
if (parts.size < 2) return null
|
|
||||||
val page = parts.last().toIntOrNull() ?: return null
|
|
||||||
val bookId = parts.dropLast(1).joinToString(" ")
|
|
||||||
return bookId to page
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<ValidationError> {
|
|
||||||
val errors = mutableListOf<ValidationError>()
|
|
||||||
|
|
||||||
if (song.title.isBlank()) {
|
|
||||||
errors.add(ValidationError(file = fileName, line = null, message = "Song must have a title"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (song.sections.isEmpty()) {
|
|
||||||
errors.add(ValidationError(file = fileName, line = null, message = "Song must have at least one section"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validateSong(song: Song, config: BookConfig, fileName: String? = null): List<ValidationError> {
|
|
||||||
val errors = validateSong(song, fileName).toMutableList()
|
|
||||||
|
|
||||||
val knownBookIds = config.referenceBooks.map { it.id }.toSet()
|
|
||||||
for ((bookId, _) in song.references) {
|
|
||||||
if (bookId !in knownBookIds) {
|
|
||||||
errors.add(
|
|
||||||
ValidationError(
|
|
||||||
file = fileName,
|
|
||||||
line = null,
|
|
||||||
message = "Reference to unknown book '$bookId'. Known books: ${knownBookIds.joinToString(", ")}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validateConfig(config: BookConfig): List<ValidationError> {
|
|
||||||
val errors = mutableListOf<ValidationError>()
|
|
||||||
|
|
||||||
with(config.layout.margins) {
|
|
||||||
if (top <= 0) errors.add(ValidationError(file = null, line = null, message = "Top margin must be greater than 0"))
|
|
||||||
if (bottom <= 0) errors.add(ValidationError(file = null, line = null, message = "Bottom margin must be greater than 0"))
|
|
||||||
if (inner <= 0) errors.add(ValidationError(file = null, line = null, message = "Inner margin must be greater than 0"))
|
|
||||||
if (outer <= 0) errors.add(ValidationError(file = null, line = null, message = "Outer margin must be greater than 0"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String>()
|
|
||||||
song.composer?.let { metaParts.add("M: $it") }
|
|
||||||
song.lyricist?.let { metaParts.add("T: $it") }
|
|
||||||
if (metaParts.isNotEmpty()) {
|
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
|
||||||
val metaSize = config.fonts.metadata.size
|
|
||||||
cb.beginText()
|
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
|
||||||
cb.setColorFill(Color.GRAY)
|
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
|
||||||
cb.showText(metaParts.joinToString(" / "))
|
|
||||||
cb.endText()
|
|
||||||
y -= metaSize * 1.8f
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render key and capo
|
|
||||||
val infoParts = mutableListOf<String>()
|
|
||||||
song.key?.let { infoParts.add("Tonart: $it") }
|
|
||||||
song.capo?.let { infoParts.add("Capo: $it") }
|
|
||||||
if (infoParts.isNotEmpty()) {
|
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
|
||||||
val metaSize = config.fonts.metadata.size
|
|
||||||
cb.beginText()
|
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
|
||||||
cb.setColorFill(Color.GRAY)
|
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
|
||||||
cb.showText(infoParts.joinToString(" | "))
|
|
||||||
cb.endText()
|
|
||||||
y -= metaSize * 1.8f
|
|
||||||
}
|
|
||||||
|
|
||||||
y -= 4f // gap before sections
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which sections to render on this page
|
|
||||||
// For simplicity in this implementation, render all sections on pageIndex 0
|
|
||||||
// A more sophisticated implementation would split sections across pages
|
|
||||||
val sections = if (pageIndex == 0) song.sections else emptyList()
|
|
||||||
|
|
||||||
for (section in sections) {
|
|
||||||
// Section label
|
|
||||||
if (section.label != null || section.type == SectionType.CHORUS) {
|
|
||||||
val labelText = section.label ?: when (section.type) {
|
|
||||||
SectionType.CHORUS -> "Refrain"
|
|
||||||
SectionType.REPEAT -> "Wiederholung"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (labelText != null) {
|
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
|
||||||
val metaSize = config.fonts.metadata.size
|
|
||||||
cb.beginText()
|
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
|
||||||
cb.setColorFill(Color.DARK_GRAY)
|
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
|
||||||
cb.showText(labelText)
|
|
||||||
cb.endText()
|
|
||||||
y -= metaSize * 1.5f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chorus indication for repeat
|
|
||||||
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
|
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
|
||||||
val metaSize = config.fonts.metadata.size
|
|
||||||
cb.beginText()
|
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
|
||||||
cb.setColorFill(Color.DARK_GRAY)
|
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
|
||||||
cb.showText("(Refrain)")
|
|
||||||
cb.endText()
|
|
||||||
y -= metaSize * 1.8f
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render repeat markers for REPEAT sections
|
|
||||||
if (section.type == SectionType.REPEAT) {
|
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
|
||||||
val metaSize = config.fonts.metadata.size
|
|
||||||
cb.beginText()
|
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
|
||||||
cb.setColorFill(Color.DARK_GRAY)
|
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
|
||||||
cb.showText("\u2502:")
|
|
||||||
cb.endText()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render lines
|
|
||||||
for (line in section.lines) {
|
|
||||||
val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth)
|
|
||||||
y -= height + 1f // 1pt gap between lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// End repeat marker
|
|
||||||
if (section.type == SectionType.REPEAT) {
|
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
|
||||||
val metaSize = config.fonts.metadata.size
|
|
||||||
cb.beginText()
|
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
|
||||||
cb.setColorFill(Color.DARK_GRAY)
|
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
|
||||||
cb.showText(":\u2502")
|
|
||||||
cb.endText()
|
|
||||||
y -= metaSize * 1.5f
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verse spacing
|
|
||||||
y -= config.layout.verseSpacing / 0.3528f
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render notes at the bottom
|
|
||||||
if (pageIndex == 0 && song.notes.isNotEmpty()) {
|
|
||||||
y -= 4f
|
|
||||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
|
||||||
val metaSize = config.fonts.metadata.size
|
|
||||||
for (note in song.notes) {
|
|
||||||
cb.beginText()
|
|
||||||
cb.setFontAndSize(metaFont, metaSize)
|
|
||||||
cb.setColorFill(Color.GRAY)
|
|
||||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
|
||||||
cb.showText(note)
|
|
||||||
cb.endText()
|
|
||||||
y -= metaSize * 1.5f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) {
|
|
||||||
try {
|
|
||||||
val img = Image.getInstance(imagePath)
|
|
||||||
img.scaleToFit(pageSize.width * 0.7f, pageSize.height * 0.7f)
|
|
||||||
img.alignment = Image.ALIGN_CENTER or Image.ALIGN_MIDDLE
|
|
||||||
document.add(img)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// If image can't be loaded, just leave the page blank
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String, BaseFont>()
|
|
||||||
|
|
||||||
fun getBaseFont(font: FontSpec): BaseFont {
|
|
||||||
val key = font.file ?: font.family
|
|
||||||
return fontCache.getOrPut(key) {
|
|
||||||
if (font.file != null) {
|
|
||||||
BaseFont.createFont(font.file, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
|
|
||||||
} else {
|
|
||||||
// Map common family names to built-in PDF fonts
|
|
||||||
val pdfFontName = when (font.family.lowercase()) {
|
|
||||||
"helvetica" -> BaseFont.HELVETICA
|
|
||||||
"courier" -> BaseFont.COURIER
|
|
||||||
"times", "times new roman" -> BaseFont.TIMES_ROMAN
|
|
||||||
else -> BaseFont.HELVETICA
|
|
||||||
}
|
|
||||||
BaseFont.createFont(pdfFontName, BaseFont.CP1252, BaseFont.NOT_EMBEDDED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also provide bold variants for chord fonts
|
|
||||||
fun getBaseFontBold(font: FontSpec): BaseFont {
|
|
||||||
if (font.file != null) return getBaseFont(font)
|
|
||||||
val key = "${font.family}_bold"
|
|
||||||
return fontCache.getOrPut(key) {
|
|
||||||
val pdfFontName = when (font.family.lowercase()) {
|
|
||||||
"helvetica" -> BaseFont.HELVETICA_BOLD
|
|
||||||
"courier" -> BaseFont.COURIER_BOLD
|
|
||||||
"times", "times new roman" -> BaseFont.TIMES_BOLD
|
|
||||||
else -> BaseFont.HELVETICA_BOLD
|
|
||||||
}
|
|
||||||
BaseFont.createFont(pdfFontName, BaseFont.CP1252, BaseFont.NOT_EMBEDDED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun measureTextWidth(text: String, font: FontSpec, size: Float): Float {
|
|
||||||
val baseFont = getBaseFont(font)
|
|
||||||
// BaseFont.getWidthPoint returns width in PDF points
|
|
||||||
// Convert to mm: 1 point = 0.3528 mm
|
|
||||||
return baseFont.getWidthPoint(text, size) * 0.3528f
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun measureLineHeight(font: FontSpec, size: Float): Float {
|
|
||||||
// Approximate line height as 1.2 * font size, converted to mm
|
|
||||||
return size * 1.2f * 0.3528f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<TocEntry>) {
|
|
||||||
val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
|
|
||||||
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
|
|
||||||
val fontSize = config.fonts.toc.size
|
|
||||||
|
|
||||||
// Title "Inhaltsverzeichnis"
|
|
||||||
val titleFont = Font(fontMetrics.getBaseFont(config.fonts.title), config.fonts.title.size, Font.BOLD)
|
|
||||||
val title = Paragraph("Inhaltsverzeichnis", titleFont)
|
|
||||||
title.alignment = Element.ALIGN_CENTER
|
|
||||||
title.spacingAfter = 12f
|
|
||||||
document.add(title)
|
|
||||||
|
|
||||||
// Determine columns: Title | Page | ref book abbreviations...
|
|
||||||
val refBooks = config.referenceBooks
|
|
||||||
val numCols = 2 + refBooks.size
|
|
||||||
val table = PdfPTable(numCols)
|
|
||||||
table.widthPercentage = 100f
|
|
||||||
|
|
||||||
// Set column widths: title takes most space
|
|
||||||
val widths = FloatArray(numCols)
|
|
||||||
widths[0] = 10f // title
|
|
||||||
widths[1] = 1.5f // page
|
|
||||||
for (i in refBooks.indices) {
|
|
||||||
widths[2 + i] = 1.5f
|
|
||||||
}
|
|
||||||
table.setWidths(widths)
|
|
||||||
|
|
||||||
// Header row
|
|
||||||
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
|
||||||
table.addCell(headerCell("Titel", headerFont))
|
|
||||||
table.addCell(headerCell("Seite", headerFont))
|
|
||||||
for (book in refBooks) {
|
|
||||||
table.addCell(headerCell(book.abbreviation, headerFont))
|
|
||||||
}
|
|
||||||
table.headerRows = 1
|
|
||||||
|
|
||||||
// TOC entries
|
|
||||||
val entryFont = Font(tocFont, fontSize)
|
|
||||||
val aliasFont = Font(tocFont, fontSize, Font.ITALIC)
|
|
||||||
for (entry in tocEntries.sortedBy { it.title.lowercase() }) {
|
|
||||||
val font = if (entry.isAlias) aliasFont else entryFont
|
|
||||||
table.addCell(entryCell(entry.title, font))
|
|
||||||
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT))
|
|
||||||
for (book in refBooks) {
|
|
||||||
val ref = entry.references[book.abbreviation]
|
|
||||||
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.add(table)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun headerCell(text: String, font: Font): PdfPCell {
|
|
||||||
val cell = PdfPCell(Phrase(text, font))
|
|
||||||
cell.borderWidth = 0f
|
|
||||||
cell.borderWidthBottom = 0.5f
|
|
||||||
cell.paddingBottom = 4f
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun entryCell(text: String, font: Font, alignment: Int = Element.ALIGN_LEFT): PdfPCell {
|
|
||||||
val cell = PdfPCell(Phrase(text, font))
|
|
||||||
cell.borderWidth = 0f
|
|
||||||
cell.horizontalAlignment = alignment
|
|
||||||
cell.paddingTop = 1f
|
|
||||||
cell.paddingBottom = 1f
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
100
songbook-style.sty
Normal file
100
songbook-style.sty
Normal file
@@ -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}%
|
||||||
|
}
|
||||||
31
songbook.tex
Normal file
31
songbook.tex
Normal file
@@ -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}
|
||||||
@@ -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"
|
|
||||||
@@ -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}
|
|
||||||
31
songs/abend-wird-es-wieder.tex
Normal file
31
songs/abend-wird-es-wieder.tex
Normal file
@@ -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}
|
||||||
@@ -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}
|
|
||||||
29
songs/auf-auf-zum-froehlichen-jagen.tex
Normal file
29
songs/auf-auf-zum-froehlichen-jagen.tex
Normal file
@@ -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}
|
||||||
@@ -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}
|
|
||||||
46
songs/die-gedanken-sind-frei.tex
Normal file
46
songs/die-gedanken-sind-frei.tex
Normal file
@@ -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}
|
||||||
@@ -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}
|
|
||||||
24
songs/hejo-spann-den-wagen-an.tex
Normal file
24
songs/hejo-spann-den-wagen-an.tex
Normal file
@@ -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}
|
||||||
@@ -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}
|
|
||||||
37
songs/kein-schoener-land.tex
Normal file
37
songs/kein-schoener-land.tex
Normal file
@@ -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}
|
||||||
Reference in New Issue
Block a user