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:
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
BIN
renderer-pdf/src/test/resources/TestFont.ttf
Normal file
BIN
renderer-pdf/src/test/resources/TestFont.ttf
Normal file
Binary file not shown.
Reference in New Issue
Block a user