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

@@ -1,7 +1,9 @@
package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.BookConfig
import de.pfadfinder.songbook.model.FontSpec
import de.pfadfinder.songbook.model.Song
import java.io.File
data class ValidationError(val file: String?, val line: Int?, val message: String)
@@ -50,6 +52,27 @@ object Validator {
if (outer <= 0) errors.add(ValidationError(file = null, line = null, message = "Outer margin must be greater than 0"))
}
// Validate font files exist (paths should already be resolved to absolute by the pipeline)
validateFontFile(config.fonts.lyrics, "lyrics", errors)
validateFontFile(config.fonts.chords, "chords", errors)
validateFontFile(config.fonts.title, "title", errors)
validateFontFile(config.fonts.metadata, "metadata", errors)
validateFontFile(config.fonts.toc, "toc", errors)
return errors
}
private fun validateFontFile(font: FontSpec, fontRole: String, errors: MutableList<ValidationError>) {
val fontFile = font.file ?: return
val file = File(fontFile)
if (!file.exists()) {
errors.add(
ValidationError(
file = null,
line = null,
message = "Font file for '$fontRole' not found: $fontFile"
)
)
}
}
}

View File

@@ -251,4 +251,21 @@ class ConfigParserTest {
val config = ConfigParser.parse(yaml)
config.book.title shouldBe "Test"
}
@Test
fun `parse config with custom title font file only`() {
val yaml = """
book:
title: "Fraktur Test"
fonts:
title: { file: "./fonts/Fraktur.ttf", size: 16 }
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.fonts.title.file shouldBe "./fonts/Fraktur.ttf"
config.fonts.title.size shouldBe 16f
config.fonts.title.family shouldBe "Helvetica" // default family as fallback
// Other fonts should still use defaults
config.fonts.lyrics.file.shouldBeNull()
config.fonts.lyrics.family shouldBe "Helvetica"
}
}

View File

@@ -206,4 +206,53 @@ class ValidatorTest {
errors shouldHaveSize 1
errors[0].file shouldContain "myfile.chopro"
}
@Test
fun `missing font file produces validation error`() {
val config = BookConfig(
fonts = FontsConfig(
title = FontSpec(file = "/nonexistent/path/FrakturFont.ttf", size = 14f)
)
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "title"
errors[0].message shouldContain "not found"
}
@Test
fun `multiple missing font files produce multiple errors`() {
val config = BookConfig(
fonts = FontsConfig(
title = FontSpec(file = "/nonexistent/title.ttf", size = 14f),
lyrics = FontSpec(file = "/nonexistent/lyrics.ttf", size = 10f)
)
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 2
}
@Test
fun `config with no font files produces no font errors`() {
val config = BookConfig() // all default built-in fonts
val errors = Validator.validateConfig(config)
errors.shouldBeEmpty()
}
@Test
fun `config with existing font file produces no error`() {
// Create a temporary file to simulate an existing font file
val tempFile = kotlin.io.path.createTempFile(suffix = ".ttf").toFile()
try {
val config = BookConfig(
fonts = FontsConfig(
title = FontSpec(file = tempFile.absolutePath, size = 14f)
)
)
val errors = Validator.validateConfig(config)
errors.shouldBeEmpty()
} finally {
tempFile.delete()
}
}
}