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