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:
@@ -28,9 +28,12 @@ class SongbookPipeline(private val projectDir: File) {
|
||||
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
||||
}
|
||||
logger.info { "Parsing config: ${configFile.absolutePath}" }
|
||||
val config = ConfigParser.parse(configFile)
|
||||
val rawConfig = ConfigParser.parse(configFile)
|
||||
|
||||
// Validate config
|
||||
// Resolve font file paths relative to the project directory
|
||||
val config = resolveFontPaths(rawConfig)
|
||||
|
||||
// Validate config (including font file existence)
|
||||
val configErrors = Validator.validateConfig(config)
|
||||
if (configErrors.isNotEmpty()) {
|
||||
return BuildResult(false, errors = configErrors)
|
||||
@@ -149,13 +152,39 @@ class SongbookPipeline(private val projectDir: File) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves font file paths relative to the project directory.
|
||||
* If a FontSpec has a `file` property, it is resolved against projectDir
|
||||
* to produce an absolute path.
|
||||
*/
|
||||
private fun resolveFontPaths(config: BookConfig): BookConfig {
|
||||
fun FontSpec.resolveFile(): FontSpec {
|
||||
val fontFile = this.file ?: return this
|
||||
val fontFileObj = File(fontFile)
|
||||
// Only resolve relative paths; absolute paths are left as-is
|
||||
if (fontFileObj.isAbsolute) return this
|
||||
val resolved = File(projectDir, fontFile)
|
||||
return this.copy(file = resolved.absolutePath)
|
||||
}
|
||||
|
||||
val resolvedFonts = config.fonts.copy(
|
||||
lyrics = config.fonts.lyrics.resolveFile(),
|
||||
chords = config.fonts.chords.resolveFile(),
|
||||
title = config.fonts.title.resolveFile(),
|
||||
metadata = config.fonts.metadata.resolveFile(),
|
||||
toc = config.fonts.toc.resolveFile()
|
||||
)
|
||||
return config.copy(fonts = resolvedFonts)
|
||||
}
|
||||
|
||||
fun validate(): List<ValidationError> {
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
if (!configFile.exists()) {
|
||||
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
||||
}
|
||||
|
||||
val config = ConfigParser.parse(configFile)
|
||||
val rawConfig = ConfigParser.parse(configFile)
|
||||
val config = resolveFontPaths(rawConfig)
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
errors.addAll(Validator.validateConfig(config))
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
@@ -14,6 +14,11 @@ fonts:
|
||||
title: { family: "Helvetica", size: 14 }
|
||||
metadata: { family: "Helvetica", size: 8 }
|
||||
toc: { family: "Helvetica", size: 9 }
|
||||
# To use a custom font file (e.g. Fraktur/Blackletter for titles):
|
||||
# title: { file: "./fonts/FrakturFont.ttf", size: 16 }
|
||||
# The file path is relative to the project directory.
|
||||
# Supported formats: .ttf, .otf
|
||||
# Custom fonts are embedded in the PDF and support Unicode (including umlauts).
|
||||
|
||||
layout:
|
||||
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
|
||||
|
||||
Reference in New Issue
Block a user