Compare commits
2 Commits
b339c10ca0
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5378bdbc24 | ||
|
|
ab91ad2db6 |
46
CLAUDE.md
46
CLAUDE.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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