Compare commits

...

5 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
shahondin1624
b339c10ca0 feat: use Worte/Weise labels and render metadata at page bottom (Closes #5)
Add metadata_labels ("abbreviated"/"german") and metadata_position
("top"/"bottom") options to LayoutConfig. German labels use "Worte:" and
"Weise:" instead of "T:" and "M:", with "Worte und Weise:" when lyricist
and composer are the same person. Metadata at bottom position renders
after notes with word-wrapping. MeasurementEngine accounts for two-line
metadata in German label mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:46:06 +01:00
shahondin1624
8dca7d7131 feat: support inline images within song pages (Closes #2)
Add {image: path} directive to embed images at any position within a song's
sections. SongLine gains an optional imagePath field; when set, the line
represents an inline image rather than chord/lyric content. The renderer
scales and centers images within the content width. MeasurementEngine
reserves 40mm height per inline image for layout calculations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:45:26 +01:00
shahondin1624
8c92c7d78b feat: support rich multi-paragraph notes with formatting (Closes #7)
Add {start_of_notes}/{end_of_notes} (and short forms {son}/{eon}) block
directives to ChordProParser for multi-paragraph note content. Blank lines
within the block separate paragraphs. The renderer now word-wraps note
paragraphs to fit within the content width. MeasurementEngine estimates
wrapped line count for more accurate height calculations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:38:25 +01:00
16 changed files with 663 additions and 56 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

@@ -15,9 +15,16 @@ class MeasurementEngine(
// Title height // Title height
heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
// Metadata line (composer/lyricist) // Metadata lines (composer/lyricist) - may be 1 or 2 lines depending on label style
if (song.composer != null || song.lyricist != null) { if (song.composer != null || song.lyricist != null) {
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
val useGerman = config.layout.metadataLabels == "german"
if (useGerman && song.lyricist != null && song.composer != null && song.lyricist != song.composer) {
// Two separate lines: "Worte: ..." and "Weise: ..."
heightMm += metaLineHeight * 2
} else {
heightMm += metaLineHeight
}
} }
// Key/capo line // Key/capo line
@@ -43,6 +50,11 @@ class MeasurementEngine(
// Lines in section // Lines in section
for (line in section.lines) { for (line in section.lines) {
if (line.imagePath != null) {
// Inline image: estimate height as 40mm (default image block height)
heightMm += 40f
heightMm += 2f // gap around image
} else {
val hasChords = line.segments.any { it.chord != null } val hasChords = line.segments.any { it.chord != null }
val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size) val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size)
if (hasChords) { if (hasChords) {
@@ -53,16 +65,29 @@ class MeasurementEngine(
} }
heightMm += 0.35f // ~1pt gap between lines heightMm += 0.35f // ~1pt gap between lines
} }
}
// Verse spacing // Verse spacing
heightMm += config.layout.verseSpacing heightMm += config.layout.verseSpacing
} }
// Notes at bottom // Notes at bottom (with word-wrap estimation for multi-paragraph notes)
if (song.notes.isNotEmpty()) { if (song.notes.isNotEmpty()) {
heightMm += 1.5f // gap heightMm += 1.5f // gap before notes
for (note in song.notes) { val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f // A5 content width in mm = 148 - inner margin - outer margin
val contentWidthMm = 148f - config.layout.margins.inner - config.layout.margins.outer
for ((idx, note) in song.notes.withIndex()) {
// Estimate how many wrapped lines this note paragraph needs
val noteWidthMm = fontMetrics.measureTextWidth(note, config.fonts.metadata, config.fonts.metadata.size)
val estimatedLines = maxOf(1, kotlin.math.ceil((noteWidthMm / contentWidthMm).toDouble()).toInt())
heightMm += metaLineHeight * estimatedLines
// Paragraph spacing between note paragraphs
if (idx < song.notes.size - 1) {
heightMm += metaLineHeight * 0.3f
}
} }
} }

View File

@@ -324,4 +324,40 @@ class MeasurementEngineTest {
// Should be the same since no reference books are configured // Should be the same since no reference books are configured
heightWith shouldBe heightWithout heightWith shouldBe heightWithout
} }
@Test
fun `inline image adds significant height`() {
val songWithImage = Song(
title = "With Image",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(
SongLine(listOf(LineSegment(text = "Line before"))),
SongLine(imagePath = "images/test.png"),
SongLine(listOf(LineSegment(text = "Line after")))
)
)
)
)
val songWithoutImage = Song(
title = "No Image",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(
SongLine(listOf(LineSegment(text = "Line before"))),
SongLine(listOf(LineSegment(text = "Line after")))
)
)
)
)
val heightWith = engine.measure(songWithImage).totalHeightMm
val heightWithout = engine.measure(songWithoutImage).totalHeightMm
// Inline image adds ~42mm (40mm image + 2mm gap)
val diff = heightWith - heightWithout
diff shouldBeGreaterThan 30f // should be substantial
}
} }

