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")))
|
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
||||||
}
|
}
|
||||||
logger.info { "Parsing config: ${configFile.absolutePath}" }
|
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)
|
val configErrors = Validator.validateConfig(config)
|
||||||
if (configErrors.isNotEmpty()) {
|
if (configErrors.isNotEmpty()) {
|
||||||
return BuildResult(false, errors = configErrors)
|
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> {
|
fun validate(): List<ValidationError> {
|
||||||
val configFile = File(projectDir, "songbook.yaml")
|
val configFile = File(projectDir, "songbook.yaml")
|
||||||
if (!configFile.exists()) {
|
if (!configFile.exists()) {
|
||||||
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
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>()
|
val errors = mutableListOf<ValidationError>()
|
||||||
errors.addAll(Validator.validateConfig(config))
|
errors.addAll(Validator.validateConfig(config))
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.pfadfinder.songbook.parser
|
package de.pfadfinder.songbook.parser
|
||||||
|
|
||||||
import de.pfadfinder.songbook.model.BookConfig
|
import de.pfadfinder.songbook.model.BookConfig
|
||||||
|
import de.pfadfinder.songbook.model.FontSpec
|
||||||
import de.pfadfinder.songbook.model.Song
|
import de.pfadfinder.songbook.model.Song
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
data class ValidationError(val file: String?, val line: Int?, val message: String)
|
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"))
|
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
|
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)
|
val config = ConfigParser.parse(yaml)
|
||||||
config.book.title shouldBe "Test"
|
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 shouldHaveSize 1
|
||||||
errors[0].file shouldContain "myfile.chopro"
|
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 com.lowagie.text.pdf.BaseFont
|
||||||
import de.pfadfinder.songbook.model.FontMetrics
|
import de.pfadfinder.songbook.model.FontMetrics
|
||||||
import de.pfadfinder.songbook.model.FontSpec
|
import de.pfadfinder.songbook.model.FontSpec
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class PdfFontMetrics : FontMetrics {
|
class PdfFontMetrics : FontMetrics {
|
||||||
private val fontCache = mutableMapOf<String, BaseFont>()
|
private val fontCache = mutableMapOf<String, BaseFont>()
|
||||||
|
|
||||||
fun getBaseFont(font: FontSpec): 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) {
|
return fontCache.getOrPut(key) {
|
||||||
if (font.file != null) {
|
if (fontFile != null) {
|
||||||
BaseFont.createFont(font.file, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
|
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 {
|
} else {
|
||||||
// Map common family names to built-in PDF fonts
|
// Map common family names to built-in PDF fonts
|
||||||
val pdfFontName = when (font.family.lowercase()) {
|
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.shouldBe
|
||||||
import io.kotest.matchers.types.shouldBeSameInstanceAs
|
import io.kotest.matchers.types.shouldBeSameInstanceAs
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
|
||||||
class PdfFontMetricsTest {
|
class PdfFontMetricsTest {
|
||||||
|
|
||||||
@@ -158,4 +159,73 @@ class PdfFontMetricsTest {
|
|||||||
height shouldBeGreaterThan 3f
|
height shouldBeGreaterThan 3f
|
||||||
height shouldBeLessThan 6f
|
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 }
|
title: { family: "Helvetica", size: 14 }
|
||||||
metadata: { family: "Helvetica", size: 8 }
|
metadata: { family: "Helvetica", size: 8 }
|
||||||
toc: { family: "Helvetica", size: 9 }
|
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:
|
layout:
|
||||||
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
|
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
|
||||||
|
|||||||
Reference in New Issue
Block a user