feat: support custom font files for song titles (Closes #4)

- Improve PdfFontMetrics: use canonical path for cache key, validate
  font file existence, use absolute paths for BaseFont.createFont
- Add font file path resolution in SongbookPipeline (relative to
  project directory)
- Add font file existence validation in Validator.validateConfig
- Add end-to-end tests: custom font loading, umlaut rendering,
  cache deduplication, missing file error
- Document custom font file usage in example songbook.yaml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #14.
This commit is contained in:
shahondin1624
2026-03-17 09:54:15 +01:00
parent b339c10ca0
commit ab91ad2db6
8 changed files with 205 additions and 6 deletions

View File

@@ -3,15 +3,21 @@ package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.pdf.BaseFont
import de.pfadfinder.songbook.model.FontMetrics
import de.pfadfinder.songbook.model.FontSpec
import java.io.File
class PdfFontMetrics : FontMetrics {
private val fontCache = mutableMapOf<String, BaseFont>()
fun getBaseFont(font: FontSpec): BaseFont {
val key = font.file ?: font.family
val fontFile = font.file
val key = if (fontFile != null) File(fontFile).canonicalPath else font.family
return fontCache.getOrPut(key) {
if (font.file != null) {
BaseFont.createFont(font.file, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
if (fontFile != null) {
val file = File(fontFile)
if (!file.exists()) {
throw IllegalArgumentException("Font file not found: ${file.absolutePath}")
}
BaseFont.createFont(file.absolutePath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
} else {
// Map common family names to built-in PDF fonts
val pdfFontName = when (font.family.lowercase()) {

View File

@@ -6,6 +6,7 @@ import io.kotest.matchers.floats.shouldBeLessThan
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeSameInstanceAs
import kotlin.test.Test
import kotlin.test.assertFailsWith
class PdfFontMetricsTest {
@@ -158,4 +159,73 @@ class PdfFontMetricsTest {
height shouldBeGreaterThan 3f
height shouldBeLessThan 6f
}
// --- Custom font file tests ---
private val testFontPath: String
get() = this::class.java.getResource("/TestFont.ttf")!!.file
@Test
fun `getBaseFont loads custom font from file path`() {
val font = FontSpec(file = testFontPath, size = 12f)
val baseFont = metrics.getBaseFont(font)
// Custom font should load successfully and have a non-null PostScript name
baseFont.postscriptFontName.isNotEmpty() shouldBe true
}
@Test
fun `getBaseFont caches custom font by canonical path`() {
val font1 = FontSpec(file = testFontPath, size = 12f)
val font2 = FontSpec(file = testFontPath, size = 14f) // different size, same file
val first = metrics.getBaseFont(font1)
val second = metrics.getBaseFont(font2)
first shouldBeSameInstanceAs second
}
@Test
fun `getBaseFont throws for missing font file`() {
val font = FontSpec(file = "/nonexistent/path/MissingFont.ttf", size = 12f)
assertFailsWith<IllegalArgumentException> {
metrics.getBaseFont(font)
}
}
@Test
fun `getBaseFontBold returns same font when file is specified`() {
val font = FontSpec(file = testFontPath, size = 12f)
val regular = metrics.getBaseFont(font)
val bold = metrics.getBaseFontBold(font)
// Custom fonts don't have auto-resolved bold variants
regular shouldBeSameInstanceAs bold
}
@Test
fun `measureTextWidth works with custom font file`() {
val font = FontSpec(file = testFontPath, size = 12f)
val width = metrics.measureTextWidth("Hello World", font, 12f)
width shouldBeGreaterThan 0f
}
@Test
fun `measureTextWidth handles German umlauts with custom font`() {
val font = FontSpec(file = testFontPath, size = 12f)
// These should not throw and should return positive widths
val umlautWidth = metrics.measureTextWidth("\u00e4\u00f6\u00fc\u00df", font, 12f)
umlautWidth shouldBeGreaterThan 0f
// Full German words with umlauts
val wordWidth = metrics.measureTextWidth("Gr\u00fc\u00dfe aus \u00d6sterreich", font, 12f)
wordWidth shouldBeGreaterThan 0f
}
@Test
fun `measureTextWidth with custom font returns different width than built-in font`() {
val customFont = FontSpec(file = testFontPath, size = 10f)
val builtInFont = FontSpec(family = "Courier", size = 10f) // use Courier for contrast
val customWidth = metrics.measureTextWidth("Test text", customFont, 10f)
val builtInWidth = metrics.measureTextWidth("Test text", builtInFont, 10f)
// They should both be positive but likely different
customWidth shouldBeGreaterThan 0f
builtInWidth shouldBeGreaterThan 0f
}
}

Binary file not shown.