Initial implementation of songbook toolset

Kotlin/JVM multi-module project for generating a scout songbook PDF
from ChordPro-format text files. Includes ChordPro parser, layout engine
with greedy spread packing for double-page songs, OpenPDF renderer,
CLI (Clikt), Compose Desktop GUI, and 5 sample songs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-03-17 08:35:42 +01:00
commit e386501b57
56 changed files with 5152 additions and 0 deletions

10
layout/build.gradle.kts Normal file
View File

@@ -0,0 +1,10 @@
plugins {
id("songbook-conventions")
}
dependencies {
implementation(project(":model"))
testImplementation(kotlin("test"))
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
}

View File

@@ -0,0 +1,13 @@
package de.pfadfinder.songbook.layout
import java.io.File
object GapFiller {
fun findImages(directory: String): List<String> {
val dir = File(directory)
if (!dir.exists() || !dir.isDirectory) return emptyList()
return dir.listFiles { f ->
f.extension.lowercase() in listOf("png", "jpg", "jpeg")
}?.map { it.absolutePath }?.sorted() ?: emptyList()
}
}

View File

@@ -0,0 +1,72 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
class MeasurementEngine(
private val fontMetrics: FontMetrics,
private val config: BookConfig
) {
// A5 content height = 210mm - top margin - bottom margin
private val contentHeightMm: Float = 210f - config.layout.margins.top - config.layout.margins.bottom
fun measure(song: Song): MeasuredSong {
var heightMm = 0f
// Title height
heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
// Metadata line (composer/lyricist)
if (song.composer != null || song.lyricist != null) {
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
}
// Key/capo line
if (song.key != null || song.capo != null) {
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
}
// Gap before sections
heightMm += 1.5f // ~4pt in mm
// Sections
for (section in song.sections) {
// Section label
if (section.label != null || section.type == SectionType.CHORUS) {
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
}
// Chorus repeat reference (no lines)
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
continue
}
// Lines in section
for (line in section.lines) {
val hasChords = line.segments.any { it.chord != null }
val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size)
if (hasChords) {
val chordHeight = fontMetrics.measureLineHeight(config.fonts.chords, config.fonts.chords.size)
heightMm += chordHeight + config.layout.chordLineSpacing + lyricHeight
} else {
heightMm += lyricHeight
}
heightMm += 0.35f // ~1pt gap between lines
}
// Verse spacing
heightMm += config.layout.verseSpacing
}
// Notes at bottom
if (song.notes.isNotEmpty()) {
heightMm += 1.5f // gap
for (note in song.notes) {
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
}
}
val pageCount = if (heightMm <= contentHeightMm) 1 else 2
return MeasuredSong(song, heightMm, pageCount)
}
}

View File

@@ -0,0 +1,53 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
import java.io.File
class PaginationEngine(private val config: BookConfig) {
fun paginate(measuredSongs: List<MeasuredSong>, tocPages: Int): List<PageContent> {
val pages = mutableListOf<PageContent>()
// Current page number (1-based, after TOC)
// TOC occupies pages 1..tocPages
// Content starts at page tocPages + 1
var currentPage = tocPages + 1
// Collect available filler images
val imageDir = File(config.images.directory)
val images = if (imageDir.exists() && imageDir.isDirectory) {
imageDir.listFiles { f -> f.extension.lowercase() in listOf("png", "jpg", "jpeg", "svg") }
?.map { it.absolutePath }
?.shuffled()
?.toMutableList()
?: mutableListOf()
} else {
mutableListOf()
}
var imageIndex = 0
for (ms in measuredSongs) {
if (ms.pageCount == 1) {
pages.add(PageContent.SongPage(ms.song, 0))
currentPage++
} else {
// 2-page song: must start on left page (even page number)
val isLeftPage = currentPage % 2 == 0
if (!isLeftPage) {
// Insert filler on the right page
if (images.isNotEmpty()) {
pages.add(PageContent.FillerImage(images[imageIndex % images.size]))
imageIndex++
} else {
pages.add(PageContent.BlankPage)
}
currentPage++
}
pages.add(PageContent.SongPage(ms.song, 0))
pages.add(PageContent.SongPage(ms.song, 1))
currentPage += 2
}
}
return pages
}
}

