2 Commits

Author SHA1 Message Date
shahondin1624
5378bdbc24 docs: update CLAUDE.md to reflect current codebase
Add ForewordParser pipeline step, document all Song/SongLine fields,
Foreword type, ForewordPage variant, BookConfig subtypes, all ChordPro
directives, and new Configuration section for songbook.yaml options.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 10:01:48 +01:00
shahondin1624
ab91ad2db6 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>
2026-03-17 09:54:15 +01:00
9 changed files with 241 additions and 16 deletions

View File

@@ -35,16 +35,17 @@ Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.
## Architecture ## Architecture
**Pipeline:** Parse → Measure → Paginate → Render **Pipeline:** Parse → Validate → Measure → Paginate → Render
`SongbookPipeline` (in `app`) orchestrates the full flow: `SongbookPipeline` (in `app`) orchestrates the full flow:
1. `ConfigParser` reads `songbook.yaml``BookConfig` 1. `ConfigParser` reads `songbook.yaml``BookConfig`
2. `ChordProParser` reads `.chopro` files → `Song` objects 2. `ChordProParser` reads `.chopro`/`.cho`/`.crd` files → `Song` objects
3. `Validator` checks config and songs 3. `ForewordParser` reads optional `foreword.txt``Foreword` (if configured)
4. `MeasurementEngine` calculates each song's height in mm using `FontMetrics` 4. `Validator` checks config and songs
5. `TocGenerator` estimates TOC page count and creates entries 5. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
6. `PaginationEngine` arranges songs into pages (greedy spread packing) 6. `TocGenerator` estimates TOC page count and creates entries
7. `PdfBookRenderer` generates the PDF via OpenPDF 7. `PaginationEngine` arranges songs into pages (greedy spread packing)
8. `PdfBookRenderer` generates the PDF via OpenPDF
**Module dependency graph:** **Module dependency graph:**
``` ```
@@ -62,14 +63,39 @@ app, parser ← gui (Compose Desktop)
## Key Types ## Key Types
- `Song` → sections → `SongLine``LineSegment(chord?, text)` — chord is placed above the text segment - `Song` → sections → `SongLine``LineSegment(chord?, text)` — chord is placed above the text segment. Also has `aliases`, `lyricist`, `composer`, `key`, `tags`, `notes: List<String>`, `references: Map<String, Int>` (bookId → page), `capo`
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage` - `SongLine` — holds `segments` plus optional `imagePath` (when set, the line is an inline image)
- `Foreword``quote`, `paragraphs`, `signatures` — parsed from a plain-text file
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`, `ForewordPage`
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT` - `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
- `BookConfig` — top-level config with `FontsConfig`, `LayoutConfig`, `TocConfig`, `ForewordConfig`, `ReferenceBook` list. `FontSpec.file` supports custom font files. `LayoutConfig.metadataLabels` (`"abbreviated"` or `"german"`) and `metadataPosition` (`"top"` or `"bottom"`) control metadata rendering
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts - `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
## Song Format ## Song Format
ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples. ChordPro-compatible `.chopro`/`.cho`/`.crd` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
**Metadata directives:** `{title: }` / `{t: }`, `{alias: }`, `{lyricist: }`, `{composer: }`, `{key: }`, `{tags: }`, `{note: }`, `{capo: }`
**Section directives:** `{start_of_verse}` / `{sov}`, `{end_of_verse}` / `{eov}`, `{start_of_chorus}` / `{soc}`, `{end_of_chorus}` / `{eoc}`, `{start_of_repeat}` / `{sor}`, `{end_of_repeat}` / `{eor}`. Section starts accept an optional label. `{chorus}` inserts a chorus reference, `{repeat}` sets a repeat label.
**Notes block:** `{start_of_notes}` / `{son}``{end_of_notes}` / `{eon}` — multi-paragraph rich-text notes rendered at the end of a song.
**Inline image:** `{image: path}` — embeds an image within a song section.
**Reference:** `{ref: bookId pageNumber}` — cross-reference to a page in another songbook (configured in `reference_books`).
## Configuration
`songbook.yaml` at the project root. Key options beyond the basics:
- `fonts.<role>.file` — path to a custom font file (TTF/OTF) for any font role (`lyrics`, `chords`, `title`, `metadata`, `toc`)
- `layout.metadata_labels``"abbreviated"` (M:/T:) or `"german"` (Worte:/Weise:)
- `layout.metadata_position``"top"` (after title) or `"bottom"` (bottom of last page)
- `toc.highlight_column` — abbreviation of the reference-book column to highlight (e.g. `"CL"`)
- `foreword.file` — path to a foreword text file (default `./foreword.txt`)
- `reference_books` — list of `{id, name, abbreviation}` for cross-reference columns in the TOC
- `songs.order``"alphabetical"` or `"manual"` (file-system order)
## Test Patterns ## Test Patterns

View File

@@ -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))

View File

@@ -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"
)
)
}
}
} }

View File

@@ -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"
}
} }

View File

@@ -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()
}
}
} }

View File

@@ -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()) {

View File

@@ -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
}
} }

Binary file not shown.

View File

@@ -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 }