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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user