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

View File

@@ -0,0 +1,70 @@
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)
}
}

View File

@@ -0,0 +1,37 @@
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()
}
}

View File

@@ -0,0 +1,248 @@
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
}
}
}

View File

@@ -0,0 +1,54 @@
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
}
}

View File

@@ -0,0 +1,79 @@
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
}
}

View File

@@ -0,0 +1,103 @@
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
}
}
}

View File

@@ -0,0 +1,55 @@
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)
}
}
}

View File

@@ -0,0 +1,420 @@
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
}
}

View File

@@ -0,0 +1,161 @@
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
}
}

View File

@@ -0,0 +1,124 @@
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
}
}