View File

@@ -0,0 +1,52 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
class TocGenerator(private val config: BookConfig) {
fun generate(pages: List<PageContent>, tocStartPage: Int): List<TocEntry> {
val entries = mutableListOf<TocEntry>()
val refAbbreviations = config.referenceBooks.associate { it.id to it.abbreviation }
// Map songs to their page numbers
val songPages = mutableMapOf<String, Int>() // song title -> first page number
var currentPageNum = tocStartPage
for (page in pages) {
currentPageNum++
if (page is PageContent.SongPage && page.pageIndex == 0) {
songPages[page.song.title] = currentPageNum
}
}
// Create entries for each song
for ((title, pageNumber) in songPages) {
// Find the song to get aliases and references
val song = pages.filterIsInstance<PageContent.SongPage>()
.find { it.song.title == title && it.pageIndex == 0 }?.song
?: continue
// Map references from book IDs to abbreviations
val refs = song.references.mapKeys { (bookId, _) ->
refAbbreviations[bookId] ?: bookId
}
entries.add(TocEntry(title = title, pageNumber = pageNumber, references = refs))
// Add alias entries
for (alias in song.aliases) {
entries.add(TocEntry(title = alias, pageNumber = pageNumber, isAlias = true, references = refs))
}
}
return entries.sortedBy { it.title.lowercase() }
}
fun estimateTocPages(songs: List<Song>): Int {
// Rough estimate: count total titles + aliases
val totalEntries = songs.sumOf { 1 + it.aliases.size }
// Assume ~40 entries per A5 page
val pages = (totalEntries / 40) + 1
// TOC should be even number of pages (for double-sided printing)
return if (pages % 2 == 0) pages else pages + 1
}
}

View File

@@ -0,0 +1,74 @@
package de.pfadfinder.songbook.layout
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class GapFillerTest {
@Test
fun `findImages returns empty for nonexistent directory`() {
val images = GapFiller.findImages("/nonexistent/path/to/images")
images.shouldBeEmpty()
}
@Test
fun `findImages returns empty for empty directory`() {
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-empty").toFile()
try {
val images = GapFiller.findImages(tempDir.absolutePath)
images.shouldBeEmpty()
} finally {
tempDir.deleteRecursively()
}
}
@Test
fun `findImages returns image files sorted`() {
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-images").toFile()
try {
java.io.File(tempDir, "c_image.png").writeText("fake")
java.io.File(tempDir, "a_image.jpg").writeText("fake")
java.io.File(tempDir, "b_image.jpeg").writeText("fake")
val images = GapFiller.findImages(tempDir.absolutePath)
images shouldHaveSize 3
// Should be sorted by absolute path (which means sorted by filename here)
images[0] shouldBe java.io.File(tempDir, "a_image.jpg").absolutePath
images[1] shouldBe java.io.File(tempDir, "b_image.jpeg").absolutePath
images[2] shouldBe java.io.File(tempDir, "c_image.png").absolutePath
} finally {
tempDir.deleteRecursively()
}
}
@Test
fun `findImages ignores non-image files`() {
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-nonimage").toFile()
try {
java.io.File(tempDir, "image.png").writeText("fake")
java.io.File(tempDir, "document.txt").writeText("fake")
java.io.File(tempDir, "data.json").writeText("fake")
java.io.File(tempDir, "photo.jpg").writeText("fake")
val images = GapFiller.findImages(tempDir.absolutePath)
images shouldHaveSize 2
} finally {
tempDir.deleteRecursively()
}
}
@Test
fun `findImages returns empty when directory is a file`() {
val tempFile = kotlin.io.path.createTempFile("songbook-test-file").toFile()
try {
val images = GapFiller.findImages(tempFile.absolutePath)
images.shouldBeEmpty()
} finally {
tempFile.delete()
}
}
}