View File

@@ -51,7 +51,9 @@ data class LayoutConfig(
val margins: Margins = Margins(), val margins: Margins = Margins(),
val chordLineSpacing: Float = 3f, // mm val chordLineSpacing: Float = 3f, // mm
val verseSpacing: Float = 4f, // mm val verseSpacing: Float = 4f, // mm
val pageNumberPosition: String = "bottom-outer" val pageNumberPosition: String = "bottom-outer",
val metadataLabels: String = "abbreviated", // "abbreviated" (M:/T:) or "german" (Worte:/Weise:)
val metadataPosition: String = "top" // "top" (after title) or "bottom" (bottom of last page)
) )
data class Margins( data class Margins(

View File

@@ -23,7 +23,10 @@ enum class SectionType {
VERSE, CHORUS, BRIDGE, REPEAT VERSE, CHORUS, BRIDGE, REPEAT
} }
data class SongLine(val segments: List<LineSegment>) data class SongLine(
val segments: List<LineSegment> = emptyList(),
val imagePath: String? = null // when non-null, this "line" is an inline image (segments ignored)
)
data class LineSegment( data class LineSegment(
val chord: String? = null, // null = no chord above this segment val chord: String? = null, // null = no chord above this segment

View File

@@ -25,6 +25,17 @@ object ChordProParser {
var currentLabel: String? = null var currentLabel: String? = null
var currentLines = mutableListOf<SongLine>() var currentLines = mutableListOf<SongLine>()
// Notes block state
var inNotesBlock = false
var currentNoteParagraph = StringBuilder()
fun flushNoteParagraph() {
if (currentNoteParagraph.isNotEmpty()) {
notes.add(currentNoteParagraph.toString().trim())
currentNoteParagraph = StringBuilder()
}
}
fun flushSection() { fun flushSection() {
if (currentType != null) { if (currentType != null) {
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList())) sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
@@ -37,6 +48,27 @@ object ChordProParser {
for (rawLine in lines) { for (rawLine in lines) {
val line = rawLine.trimEnd() val line = rawLine.trimEnd()
// Inside a notes block: collect lines as paragraphs
if (inNotesBlock) {
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
val inner = line.trim().removePrefix("{").removeSuffix("}").trim().lowercase()
if (inner == "end_of_notes" || inner == "eon") {
flushNoteParagraph()
inNotesBlock = false
continue
}
}
if (line.isBlank()) {
flushNoteParagraph()
} else {
if (currentNoteParagraph.isNotEmpty()) {
currentNoteParagraph.append(" ")
}
currentNoteParagraph.append(line.trim())
}
continue
}
// Skip comments // Skip comments
if (line.trimStart().startsWith("#")) continue if (line.trimStart().startsWith("#")) continue
@@ -97,6 +129,21 @@ object ChordProParser {
"end_of_repeat", "eor" -> { "end_of_repeat", "eor" -> {
flushSection() flushSection()
} }
"image" -> if (value != null) {
// Inline image within a song section
if (currentType == null) {
currentType = SectionType.VERSE
}
currentLines.add(SongLine(imagePath = value.trim()))
}
"start_of_notes", "son" -> {
inNotesBlock = true
}
"end_of_notes", "eon" -> {
// Should have been handled in the notes block above
flushNoteParagraph()
inNotesBlock = false
}
"chorus" -> { "chorus" -> {
flushSection() flushSection()
sections.add(SongSection(type = SectionType.CHORUS)) sections.add(SongSection(type = SectionType.CHORUS))

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

@@ -485,4 +485,147 @@ class ChordProParserTest {
line.segments[2].chord shouldBe "G" line.segments[2].chord shouldBe "G"
line.segments[2].text shouldBe "End" line.segments[2].text shouldBe "End"
} }
@Test
fun `parse notes block with multiple paragraphs`() {
val input = """
{title: Song}
{start_of_notes}
First paragraph of the notes.
It continues on the next line.
Second paragraph with different content.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 2
song.notes[0] shouldBe "First paragraph of the notes. It continues on the next line."
song.notes[1] shouldBe "Second paragraph with different content."
}
@Test
fun `parse notes block with single paragraph`() {
val input = """
{title: Song}
{start_of_notes}
A single note paragraph.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 1
song.notes[0] shouldBe "A single note paragraph."
}
@Test
fun `parse notes block with short directives son eon`() {
val input = """
{title: Song}
{son}
Short form notes.
{eon}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 1
song.notes[0] shouldBe "Short form notes."
}
@Test
fun `notes block and single note directives combine`() {
val input = """
{title: Song}
{note: Single line note}
{start_of_notes}
Block note paragraph.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 2
song.notes[0] shouldBe "Single line note"
song.notes[1] shouldBe "Block note paragraph."
}
@Test
fun `parse notes block with three paragraphs`() {
val input = """
{title: Song}
{start_of_notes}
Paragraph one.
Paragraph two.
Paragraph three.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 3
song.notes[0] shouldBe "Paragraph one."
song.notes[1] shouldBe "Paragraph two."
song.notes[2] shouldBe "Paragraph three."
}
@Test
fun `parse image directive within song section`() {
val input = """
{title: Song}
{start_of_verse}
[Am]Hello world
{image: images/drawing.png}
[C]Goodbye world
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].lines shouldHaveSize 3
song.sections[0].lines[0].segments[0].chord shouldBe "Am"
song.sections[0].lines[1].imagePath shouldBe "images/drawing.png"
song.sections[0].lines[1].segments.shouldBeEmpty()
song.sections[0].lines[2].segments[0].chord shouldBe "C"
}
@Test
fun `parse image directive outside section creates implicit verse`() {
val input = """
{title: Song}
{image: images/landscape.jpg}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].lines shouldHaveSize 1
song.sections[0].lines[0].imagePath shouldBe "images/landscape.jpg"
}
@Test
fun `parse multiple image directives`() {
val input = """
{title: Song}
{start_of_verse}
{image: img1.png}
Some text
{image: img2.png}
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections[0].lines shouldHaveSize 3
song.sections[0].lines[0].imagePath shouldBe "img1.png"
song.sections[0].lines[0].segments.shouldBeEmpty()
song.sections[0].lines[1].imagePath.shouldBeNull()
song.sections[0].lines[1].segments[0].text shouldBe "Some text"
song.sections[0].lines[2].imagePath shouldBe "img2.png"
}
} }

View File

@@ -214,6 +214,31 @@ class ConfigParserTest {
config.toc.highlightColumn.shouldBeNull() config.toc.highlightColumn.shouldBeNull()
} }
@Test
fun `parse config with german metadata labels`() {
val yaml = """
book:
title: "Test"
layout:
metadata_labels: german
metadata_position: bottom
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.layout.metadataLabels shouldBe "german"
config.layout.metadataPosition shouldBe "bottom"
}
@Test
fun `parse config with default metadata settings`() {
val yaml = """
book:
title: "Test"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.layout.metadataLabels shouldBe "abbreviated"
config.layout.metadataPosition shouldBe "top"
}
@Test @Test
fun `parse config ignores unknown properties`() { fun `parse config ignores unknown properties`() {
val yaml = """ val yaml = """
@@ -226,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

@@ -104,6 +104,8 @@ class PdfBookRenderer : BookRenderer {
) { ) {
var y = contentTop var y = contentTop
val renderMetaAtBottom = config.layout.metadataPosition == "bottom"
if (pageIndex == 0) { if (pageIndex == 0) {
// Render title // Render title
val titleFont = fontMetrics.getBaseFont(config.fonts.title) val titleFont = fontMetrics.getBaseFont(config.fonts.title)
@@ -116,21 +118,23 @@ class PdfBookRenderer : BookRenderer {
cb.endText() cb.endText()
y -= titleSize * 1.5f y -= titleSize * 1.5f
// Render metadata line (composer/lyricist) // Render metadata line (composer/lyricist) - at top position only
val metaParts = mutableListOf<String>() if (!renderMetaAtBottom) {
song.composer?.let { metaParts.add("M: $it") } val metaParts = buildMetadataLines(song, config)
song.lyricist?.let { metaParts.add("T: $it") }
if (metaParts.isNotEmpty()) { if (metaParts.isNotEmpty()) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata) val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size val metaSize = config.fonts.metadata.size
for (metaLine in metaParts) {
cb.beginText() cb.beginText()
cb.setFontAndSize(metaFont, metaSize) cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY) cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, y - metaSize) cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(metaParts.joinToString(" / ")) cb.showText(metaLine)
cb.endText() cb.endText()
y -= metaSize * 1.8f y -= metaSize * 1.8f
} }
}
}
// Render key and capo // Render key and capo
val infoParts = mutableListOf<String>() val infoParts = mutableListOf<String>()
@@ -205,9 +209,15 @@ class PdfBookRenderer : BookRenderer {
// Render lines // Render lines
for (line in section.lines) { for (line in section.lines) {
val imgPath = line.imagePath
if (imgPath != null) {
// Render inline image
y -= renderInlineImage(cb, imgPath, leftMargin, y, contentWidth)
} else {
val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth) val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth)
y -= height + 1f // 1pt gap between lines y -= height + 1f // 1pt gap between lines
} }
}
// End repeat marker // End repeat marker
if (section.type == SectionType.REPEAT) { if (section.type == SectionType.REPEAT) {
@@ -226,21 +236,52 @@ class PdfBookRenderer : BookRenderer {
y -= config.layout.verseSpacing / 0.3528f y -= config.layout.verseSpacing / 0.3528f
} }
// Render notes at the bottom // Render notes at the bottom (with word-wrap for multi-paragraph notes)
if (pageIndex == 0 && song.notes.isNotEmpty()) { if (pageIndex == 0 && song.notes.isNotEmpty()) {
y -= 4f y -= 4f
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata) val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size val metaSize = config.fonts.metadata.size
for (note in song.notes) { val noteLineHeight = metaSize * 1.5f
for ((idx, note) in song.notes.withIndex()) {
val wrappedLines = wrapText(note, metaFont, metaSize, contentWidth)
for (wrappedLine in wrappedLines) {
cb.beginText() cb.beginText()
cb.setFontAndSize(metaFont, metaSize) cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY) cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, y - metaSize) cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(note) cb.showText(wrappedLine)
cb.endText()
y -= noteLineHeight
}
// Add paragraph spacing between note paragraphs
if (idx < song.notes.size - 1) {
y -= noteLineHeight * 0.3f
}
}
}
// Render metadata at bottom of song page (if configured)
if (renderMetaAtBottom && pageIndex == 0) {
val metaParts = buildMetadataLines(song, config)
if (metaParts.isNotEmpty()) {
y -= 4f
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
for (metaLine in metaParts) {
val wrappedLines = wrapText(metaLine, metaFont, metaSize, contentWidth)
for (wrappedLine in wrappedLines) {
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(wrappedLine)
cb.endText() cb.endText()
y -= metaSize * 1.5f y -= metaSize * 1.5f
} }
} }
}
}
// Render reference book footer on the last page of the song // Render reference book footer on the last page of the song
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) { if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
@@ -255,6 +296,35 @@ class PdfBookRenderer : BookRenderer {
} }
} }
/**
* Build metadata lines based on configured label style.
* Returns a list of lines to render (may be empty).
*/
private fun buildMetadataLines(song: Song, config: BookConfig): List<String> {
val useGerman = config.layout.metadataLabels == "german"
val lines = mutableListOf<String>()
if (useGerman) {
// German labels: "Worte und Weise:" when same person, otherwise separate
if (song.lyricist != null && song.composer != null && song.lyricist == song.composer) {
lines.add("Worte und Weise: ${song.lyricist}")
} else {
song.lyricist?.let { lines.add("Worte: $it") }
song.composer?.let { lines.add("Weise: $it") }
}
} else {
// Abbreviated labels on a single line
val parts = mutableListOf<String>()
song.composer?.let { parts.add("M: $it") }
song.lyricist?.let { parts.add("T: $it") }
if (parts.isNotEmpty()) {
lines.add(parts.joinToString(" / "))
}
}
return lines
}
/** /**
* Renders reference book abbreviations and page numbers as a footer row * Renders reference book abbreviations and page numbers as a footer row
* at the bottom of the song page, above the page number. * at the bottom of the song page, above the page number.
@@ -437,6 +507,37 @@ class PdfBookRenderer : BookRenderer {
return lines return lines
} }
/**
* Renders an inline image within a song page at the given position.
* Returns the total height consumed in PDF points.
*/
private fun renderInlineImage(
cb: PdfContentByte,
imagePath: String,
leftMargin: Float,
y: Float,
contentWidth: Float
): Float {
try {
val img = Image.getInstance(imagePath)
// Scale to fit within content width, max height 40mm (~113 points)
val maxHeight = 40f / 0.3528f // 40mm in points
img.scaleToFit(contentWidth * 0.8f, maxHeight)
// Center horizontally
val imgX = leftMargin + (contentWidth - img.scaledWidth) / 2
val imgY = y - img.scaledHeight - 3f // 3pt gap above
img.setAbsolutePosition(imgX, imgY)
cb.addImage(img)
return img.scaledHeight + 6f // image height + gaps above/below
} catch (_: Exception) {
// If image can't be loaded, consume minimal space
return 5f
}
}
private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) { private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) {
try { try {
val img = Image.getInstance(imagePath) val img = Image.getInstance(imagePath)

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 }