diff --git a/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt b/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt index e506bef..fc1aae2 100644 --- a/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt +++ b/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt @@ -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 { 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() errors.addAll(Validator.validateConfig(config)) diff --git a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/Validator.kt b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/Validator.kt index eb9f3b6..f01ae4d 100644 --- a/parser/src/main/kotlin/de/pfadfinder/songbook/parser/Validator.kt +++ b/parser/src/main/kotlin/de/pfadfinder/songbook/parser/Validator.kt @@ -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) { + 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" + ) + ) + } + } } diff --git a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt index cdbb844..014fecb 100644 --- a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt +++ b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ConfigParserTest.kt @@ -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" + } } diff --git a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ValidatorTest.kt b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ValidatorTest.kt index b416cae..3df3e03 100644 --- a/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ValidatorTest.kt +++ b/parser/src/test/kotlin/de/pfadfinder/songbook/parser/ValidatorTest.kt @@ -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() + } + } } diff --git a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetrics.kt b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetrics.kt index 6bbd82f..c3fc9a4 100644 --- a/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetrics.kt +++ b/renderer-pdf/src/main/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetrics.kt @@ -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() 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()) { diff --git a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetricsTest.kt b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetricsTest.kt index af9cfe5..a863bce 100644 --- a/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetricsTest.kt +++ b/renderer-pdf/src/test/kotlin/de/pfadfinder/songbook/renderer/pdf/PdfFontMetricsTest.kt @@ -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 { + 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 + } } diff --git a/renderer-pdf/src/test/resources/TestFont.ttf b/renderer-pdf/src/test/resources/TestFont.ttf new file mode 100644 index 0000000..6b7e0e3 Binary files /dev/null and b/renderer-pdf/src/test/resources/TestFont.ttf differ diff --git a/songbook.yaml b/songbook.yaml index 5cc7c7c..ca14ebc 100644 --- a/songbook.yaml +++ b/songbook.yaml @@ -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 }