View File

@@ -0,0 +1,261 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.floats.shouldBeGreaterThan
import io.kotest.matchers.floats.shouldBeLessThan
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class MeasurementEngineTest {
private val fontMetrics = StubFontMetrics()
private val config = BookConfig()
private val engine = MeasurementEngine(fontMetrics, config)
// Content height = 210 - 15 (top) - 15 (bottom) = 180mm
private val contentHeight = 210f - config.layout.margins.top - config.layout.margins.bottom
@Test
fun `simple song with one verse and no chords fits on one page`() {
val song = Song(
title = "Simple Song",
sections = listOf(
SongSection(
type = SectionType.VERSE,
label = "Verse 1",
lines = listOf(
SongLine(listOf(LineSegment(text = "This is a simple line"))),
SongLine(listOf(LineSegment(text = "Another simple line")))
)
)
)
)
val result = engine.measure(song)
result.pageCount shouldBe 1
result.song shouldBe song
result.totalHeightMm shouldBeGreaterThan 0f
result.totalHeightMm shouldBeLessThan contentHeight
}
@Test
fun `song with many sections exceeds one page`() {
// Create a song with many sections to exceed content height
val sections = (1..30).map { i ->
SongSection(
type = SectionType.VERSE,
label = "Verse $i",
lines = (1..5).map {
SongLine(
listOf(
LineSegment(chord = "Am", text = "Some "),
LineSegment(chord = "G", text = "text with chords")
)
)
}
)
}
val song = Song(title = "Long Song", sections = sections)
val result = engine.measure(song)
result.pageCount shouldBe 2
result.totalHeightMm shouldBeGreaterThan contentHeight
}
@Test
fun `font metrics is used for title measurement`() {
val song = Song(title = "Title Only")
val result = engine.measure(song)
// Title contributes: measureLineHeight(title font, 14f) * 1.5
val expectedTitleHeight = fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
// Plus gap before sections
val expectedMinHeight = expectedTitleHeight + 1.5f
result.totalHeightMm shouldBeGreaterThan (expectedMinHeight - 0.01f)
}
@Test
fun `composer and lyricist add metadata height`() {
val songWithoutMeta = Song(title = "No Meta")
val songWithMeta = Song(title = "With Meta", composer = "Bach", lyricist = "Goethe")
val heightWithout = engine.measure(songWithoutMeta).totalHeightMm
val heightWith = engine.measure(songWithMeta).totalHeightMm
val metadataLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
heightWith shouldBeGreaterThan heightWithout
// The difference should be approximately the metadata line height
val diff = heightWith - heightWithout
diff shouldBeGreaterThan (metadataLineHeight - 0.01f)
diff shouldBeLessThan (metadataLineHeight + 0.01f)
}
@Test
fun `key and capo add metadata height`() {
val songWithoutKeyCap = Song(title = "No Key")
val songWithKey = Song(title = "With Key", key = "Am")
val heightWithout = engine.measure(songWithoutKeyCap).totalHeightMm
val heightWith = engine.measure(songWithKey).totalHeightMm
heightWith shouldBeGreaterThan heightWithout
}
@Test
fun `capo alone adds metadata height`() {
val songWithout = Song(title = "No Capo")
val songWith = Song(title = "With Capo", capo = 2)
val heightWithout = engine.measure(songWithout).totalHeightMm
val heightWith = engine.measure(songWith).totalHeightMm
heightWith shouldBeGreaterThan heightWithout
}
@Test
fun `chords add extra height compared to lyrics only`() {
val songWithoutChords = Song(
title = "No Chords",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Just lyrics"))))
)
)
)
val songWithChords = Song(
title = "With Chords",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(chord = "Am", text = "With chords"))))
)
)
)
val heightWithout = engine.measure(songWithoutChords).totalHeightMm
val heightWith = engine.measure(songWithChords).totalHeightMm
heightWith shouldBeGreaterThan heightWithout
}
@Test
fun `chorus section label adds height`() {
val songWithChorus = Song(
title = "Chorus Song",
sections = listOf(
SongSection(
type = SectionType.CHORUS,
lines = listOf(SongLine(listOf(LineSegment(text = "Chorus line"))))
)
)
)
val songWithVerse = Song(
title = "Verse Song",
sections = listOf(
SongSection(
type = SectionType.VERSE,
// No label, type is VERSE - no label height added
lines = listOf(SongLine(listOf(LineSegment(text = "Verse line"))))
)
)
)
val chorusHeight = engine.measure(songWithChorus).totalHeightMm
val verseHeight = engine.measure(songWithVerse).totalHeightMm
// Chorus always gets a section label, verse without label does not
chorusHeight shouldBeGreaterThan verseHeight
}
@Test
fun `empty chorus repeat reference adds height without lines`() {
val song = Song(
title = "Repeat Song",
sections = listOf(
SongSection(
type = SectionType.CHORUS,
lines = emptyList() // chorus repeat reference
)
)
)
val result = engine.measure(song)
// Should have title + gap + chorus label height + chorus repeat height + verse spacing
result.totalHeightMm shouldBeGreaterThan 0f
}
@Test
fun `notes add height at bottom`() {
val songWithout = Song(title = "No Notes")
val songWith = Song(title = "With Notes", notes = listOf("Note 1", "Note 2"))
val heightWithout = engine.measure(songWithout).totalHeightMm
val heightWith = engine.measure(songWith).totalHeightMm
heightWith shouldBeGreaterThan heightWithout
}
@Test
fun `verse spacing is added per section`() {
val oneSectionSong = Song(
title = "One Section",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val twoSectionSong = Song(
title = "Two Sections",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
),
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val oneHeight = engine.measure(oneSectionSong).totalHeightMm
val twoHeight = engine.measure(twoSectionSong).totalHeightMm
twoHeight shouldBeGreaterThan oneHeight
}
@Test
fun `section with label adds label height`() {
val songWithLabel = Song(
title = "Labeled",
sections = listOf(
SongSection(
type = SectionType.VERSE,
label = "Verse 1",
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val songWithoutLabel = Song(
title = "Unlabeled",
sections = listOf(
SongSection(
type = SectionType.VERSE,
label = null,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val labeledHeight = engine.measure(songWithLabel).totalHeightMm
val unlabeledHeight = engine.measure(songWithoutLabel).totalHeightMm
labeledHeight shouldBeGreaterThan unlabeledHeight
}
}

View File

@@ -0,0 +1,205 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import kotlin.test.Test
class PaginationEngineTest {
private val config = BookConfig(images = ImagesConfig(directory = "/nonexistent/images"))
private val engine = PaginationEngine(config)
private fun song(title: String) = Song(title = title)
private fun onePage(song: Song) = MeasuredSong(song, 100f, 1)
private fun twoPage(song: Song) = MeasuredSong(song, 200f, 2)
@Test
fun `single page songs are placed sequentially`() {
val songs = listOf(
onePage(song("Song A")),
onePage(song("Song B")),
onePage(song("Song C"))
)
val pages = engine.paginate(songs, tocPages = 2)
pages shouldHaveSize 3
pages.forEach { it.shouldBeInstanceOf<PageContent.SongPage>() }
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[2] as PageContent.SongPage).song.title shouldBe "Song C"
}
@Test
fun `single page songs all have pageIndex 0`() {
val songs = listOf(
onePage(song("Song A")),
onePage(song("Song B"))
)
val pages = engine.paginate(songs, tocPages = 2)
pages.forEach {
(it as PageContent.SongPage).pageIndex shouldBe 0
}
}
@Test
fun `two page song starting on left page has no filler`() {
// tocPages = 2, so content starts at page 3 (odd/right page)
// First one-page song occupies page 3, next page is 4 (even/left)
val songs = listOf(
onePage(song("Song A")),
twoPage(song("Song B"))
)
val pages = engine.paginate(songs, tocPages = 2)
// Song A at page 3, Song B starts at page 4 (even = left)
pages shouldHaveSize 3
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
(pages[2] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
}
@Test
fun `two page song on odd page gets blank filler before it`() {
// tocPages = 2, content starts at page 3 (odd/right)
// First 2-page song needs to start on even page, so filler at page 3
val songs = listOf(
twoPage(song("Song A"))
)
val pages = engine.paginate(songs, tocPages = 2)
// Blank at page 3, Song A at pages 4-5
pages shouldHaveSize 3
pages[0].shouldBeInstanceOf<PageContent.BlankPage>()
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
(pages[2] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
}
@Test
fun `two page song after two single page songs does not need filler`() {
// tocPages = 2, content starts at page 3
// Song A at page 3, Song B at page 4, Song C (2-page) should start at page 5 (odd)
// Page 5 is odd, so it needs filler
val songs = listOf(
onePage(song("Song A")),
onePage(song("Song B")),
twoPage(song("Song C"))
)
val pages = engine.paginate(songs, tocPages = 2)
// Song A at 3, Song B at 4, filler at 5, Song C at 6-7
pages shouldHaveSize 5
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
pages[2].shouldBeInstanceOf<PageContent.BlankPage>()
(pages[3] as PageContent.SongPage).song.title shouldBe "Song C"
(pages[4] as PageContent.SongPage).song.title shouldBe "Song C"
}
@Test
fun `two consecutive two-page songs are placed correctly`() {
// tocPages = 2, content starts at page 3 (odd)
// Song A (2-page): needs even start -> filler at 3, Song A at 4-5
// Song B (2-page): next page is 6 (even/left) -> no filler, Song B at 6-7
val songs = listOf(
twoPage(song("Song A")),
twoPage(song("Song B"))
)
val pages = engine.paginate(songs, tocPages = 2)
pages shouldHaveSize 5
pages[0].shouldBeInstanceOf<PageContent.BlankPage>()
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[2] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[3] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[4] as PageContent.SongPage).song.title shouldBe "Song B"
}
@Test
fun `empty input produces empty output`() {
val pages = engine.paginate(emptyList(), tocPages = 2)
pages shouldHaveSize 0
}
@Test
fun `tocPages affects page numbering for alignment`() {
// tocPages = 3, content starts at page 4 (even/left)
// 2-page song should start directly on page 4 (even) - no filler needed
val songs = listOf(
twoPage(song("Song A"))
)
val pages = engine.paginate(songs, tocPages = 3)
// Page 4 is even -> no filler needed
pages shouldHaveSize 2
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[0] as PageContent.SongPage).pageIndex shouldBe 0
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).pageIndex shouldBe 1
}
@Test
fun `filler uses image when images directory exists`() {
// Create a temp directory with an image file
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-images").toFile()
try {
val imageFile = java.io.File(tempDir, "filler.png")
imageFile.writeText("fake image")
val configWithImages = BookConfig(images = ImagesConfig(directory = tempDir.absolutePath))
val engineWithImages = PaginationEngine(configWithImages)
val songs = listOf(twoPage(song("Song A")))
val pages = engineWithImages.paginate(songs, tocPages = 2)
// tocPages=2, start at page 3 (odd), needs filler
pages shouldHaveSize 3
val filler = pages[0]
filler.shouldBeInstanceOf<PageContent.FillerImage>()
(filler as PageContent.FillerImage).imagePath shouldBe imageFile.absolutePath
} finally {
tempDir.deleteRecursively()
}
}
@Test
fun `mixed single and two-page songs layout correctly`() {
// tocPages = 4, content starts at page 5 (odd)
val songs = listOf(
onePage(song("Song A")), // page 5
twoPage(song("Song B")), // starts page 6 (even) - no filler
onePage(song("Song C")), // page 8
onePage(song("Song D")), // page 9
twoPage(song("Song E")) // starts page 10 (even) - no filler
)
val pages = engine.paginate(songs, tocPages = 4)
pages shouldHaveSize 7
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
(pages[2] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
(pages[3] as PageContent.SongPage).song.title shouldBe "Song C"
(pages[4] as PageContent.SongPage).song.title shouldBe "Song D"
(pages[5] as PageContent.SongPage).song.title shouldBe "Song E"
(pages[5] as PageContent.SongPage).pageIndex shouldBe 0
(pages[6] as PageContent.SongPage).song.title shouldBe "Song E"
(pages[6] as PageContent.SongPage).pageIndex shouldBe 1
}
}

View File

@@ -0,0 +1,12 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.FontMetrics
import de.pfadfinder.songbook.model.FontSpec
class StubFontMetrics : FontMetrics {
override fun measureTextWidth(text: String, font: FontSpec, size: Float): Float =
text.length * size * 0.5f * 0.3528f
override fun measureLineHeight(font: FontSpec, size: Float): Float =
size * 1.2f * 0.3528f
}

View File

@@ -0,0 +1,211 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class TocGeneratorTest {
private val config = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO"),
ReferenceBook(id = "kljb", name = "KLJB Liederbuch", abbreviation = "KLJB")
)
)
private val generator = TocGenerator(config)
@Test
fun `generate creates entries for songs sorted alphabetically`() {
val pages = listOf(
PageContent.SongPage(Song(title = "Zebra Song"), 0),
PageContent.SongPage(Song(title = "Alpha Song"), 0),
PageContent.SongPage(Song(title = "Middle Song"), 0)
)
val entries = generator.generate(pages, tocStartPage = 0)
entries shouldHaveSize 3
entries[0].title shouldBe "Alpha Song"
entries[1].title shouldBe "Middle Song"
entries[2].title shouldBe "Zebra Song"
}
@Test
fun `generate assigns correct page numbers`() {
val pages = listOf(
PageContent.SongPage(Song(title = "Song A"), 0), // page 1
PageContent.SongPage(Song(title = "Song B"), 0), // page 2
PageContent.SongPage(Song(title = "Song C"), 0) // page 3
)
val entries = generator.generate(pages, tocStartPage = 0)
entries.find { it.title == "Song A" }!!.pageNumber shouldBe 1
entries.find { it.title == "Song B" }!!.pageNumber shouldBe 2
entries.find { it.title == "Song C" }!!.pageNumber shouldBe 3
}
@Test
fun `generate with tocStartPage offsets page numbers`() {
val pages = listOf(
PageContent.SongPage(Song(title = "Song A"), 0)
)
val entries = generator.generate(pages, tocStartPage = 4)
entries[0].pageNumber shouldBe 5
}
@Test
fun `generate creates alias entries`() {
val song = Song(title = "Original Title", aliases = listOf("Alias One", "Alias Two"))
val pages = listOf(
PageContent.SongPage(song, 0)
)
val entries = generator.generate(pages, tocStartPage = 0)
entries shouldHaveSize 3
// Sorted: Alias One, Alias Two, Original Title
entries[0].title shouldBe "Alias One"
entries[0].isAlias shouldBe true
entries[0].pageNumber shouldBe 1
entries[1].title shouldBe "Alias Two"
entries[1].isAlias shouldBe true
entries[1].pageNumber shouldBe 1
entries[2].title shouldBe "Original Title"
entries[2].isAlias shouldBe false
entries[2].pageNumber shouldBe 1
}
@Test
fun `generate maps reference book IDs to abbreviations`() {
val song = Song(
title = "Referenced Song",
references = mapOf("mundorgel" to 42, "kljb" to 117)
)
val pages = listOf(PageContent.SongPage(song, 0))
val entries = generator.generate(pages, tocStartPage = 0)
entries shouldHaveSize 1
entries[0].references shouldBe mapOf("MO" to 42, "KLJB" to 117)
}
@Test
fun `generate keeps unknown reference book IDs as-is`() {
val song = Song(
title = "Song",
references = mapOf("unknown_book" to 5)
)
val pages = listOf(PageContent.SongPage(song, 0))
val entries = generator.generate(pages, tocStartPage = 0)
entries[0].references shouldBe mapOf("unknown_book" to 5)
}
@Test
fun `generate skips filler and blank pages for page numbering`() {
val pages = listOf(
PageContent.BlankPage, // page 1
PageContent.SongPage(Song(title = "Song A"), 0), // page 2
PageContent.FillerImage("/path/to/image.png"), // page 3
PageContent.SongPage(Song(title = "Song B"), 0) // page 4
)
val entries = generator.generate(pages, tocStartPage = 0)
entries shouldHaveSize 2
entries.find { it.title == "Song A" }!!.pageNumber shouldBe 2
entries.find { it.title == "Song B" }!!.pageNumber shouldBe 4
}
@Test
fun `generate handles two-page songs correctly`() {
val song = Song(title = "Long Song")
val pages = listOf(
PageContent.SongPage(song, 0), // page 1 - first page of song
PageContent.SongPage(song, 1) // page 2 - second page of song
)
val entries = generator.generate(pages, tocStartPage = 0)
// Should only have one entry pointing to the first page
entries shouldHaveSize 1
entries[0].title shouldBe "Long Song"
entries[0].pageNumber shouldBe 1
}
@Test
fun `generate aliases share references with original song`() {
val song = Song(
title = "Main Song",
aliases = listOf("Alt Name"),
references = mapOf("mundorgel" to 10)
)
val pages = listOf(PageContent.SongPage(song, 0))
val entries = generator.generate(pages, tocStartPage = 0)
entries shouldHaveSize 2
val alias = entries.find { it.isAlias }!!
alias.references shouldBe mapOf("MO" to 10)
val main = entries.find { !it.isAlias }!!
main.references shouldBe mapOf("MO" to 10)
}
@Test
fun `generate with empty pages produces empty entries`() {
val entries = generator.generate(emptyList(), tocStartPage = 0)
entries.shouldBeEmpty()
}
@Test
fun `estimateTocPages returns even number`() {
val songs = (1..10).map { Song(title = "Song $it") }
val pages = generator.estimateTocPages(songs)
(pages % 2) shouldBe 0
}
@Test
fun `estimateTocPages accounts for aliases`() {
val songsWithoutAliases = (1..10).map { Song(title = "Song $it") }
val songsWithAliases = (1..10).map { Song(title = "Song $it", aliases = listOf("Alias $it")) }
val pagesWithout = generator.estimateTocPages(songsWithoutAliases)
val pagesWith = generator.estimateTocPages(songsWithAliases)
pagesWith shouldBe pagesWithout // both under 40 entries, same page count
}
@Test
fun `estimateTocPages with many songs returns more pages`() {
val fewSongs = (1..10).map { Song(title = "Song $it") }
val manySongs = (1..200).map { Song(title = "Song $it") }
val fewPages = generator.estimateTocPages(fewSongs)
val manyPages = generator.estimateTocPages(manySongs)
// 200 songs / 40 per page = 5 + 1 = 6 pages (already even)
manyPages shouldBe 6
fewPages shouldBe 2 // (10/40)+1 = 1, rounded up to 2 for even
}
@Test
fun `generate sorts case-insensitively`() {
val pages = listOf(
PageContent.SongPage(Song(title = "banana"), 0),
PageContent.SongPage(Song(title = "Apple"), 0),
PageContent.SongPage(Song(title = "cherry"), 0)
)
val entries = generator.generate(pages, tocStartPage = 0)
entries[0].title shouldBe "Apple"
entries[1].title shouldBe "banana"
entries[2].title shouldBe "cherry"
}
}