1 Commits

Author SHA1 Message Date
shahondin1624
79688be51e feat: highlight the current book's column in the TOC (Closes #6)
Add TocConfig with highlightColumn field to BookConfig. TocRenderer now
applies a light gray background shading to the designated column header
and data cells, making it easy to visually distinguish the current book's
page numbers from reference book columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:35:27 +01:00
27 changed files with 172 additions and 2315 deletions

View File

@@ -1,109 +0,0 @@
# Issue #17: Fix page overflow — bounds checking and content splitting
## Summary
The PDF renderer (`PdfBookRenderer.renderSongPage()`) currently renders all song sections on page 0 and leaves page 1 blank for 2-page songs. There is no bounds checking — the `y` coordinate can go below the bottom margin, causing content to render outside the visible page area. This plan adds proper `y`-position tracking against a minimum `yMin` boundary, splits content across pages at section boundaries when a song exceeds one page, and reserves space for bottom metadata/references on the last page.
## AC Verification Checklist
1. The renderer tracks `y` against the bottom margin during song page rendering
2. For 2-page songs, content splits across pages when it exceeds page 0's available space — remaining sections continue on page 1
3. Content that would be rendered below the bottom margin (minus reserved footer space) is moved to the next page
4. If metadata is "bottom" position, sufficient space is reserved at the bottom of the last page
5. No text or images are rendered outside the printable page area
6. Existing tests continue to pass
7. New tests verify content splitting for songs exceeding one page
## Implementation Steps
### Step 1: Add a section height calculation helper in PdfBookRenderer
Add a private method `calculateSectionHeight()` that computes how many PDF points a given `SongSection` will consume when rendered. This mirrors the measurement engine logic but uses the actual PDF `BaseFont` widths (not stubs). This is needed to decide whether a section fits on the current page.
The method signature:
```kotlin
private fun calculateSectionHeight(
section: SongSection,
fontMetrics: PdfFontMetrics,
config: BookConfig,
contentWidth: Float
): Float
```
### Step 2: Add footer space reservation calculation
Add a private method `calculateFooterReservation()` that computes how much vertical space must be reserved at the bottom of the **last** page of a song for:
- Bottom-position metadata (if `metadataPosition == "bottom"`)
- Notes
- Reference book footer
```kotlin
private fun calculateFooterReservation(
song: Song,
fontMetrics: PdfFontMetrics,
config: BookConfig,
contentWidth: Float
): Float
```
### Step 3: Refactor renderSongPage() to split content across pages
The key change: Instead of `val sections = if (pageIndex == 0) song.sections else emptyList()`, determine which sections belong on each page by:
1. Calculate `yMin` = bottom margin in points (plus footer reservation for the last page)
2. For `pageIndex == 0`: Render sections in order. Before rendering each section, check if the section's height fits above `yMin`. If not, stop — remaining sections go to page 1.
3. For `pageIndex == 1`: Render the sections that didn't fit on page 0. The split point is stored via a `splitIndex` that is computed during page 0 rendering.
**Approach:** Since `renderSongPage()` is called separately for page 0 and page 1, we need a way to know the split point on both calls. The cleanest approach is to compute the split index as a function:
```kotlin
private fun computeSplitIndex(
song: Song,
fontMetrics: PdfFontMetrics,
config: BookConfig,
contentWidth: Float,
availableHeight: Float // total space on page 0 (contentTop - yMin)
): Int // index of first section that goes to page 1
```
This method calculates the cumulative height of header + sections. When the cumulative height exceeds `availableHeight`, it returns the section index. If all sections fit, it returns `song.sections.size`.
### Step 4: Update renderSongPage() to use bounds checking during rendering
Even after determining the split, the actual rendering loop should still check `y >= yMin` as a safety net. If a section that was estimated to fit actually overflows (due to measurement inaccuracy), clamp rendering — do not render below `yMin`.
### Step 5: Update footer rendering for multi-page songs
Currently `isLastPage` is hardcoded to `pageIndex == 0`. Change it to correctly identify the last page:
- For 1-page songs: `pageIndex == 0` is the last page
- For 2-page songs: `pageIndex == 1` is the last page
The song's `pageCount` isn't directly available in the renderer, but we can determine it: if `pageIndex == 1`, it's always the last page. If `pageIndex == 0`, it's the last page only if the song fits on one page (i.e., `computeSplitIndex == song.sections.size`).
A simpler approach: pass the total page count as a parameter, or compute whether the song needs 2 pages inside `renderSongPage()`.
**Decision:** Add a `totalPages: Int` parameter to `renderSongPage()`. The caller already knows this from the `PageContent.SongPage` list (consecutive song pages with pageIndex 0 and 1 for the same song).
Actually, the simplest approach: The renderer sees `PageContent.SongPage(song, 0)` and `PageContent.SongPage(song, 1)` in the page list. We can pre-scan the pages list to know if a song has 2 pages. But even simpler: we can compute `computeSplitIndex` to know whether the song needs a second page. If `splitIndex < song.sections.size`, the song has 2 pages.
### Step 6: Move notes and bottom-metadata to the last page
Currently notes and bottom metadata only render on `pageIndex == 0`. Change this to render on the last page (which might be page 1 for 2-page songs). The logic:
- Compute `isLastPage` based on split index
- Render notes, bottom metadata, and reference footer only on the last page
### Step 7: Write tests
Add tests in `PdfBookRendererTest`:
1. `render handles two-page song with content split across pages` — Create a song with many sections that exceed one page, render with pageIndex 0 and 1, verify PDF is valid.
2. `render does not overflow below bottom margin` — Create a very long song, verify rendering completes without error.
3. `render places metadata at bottom of last page for two-page songs` — Use `metadataPosition = "bottom"`, create a 2-page song, verify PDF is valid.
4. `render handles notes on last page of two-page song` — Song with notes that spans 2 pages, verify rendering.
### Step 8: Verify existing tests pass
Run `gradle :renderer-pdf:test` and `gradle :app:test` to ensure no regressions.

View File

@@ -1,44 +0,0 @@
# Issue #18: Add page-by-page preview in the GUI after building
## Summary
Add a PDF preview panel to the GUI that appears after a successful build. It renders PDF pages as images using Apache PDFBox and displays them with previous/next navigation and a page counter. The preview loads pages lazily for performance and updates automatically on new builds.
## AC Verification Checklist
1. After a successful build, a preview panel appears showing generated pages
2. Users can navigate between pages (previous/next buttons)
3. Current page number and total count displayed (e.g., "Seite 3 / 42")
4. Preview renders actual PDF pages as images (PDF-to-image via PDFBox)
5. Preview panel can be closed/hidden to return to normal view
6. Preview updates automatically when a new build completes
7. GUI remains responsive while preview is loading (async rendering)
## Implementation Steps
### Step 1: Add PDFBox dependency to gui module
Add `org.apache.pdfbox:pdfbox:3.0.4` to gui/build.gradle.kts.
### Step 2: Create PdfPreviewState class
A state holder for the preview: current page index, total pages, rendered page images (cached), loading state. Pages are rendered lazily — only the current page is rendered at a time.
### Step 3: Create PdfPreviewPanel composable
A Compose panel with:
- An Image composable showing the current page
- Navigation row: "< Prev" button | "Seite X / Y" label | "Next >" button
- A close/hide button
- Loading indicator while page is rendering
### Step 4: Integrate preview into App composable
After a successful build:
- Show a "Vorschau" button in the action buttons row
- When clicked, show the preview panel (replacing or overlaying the song list area)
- When a new build succeeds, update the preview automatically
### Step 5: Lazy page rendering
Render pages on demand using coroutines on Dispatchers.IO to keep the UI responsive.

View File

@@ -1,41 +0,0 @@
# Issue #19: Add drag-and-drop song reordering in the GUI
## Summary
Add drag-and-drop reordering of songs in the GUI song list. When `songs.order` is "manual", users can drag songs to rearrange them. The custom order is passed to the pipeline at build time. When order is "alphabetical", drag-and-drop is disabled with a hint.
## AC Verification Checklist
1. Songs can be reordered via drag-and-drop
2. Reordered list is used when building (overrides config order)
3. Visual indicator shows drop target (highlight)
4. Order can be reset via a button
5. Reordering only enabled when songs.order is "manual"
6. When alphabetical, list shows alphabetical order and drag is disabled (with hint)
7. GUI remains responsive during drag operations
## Implementation Steps
### Step 1: Add customSongOrder parameter to SongbookPipeline.build()
Add an optional `customSongOrder: List<String>? = null` parameter. When provided, use this ordered list of file names to sort the parsed songs instead of the config-based sort.
### Step 2: Create ReorderableSongList composable
Build a song list that supports drag-and-drop reordering:
- Use `detectDragGesturesAfterLongPress` on each item to detect drag start
- Track the dragged item index and current hover position
- Show a visual indicator (highlighted background) at the drop target
- On drop, reorder the list
### Step 3: Integrate into App.kt
- Track `songsOrder` config value ("alphabetical" or "manual")
- Track `originalSongs` list (from file loading) to support reset
- When manual: enable drag-and-drop, show reset button
- When alphabetical: disable drag-and-drop, show hint
- Pass custom order (file names) to pipeline on build
### Step 4: Add reset button
"Reihenfolge zurücksetzen" button restores `songs` to `originalSongs`.

View File

@@ -35,17 +35,16 @@ Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.
## Architecture ## Architecture
**Pipeline:** Parse → Validate → Measure → Paginate → Render **Pipeline:** Parse → 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`/`.cho`/`.crd` files → `Song` objects 2. `ChordProParser` reads `.chopro` files → `Song` objects
3. `ForewordParser` reads optional `foreword.txt``Foreword` (if configured) 3. `Validator` checks config and songs
4. `Validator` checks config and songs 4. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
5. `MeasurementEngine` calculates each song's height in mm using `FontMetrics` 5. `TocGenerator` estimates TOC page count and creates entries
6. `TocGenerator` estimates TOC page count and creates entries 6. `PaginationEngine` arranges songs into pages (greedy spread packing)
7. `PaginationEngine` arranges songs into pages (greedy spread packing) 7. `PdfBookRenderer` generates the PDF via OpenPDF
8. `PdfBookRenderer` generates the PDF via OpenPDF
**Module dependency graph:** **Module dependency graph:**
``` ```
@@ -63,39 +62,14 @@ app, parser ← gui (Compose Desktop)
## Key Types ## Key Types
- `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` - `Song` → sections → `SongLine``LineSegment(chord?, text)` — chord is placed above the text segment
- `SongLine` — holds `segments` plus optional `imagePath` (when set, the line is an inline image) - `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`
- `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`/`.cho`/`.crd` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples. ChordPro-compatible `.chopro` 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

@@ -4,9 +4,7 @@ import de.pfadfinder.songbook.model.*
import de.pfadfinder.songbook.parser.* import de.pfadfinder.songbook.parser.*
import de.pfadfinder.songbook.parser.ForewordParser import de.pfadfinder.songbook.parser.ForewordParser
import de.pfadfinder.songbook.layout.* import de.pfadfinder.songbook.layout.*
import de.pfadfinder.songbook.renderer.pdf.PdfBookRenderer import de.pfadfinder.songbook.renderer.pdf.*
import de.pfadfinder.songbook.renderer.pdf.PdfFontMetrics
import de.pfadfinder.songbook.renderer.pdf.TocRenderer
import mu.KotlinLogging import mu.KotlinLogging
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -23,29 +21,16 @@ data class BuildResult(
class SongbookPipeline(private val projectDir: File) { class SongbookPipeline(private val projectDir: File) {
/** fun build(): BuildResult {
* Build the songbook PDF.
*
* @param customSongOrder Optional list of song file names in the desired order.
* When provided, songs are sorted to match this order instead of using the
* config-based sort (alphabetical or manual). Files not in this list are
* appended at the end.
* @param onProgress Optional callback invoked with status messages during the build.
*/
fun build(customSongOrder: List<String>? = null, onProgress: ((String) -> Unit)? = null): BuildResult {
// 1. Parse config // 1. Parse config
onProgress?.invoke("Konfiguration wird geladen...")
val configFile = File(projectDir, "songbook.yaml") val configFile = File(projectDir, "songbook.yaml")
if (!configFile.exists()) { if (!configFile.exists()) {
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 rawConfig = ConfigParser.parse(configFile) val config = ConfigParser.parse(configFile)
// Resolve font file paths relative to the project directory // Validate config
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)
@@ -65,23 +50,19 @@ class SongbookPipeline(private val projectDir: File) {
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found"))) return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found")))
} }
onProgress?.invoke("Lieder werden importiert (${songFiles.size} Dateien)...")
logger.info { "Found ${songFiles.size} song files" } logger.info { "Found ${songFiles.size} song files" }
val songsByFileName = mutableMapOf<String, Song>() val songs = mutableListOf<Song>()
val allErrors = mutableListOf<ValidationError>() val allErrors = mutableListOf<ValidationError>()
for ((index, file) in songFiles.withIndex()) { for (file in songFiles) {
if (index > 0 && index % 50 == 0) {
onProgress?.invoke("Lieder werden importiert... ($index/${songFiles.size})")
}
try { try {
val song = ChordProParser.parseFile(file) val song = ChordProParser.parseFile(file)
val songErrors = Validator.validateSong(song, file.name) val songErrors = Validator.validateSong(song, file.name)
if (songErrors.isNotEmpty()) { if (songErrors.isNotEmpty()) {
allErrors.addAll(songErrors) allErrors.addAll(songErrors)
} else { } else {
songsByFileName[file.name] = song songs.add(song)
} }
} catch (e: Exception) { } catch (e: Exception) {
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}")) allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
@@ -92,21 +73,11 @@ class SongbookPipeline(private val projectDir: File) {
return BuildResult(false, errors = allErrors) return BuildResult(false, errors = allErrors)
} }
val songs = songsByFileName.values.toList() // Sort songs
val sortedSongs = when (config.songs.order) {
// Sort songs: custom order takes priority, then config-based sort
val sortedSongs = if (customSongOrder != null) {
val orderMap = customSongOrder.withIndex().associate { (index, name) -> name to index }
songs.sortedBy { song ->
val fileName = songsByFileName.entries.find { it.value === song }?.key
orderMap[fileName] ?: Int.MAX_VALUE
}
} else {
when (config.songs.order) {
"alphabetical" -> songs.sortedBy { it.title.lowercase() } "alphabetical" -> songs.sortedBy { it.title.lowercase() }
else -> songs // manual order = file order else -> songs // manual order = file order
} }
}
logger.info { "Parsed ${sortedSongs.size} songs" } logger.info { "Parsed ${sortedSongs.size} songs" }
@@ -124,39 +95,21 @@ class SongbookPipeline(private val projectDir: File) {
} }
// 3. Measure songs // 3. Measure songs
onProgress?.invoke("Layout wird berechnet...")
val fontMetrics = PdfFontMetrics() val fontMetrics = PdfFontMetrics()
val measurementEngine = MeasurementEngine(fontMetrics, config) val measurementEngine = MeasurementEngine(fontMetrics, config)
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) } val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
// 4. Generate TOC and paginate // 4. Generate TOC and paginate
val tocGenerator = TocGenerator(config) val tocGenerator = TocGenerator(config)
val estimatedTocPages = tocGenerator.estimateTocPages(sortedSongs) val tocPages = tocGenerator.estimateTocPages(sortedSongs)
// Intro page takes 2 pages (title + blank back) for double-sided printing
val introPages = if (config.intro?.enabled == true) 2 else 0
// Foreword always takes 2 pages (for double-sided printing) // Foreword always takes 2 pages (for double-sided printing)
val forewordPages = if (foreword != null) 2 else 0 val forewordPages = if (foreword != null) 2 else 0
val headerPages = introPages + estimatedTocPages + forewordPages
val paginationEngine = PaginationEngine(config) val paginationEngine = PaginationEngine(config)
val pages = paginationEngine.paginate(measuredSongs, headerPages) val pages = paginationEngine.paginate(measuredSongs, tocPages + forewordPages)
// Generate initial TOC entries, then measure actual pages needed val tocEntries = tocGenerator.generate(pages, tocPages + forewordPages)
val initialTocEntries = tocGenerator.generate(pages, headerPages)
val tocRenderer = TocRenderer(fontMetrics, config)
val tocPages = tocRenderer.measurePages(initialTocEntries)
// Re-generate TOC entries with corrected page offset if count changed.
// Since tocPages is always even, the pagination layout (left/right parity)
// stays the same — only page numbers in the TOC entries need updating.
val actualHeaderPages = introPages + tocPages + forewordPages
val tocEntries = if (tocPages != estimatedTocPages) {
logger.info { "TOC pages: estimated $estimatedTocPages, actual $tocPages" }
tocGenerator.generate(pages, actualHeaderPages)
} else {
initialTocEntries
}
// Build final page list with foreword pages inserted before song content // Build final page list with foreword pages inserted before song content
val allPages = mutableListOf<PageContent>() val allPages = mutableListOf<PageContent>()
@@ -167,17 +120,14 @@ class SongbookPipeline(private val projectDir: File) {
allPages.addAll(pages) allPages.addAll(pages)
val layoutResult = LayoutResult( val layoutResult = LayoutResult(
introPages = introPages,
tocPages = tocPages, tocPages = tocPages,
pages = allPages, pages = allPages,
tocEntries = tocEntries tocEntries = tocEntries
) )
val totalPages = introPages + tocPages + pages.size logger.info { "Layout: ${tocPages} TOC pages, ${pages.size} content pages" }
logger.info { "Layout: ${introPages} intro, ${tocPages} TOC, ${pages.size} content pages" }
// 5. Render PDF // 5. Render PDF
onProgress?.invoke("PDF wird erzeugt (${sortedSongs.size} Lieder, $totalPages Seiten)...")
val outputDir = File(projectDir, config.output.directory) val outputDir = File(projectDir, config.output.directory)
outputDir.mkdirs() outputDir.mkdirs()
val outputFile = File(outputDir, config.output.filename) val outputFile = File(outputDir, config.output.filename)
@@ -189,49 +139,23 @@ class SongbookPipeline(private val projectDir: File) {
renderer.render(layoutResult, config, fos) renderer.render(layoutResult, config, fos)
} }
logger.info { "Build complete: ${sortedSongs.size} songs, $totalPages pages" } logger.info { "Build complete: ${sortedSongs.size} songs, ${pages.size + tocPages} pages" }
return BuildResult( return BuildResult(
success = true, success = true,
outputFile = outputFile, outputFile = outputFile,
songCount = sortedSongs.size, songCount = sortedSongs.size,
pageCount = totalPages pageCount = pages.size + tocPages
) )
} }
/**
* 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 rawConfig = ConfigParser.parse(configFile) val config = 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

@@ -18,7 +18,7 @@ class BuildCommand : CliktCommand(name = "build") {
echo("Building songbook from: ${dir.path}") echo("Building songbook from: ${dir.path}")
val pipeline = SongbookPipeline(dir) val pipeline = SongbookPipeline(dir)
val result = pipeline.build(onProgress = { msg -> echo(msg) }) val result = pipeline.build()
if (result.success) { if (result.success) {
echo("Build successful!") echo("Build successful!")

View File

@@ -11,7 +11,6 @@ dependencies {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("ch.qos.logback:logback-classic:1.5.16") implementation("ch.qos.logback:logback-classic:1.5.16")
implementation("org.apache.pdfbox:pdfbox:3.0.4")
} }
compose.desktop { compose.desktop {

View File

@@ -1,7 +1,6 @@
package de.pfadfinder.songbook.gui package de.pfadfinder.songbook.gui
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -21,7 +20,6 @@ import androidx.compose.ui.window.application
import de.pfadfinder.songbook.app.BuildResult import de.pfadfinder.songbook.app.BuildResult
import de.pfadfinder.songbook.app.SongbookPipeline import de.pfadfinder.songbook.app.SongbookPipeline
import de.pfadfinder.songbook.parser.ChordProParser import de.pfadfinder.songbook.parser.ChordProParser
import de.pfadfinder.songbook.parser.ConfigParser
import de.pfadfinder.songbook.parser.ValidationError import de.pfadfinder.songbook.parser.ValidationError
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -46,54 +44,36 @@ data class SongEntry(val fileName: String, val title: String)
fun App() { fun App() {
var projectPath by remember { mutableStateOf("") } var projectPath by remember { mutableStateOf("") }
var songs by remember { mutableStateOf<List<SongEntry>>(emptyList()) } var songs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
var originalSongs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
var songsOrderConfig by remember { mutableStateOf("alphabetical") }
var isCustomOrder by remember { mutableStateOf(false) }
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) } var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
var isRunning by remember { mutableStateOf(false) } var isRunning by remember { mutableStateOf(false) }
var isLoadingSongs by remember { mutableStateOf(false) }
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) } var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
val previewState = remember { PdfPreviewState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val reorderEnabled = songsOrderConfig != "alphabetical"
fun loadSongs(path: String) { fun loadSongs(path: String) {
val projectDir = File(path) val projectDir = File(path)
songs = emptyList() songs = emptyList()
originalSongs = emptyList()
isCustomOrder = false
if (!projectDir.isDirectory) return if (!projectDir.isDirectory) return
isLoadingSongs = true
statusMessages = listOf(StatusMessage("Lieder werden geladen...", MessageType.INFO))
scope.launch {
val (loadedSongs, order) = withContext(Dispatchers.IO) {
val configFile = File(projectDir, "songbook.yaml") val configFile = File(projectDir, "songbook.yaml")
var songsDir: File val songsDir = if (configFile.exists()) {
var orderConfig = "alphabetical"
if (configFile.exists()) {
try { try {
val config = ConfigParser.parse(configFile) val config = de.pfadfinder.songbook.parser.ConfigParser.parse(configFile)
songsDir = File(projectDir, config.songs.directory) File(projectDir, config.songs.directory)
orderConfig = config.songs.order
} catch (_: Exception) { } catch (_: Exception) {
songsDir = File(projectDir, "songs") File(projectDir, "songs")
} }
} else { } else {
songsDir = File(projectDir, "songs") File(projectDir, "songs")
} }
if (!songsDir.isDirectory) return@withContext Pair(emptyList<SongEntry>(), orderConfig) if (!songsDir.isDirectory) return
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") } val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
?.sortedBy { it.name } ?.sortedBy { it.name }
?: emptyList() ?: emptyList()
val loaded = songFiles.mapNotNull { file -> songs = songFiles.mapNotNull { file ->
try { try {
val song = ChordProParser.parseFile(file) val song = ChordProParser.parseFile(file)
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension }) SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
@@ -101,28 +81,10 @@ fun App() {
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)") SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
} }
} }
Pair(loaded, orderConfig)
}
songsOrderConfig = order
songs = if (order == "alphabetical") {
loadedSongs.sortedBy { it.title.lowercase() }
} else {
loadedSongs
}
originalSongs = songs.toList()
isLoadingSongs = false
statusMessages = if (loadedSongs.isNotEmpty()) {
listOf(StatusMessage("${loadedSongs.size} Lieder geladen.", MessageType.SUCCESS))
} else {
listOf(StatusMessage("Keine Lieder gefunden.", MessageType.INFO))
}
}
} }
MaterialTheme { MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
SelectionContainer {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
// Project directory selection // Project directory selection
Text( Text(
@@ -168,76 +130,48 @@ fun App() {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Central content area: song list or preview panel // Song list
if (previewState.isVisible) {
// Show preview panel
PdfPreviewPanel(
state = previewState,
modifier = Modifier.weight(1f)
)
} else {
// Song list header with optional reset button
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text( Text(
text = "Lieder (${songs.size}):", text = "Lieder (${songs.size}):",
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium
modifier = Modifier.weight(1f)
) )
if (reorderEnabled && isCustomOrder) {
Button(
onClick = {
songs = originalSongs.toList()
isCustomOrder = false
},
enabled = !isRunning
) {
Text("Reihenfolge zuruecksetzen")
}
}
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
if (isLoadingSongs) {
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
Spacer(modifier = Modifier.height(8.dp))
Text("Lieder werden geladen...", color = Color.Gray)
}
}
} else if (songs.isEmpty() && projectPath.isNotBlank()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) { Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
) {
if (songs.isEmpty() && projectPath.isNotBlank()) {
item {
Text( Text(
"Keine Lieder gefunden. Bitte Projektverzeichnis pruefen.", "Keine Lieder gefunden. Bitte Projektverzeichnis prüfen.",
color = Color.Gray, color = Color.Gray,
modifier = Modifier.padding(8.dp) modifier = Modifier.padding(8.dp)
) )
} }
} else if (projectPath.isBlank()) { } else if (projectPath.isBlank()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) { item {
Text( Text(
"Bitte ein Projektverzeichnis auswaehlen.", "Bitte ein Projektverzeichnis auswählen.",
color = Color.Gray, color = Color.Gray,
modifier = Modifier.padding(8.dp) modifier = Modifier.padding(8.dp)
) )
} }
} else {
ReorderableSongList(
songs = songs,
reorderEnabled = reorderEnabled,
onReorder = { newList ->
songs = newList
isCustomOrder = true
},
modifier = Modifier.weight(1f)
)
} }
items(songs) { song ->
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp, horizontal = 8.dp)) {
Text(song.title, modifier = Modifier.weight(1f))
Text(song.fileName, color = Color.Gray, fontSize = 12.sp)
}
Divider()
}
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
adapter = rememberScrollbarAdapter(listState)
)
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -251,17 +185,9 @@ fun App() {
lastBuildResult = null lastBuildResult = null
statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO)) statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO))
scope.launch { scope.launch {
// Build custom song order from the current GUI list
val customOrder = if (isCustomOrder) {
songs.map { it.fileName }
} else {
null
}
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
try { try {
SongbookPipeline(File(projectPath)).build(customOrder) { msg -> SongbookPipeline(File(projectPath)).build()
statusMessages = listOf(StatusMessage(msg, MessageType.INFO))
}
} catch (e: Exception) { } catch (e: Exception) {
BuildResult( BuildResult(
success = false, success = false,
@@ -294,11 +220,6 @@ fun App() {
} }
} }
isRunning = false isRunning = false
// Automatically load preview after successful build
if (result.success && result.outputFile != null) {
previewState.loadPdf(result.outputFile!!)
}
} }
}, },
enabled = !isRunning && projectPath.isNotBlank() enabled = !isRunning && projectPath.isNotBlank()
@@ -311,7 +232,7 @@ fun App() {
if (projectPath.isBlank()) return@Button if (projectPath.isBlank()) return@Button
isRunning = true isRunning = true
lastBuildResult = null lastBuildResult = null
statusMessages = listOf(StatusMessage("Validierung laeuft...", MessageType.INFO)) statusMessages = listOf(StatusMessage("Validierung läuft...", MessageType.INFO))
scope.launch { scope.launch {
val errors = withContext(Dispatchers.IO) { val errors = withContext(Dispatchers.IO) {
try { try {
@@ -350,7 +271,7 @@ fun App() {
Desktop.getDesktop().open(file) Desktop.getDesktop().open(file)
} catch (e: Exception) { } catch (e: Exception) {
statusMessages = statusMessages + StatusMessage( statusMessages = statusMessages + StatusMessage(
"PDF konnte nicht geoeffnet werden: ${e.message}", "PDF konnte nicht geöffnet werden: ${e.message}",
MessageType.ERROR MessageType.ERROR
) )
} }
@@ -358,27 +279,7 @@ fun App() {
}, },
enabled = !isRunning enabled = !isRunning
) { ) {
Text("PDF oeffnen") Text("PDF öffnen")
}
// Show/hide preview button
Button(
onClick = {
if (previewState.isVisible) {
previewState.isVisible = false
} else {
scope.launch {
if (previewState.totalPages == 0) {
lastBuildResult?.outputFile?.let { previewState.loadPdf(it) }
} else {
previewState.isVisible = true
}
}
}
},
enabled = !isRunning
) {
Text(if (previewState.isVisible) "Vorschau ausblenden" else "Vorschau")
} }
} }
@@ -438,7 +339,6 @@ fun App() {
} }
} }
} }
}
enum class MessageType { enum class MessageType {
INFO, SUCCESS, ERROR INFO, SUCCESS, ERROR

View File

@@ -1,221 +0,0 @@
package de.pfadfinder.songbook.gui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.apache.pdfbox.Loader
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.rendering.PDFRenderer
import java.awt.image.BufferedImage
import java.io.File
/**
* State holder for the PDF preview. Manages the current page, total page count,
* and a cache of rendered page images.
*/
class PdfPreviewState {
var pdfFile: File? by mutableStateOf(null)
private set
var totalPages: Int by mutableStateOf(0)
private set
var currentPage: Int by mutableStateOf(0)
private set
var isLoading: Boolean by mutableStateOf(false)
private set
var currentImage: ImageBitmap? by mutableStateOf(null)
private set
var isVisible: Boolean by mutableStateOf(false)
private var document: PDDocument? = null
private var renderer: PDFRenderer? = null
private val pageCache = mutableMapOf<Int, ImageBitmap>()
/**
* Load a new PDF file for preview. Resets state and renders the first page.
*/
suspend fun loadPdf(file: File) {
close()
pdfFile = file
currentPage = 0
pageCache.clear()
currentImage = null
withContext(Dispatchers.IO) {
try {
val doc = Loader.loadPDF(file)
document = doc
renderer = PDFRenderer(doc)
totalPages = doc.numberOfPages
} catch (_: Exception) {
totalPages = 0
}
}
if (totalPages > 0) {
isVisible = true
renderCurrentPage()
}
}
suspend fun goToPage(page: Int) {
if (page < 0 || page >= totalPages) return
currentPage = page
renderCurrentPage()
}
suspend fun nextPage() {
goToPage(currentPage + 1)
}
suspend fun previousPage() {
goToPage(currentPage - 1)
}
private suspend fun renderCurrentPage() {
val cached = pageCache[currentPage]
if (cached != null) {
currentImage = cached
return
}
isLoading = true
try {
val image = withContext(Dispatchers.IO) {
try {
val pdfRenderer = renderer ?: return@withContext null
// Render at 150 DPI for a good balance of quality and speed
val bufferedImage: BufferedImage = pdfRenderer.renderImageWithDPI(currentPage, 150f)
bufferedImage.toComposeImageBitmap()
} catch (_: Exception) {
null
}
}
if (image != null) {
pageCache[currentPage] = image
currentImage = image
}
} finally {
isLoading = false
}
}
fun close() {
try {
document?.close()
} catch (_: Exception) {
// ignore
}
document = null
renderer = null
pageCache.clear()
currentImage = null
totalPages = 0
currentPage = 0
isVisible = false
}
}
@Composable
fun PdfPreviewPanel(
state: PdfPreviewState,
modifier: Modifier = Modifier
) {
val scope = rememberCoroutineScope()
Column(modifier = modifier.fillMaxWidth()) {
// Header with title and close button
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Vorschau",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
Button(
onClick = { state.isVisible = false },
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
) {
Text("Schliessen")
}
}
// Page image area
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFFE0E0E0)),
contentAlignment = Alignment.Center
) {
if (state.isLoading) {
CircularProgressIndicator()
} else if (state.currentImage != null) {
Image(
bitmap = state.currentImage!!,
contentDescription = "Seite ${state.currentPage + 1}",
modifier = Modifier.fillMaxSize().padding(4.dp),
contentScale = ContentScale.Fit
)
} else {
Text(
"Keine Vorschau verfuegbar",
color = Color.Gray
)
}
}
// Navigation row
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
scope.launch { state.previousPage() }
},
enabled = state.currentPage > 0 && !state.isLoading
) {
Text("<")
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = "Seite ${state.currentPage + 1} / ${state.totalPages}",
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(min = 120.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Button(
onClick = {
scope.launch { state.nextPage() }
},
enabled = state.currentPage < state.totalPages - 1 && !state.isLoading
) {
Text(">")
}
}
}
}

View File

@@ -1,142 +0,0 @@
package de.pfadfinder.songbook.gui
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* A song list that supports drag-and-drop reordering when enabled.
*
* @param songs The current song list
* @param reorderEnabled Whether drag-and-drop is enabled
* @param onReorder Callback when songs are reordered, provides the new list
*/
@Composable
fun ReorderableSongList(
songs: List<SongEntry>,
reorderEnabled: Boolean,
onReorder: (List<SongEntry>) -> Unit,
modifier: Modifier = Modifier
) {
// Track drag state
var draggedIndex by remember { mutableStateOf(-1) }
var hoverIndex by remember { mutableStateOf(-1) }
var dragOffset by remember { mutableStateOf(0f) }
// Approximate item height for calculating target index from drag offset
val itemHeightPx = 36f // approximate height of each row in pixels
Box(modifier = modifier.fillMaxWidth()) {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
) {
itemsIndexed(songs, key = { _, song -> song.fileName }) { index, song ->
val isDragTarget = hoverIndex == index && draggedIndex != -1 && draggedIndex != index
val isBeingDragged = draggedIndex == index
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (isDragTarget) {
Modifier.background(Color(0xFFBBDEFB)) // light blue drop indicator
} else if (isBeingDragged) {
Modifier.background(Color(0xFFE0E0E0)) // grey for dragged item
} else {
Modifier
}
)
.padding(vertical = 2.dp, horizontal = 8.dp)
.then(
if (reorderEnabled) {
Modifier.pointerInput(songs) {
detectDragGesturesAfterLongPress(
onDragStart = {
draggedIndex = index
hoverIndex = index
dragOffset = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragOffset += dragAmount.y
// Calculate target index based on cumulative drag offset
val indexDelta = (dragOffset / itemHeightPx).toInt()
val newHover = (draggedIndex + indexDelta).coerceIn(0, songs.size - 1)
hoverIndex = newHover
},
onDragEnd = {
if (draggedIndex != -1 && hoverIndex != -1 && draggedIndex != hoverIndex) {
val mutable = songs.toMutableList()
val item = mutable.removeAt(draggedIndex)
mutable.add(hoverIndex, item)
onReorder(mutable)
}
draggedIndex = -1
hoverIndex = -1
dragOffset = 0f
},
onDragCancel = {
draggedIndex = -1
hoverIndex = -1
dragOffset = 0f
}
)
}
} else {
Modifier
}
),
verticalAlignment = Alignment.CenterVertically
) {
if (reorderEnabled) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "Ziehen zum Verschieben",
modifier = Modifier.size(16.dp).padding(end = 4.dp),
tint = Color.Gray
)
}
Text(song.title, modifier = Modifier.weight(1f))
Text(song.fileName, color = Color.Gray, fontSize = 12.sp)
}
Divider()
}
if (!reorderEnabled && songs.isNotEmpty()) {
item {
Text(
"Reihenfolge ist alphabetisch. Wechsle in songbook.yaml zu songs.order: \"manual\" um die Reihenfolge zu aendern.",
color = Color.Gray,
fontStyle = FontStyle.Italic,
fontSize = 11.sp,
modifier = Modifier.padding(8.dp)
)
}
}
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
adapter = rememberScrollbarAdapter(listState)
)
}
}

View File

@@ -15,16 +15,9 @@ 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 lines (composer/lyricist) - may be 1 or 2 lines depending on label style // Metadata line (composer/lyricist)
if (song.composer != null || song.lyricist != null) { if (song.composer != null || song.lyricist != null) {
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f heightMm += 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
@@ -50,11 +43,6 @@ 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) {
@@ -65,38 +53,18 @@ 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 (with word-wrap estimation for multi-paragraph notes) // Notes at bottom
if (song.notes.isNotEmpty()) { if (song.notes.isNotEmpty()) {
heightMm += 1.5f // gap before notes heightMm += 1.5f // gap
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f for (note in song.notes) {
// A5 content width in mm = 148 - inner margin - outer margin heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
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
} }
} }
}
// Reference book footer: gap + separator line + abbreviation row + page number row
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size)
heightMm += 4f * 0.3528f // gap before footer (4pt converted to mm)
heightMm += metaLineHeight * 1.4f * 2 // two rows (headers + numbers)
}
val pageCount = if (heightMm <= contentHeightMm) 1 else 2 val pageCount = if (heightMm <= contentHeightMm) 1 else 2
return MeasuredSong(song, heightMm, pageCount) return MeasuredSong(song, heightMm, pageCount)

View File

@@ -258,106 +258,4 @@ class MeasurementEngineTest {
labeledHeight shouldBeGreaterThan unlabeledHeight labeledHeight shouldBeGreaterThan unlabeledHeight
} }
@Test
fun `references add footer height when reference books configured`() {
val configWithRefs = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mo", name = "Mundorgel", abbreviation = "MO"),
ReferenceBook(id = "pl", name = "Pfadfinderlied", abbreviation = "PL")
)
)
val engineWithRefs = MeasurementEngine(fontMetrics, configWithRefs)
val songWithRefs = Song(
title = "With Refs",
references = mapOf("mo" to 42, "pl" to 17),
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val songWithoutRefs = Song(
title = "No Refs",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val heightWith = engineWithRefs.measure(songWithRefs).totalHeightMm
val heightWithout = engineWithRefs.measure(songWithoutRefs).totalHeightMm
heightWith shouldBeGreaterThan heightWithout
}
@Test
fun `references do not add height when no reference books configured`() {
val songWithRefs = Song(
title = "With Refs",
references = mapOf("mo" to 42),
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val songWithoutRefs = Song(
title = "No Refs",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
// Default config has no reference books
val heightWith = engine.measure(songWithRefs).totalHeightMm
val heightWithout = engine.measure(songWithoutRefs).totalHeightMm
// Should be the same since no reference books are configured
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

@@ -9,12 +9,7 @@ data class BookConfig(
val referenceBooks: List<ReferenceBook> = emptyList(), val referenceBooks: List<ReferenceBook> = emptyList(),
val output: OutputConfig = OutputConfig(), val output: OutputConfig = OutputConfig(),
val foreword: ForewordConfig? = null, val foreword: ForewordConfig? = null,
val toc: TocConfig = TocConfig(), val toc: TocConfig = TocConfig()
val intro: IntroConfig? = null
)
data class IntroConfig(
val enabled: Boolean = true
) )
data class TocConfig( data class TocConfig(
@@ -54,11 +49,9 @@ data class FontSpec(
data class LayoutConfig( data class LayoutConfig(
val margins: Margins = Margins(), val margins: Margins = Margins(),
val chordLineSpacing: Float = 1f, // mm gap between chord line and lyrics text val chordLineSpacing: Float = 3f, // mm
val verseSpacing: Float = 6f, // mm gap between consecutive song sections 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

@@ -14,7 +14,6 @@ sealed class PageContent {
} }
data class LayoutResult( data class LayoutResult(
val introPages: Int = 0,
val tocPages: Int, val tocPages: Int,
val pages: List<PageContent>, val pages: List<PageContent>,
val tocEntries: List<TocEntry> val tocEntries: List<TocEntry>

View File

@@ -23,10 +23,7 @@ enum class SectionType {
VERSE, CHORUS, BRIDGE, REPEAT VERSE, CHORUS, BRIDGE, REPEAT
} }
data class SongLine( data class SongLine(val segments: List<LineSegment>)
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

@@ -24,18 +24,6 @@ object ChordProParser {
var currentType: SectionType? = null var currentType: SectionType? = null
var currentLabel: String? = null var currentLabel: String? = null
var currentLines = mutableListOf<SongLine>() var currentLines = mutableListOf<SongLine>()
var explicitSection = false
// 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) {
@@ -43,44 +31,17 @@ object ChordProParser {
currentType = null currentType = null
currentLabel = null currentLabel = null
currentLines = mutableListOf() currentLines = mutableListOf()
explicitSection = false
} }
} }
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
// Blank line: flush implicit sections, skip otherwise // Skip empty lines
if (line.isBlank()) { if (line.isBlank()) continue
if (currentType != null && !explicitSection) {
flushSection()
}
continue
}
// Directive line // Directive line
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) { if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
@@ -116,7 +77,6 @@ object ChordProParser {
flushSection() flushSection()
currentType = SectionType.VERSE currentType = SectionType.VERSE
currentLabel = value currentLabel = value
explicitSection = true
} }
"end_of_verse", "eov" -> { "end_of_verse", "eov" -> {
flushSection() flushSection()
@@ -125,7 +85,6 @@ object ChordProParser {
flushSection() flushSection()
currentType = SectionType.CHORUS currentType = SectionType.CHORUS
currentLabel = value currentLabel = value
explicitSection = true
} }
"end_of_chorus", "eoc" -> { "end_of_chorus", "eoc" -> {
flushSection() flushSection()
@@ -134,26 +93,10 @@ object ChordProParser {
flushSection() flushSection()
currentType = SectionType.REPEAT currentType = SectionType.REPEAT
currentLabel = value currentLabel = value
explicitSection = true
} }
"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,9 +1,7 @@
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)
@@ -52,27 +50,6 @@ 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,288 +485,4 @@ 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"
}
@Test
fun `blank line splits implicit verses into separate sections`() {
val input = """
{title: Am Brunnen vor dem Tore}
Am [D]Brunnen vor dem Tore
Ich [D]träumt in seinem Schatten
Ich musst auch heute wandern
Da hab ich noch im Dunkeln
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 2
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].lines shouldHaveSize 2
song.sections[0].lines[0].segments shouldHaveSize 2
song.sections[0].lines[0].segments[0].chord.shouldBeNull()
song.sections[0].lines[0].segments[0].text shouldBe "Am "
song.sections[0].lines[0].segments[1].chord shouldBe "D"
song.sections[0].lines[0].segments[1].text shouldBe "Brunnen vor dem Tore"
song.sections[1].type shouldBe SectionType.VERSE
song.sections[1].lines shouldHaveSize 2
song.sections[1].lines[0].segments[0].text shouldBe "Ich musst auch heute wandern"
}
@Test
fun `blank lines within explicit sections are ignored`() {
val input = """
{title: Song}
{start_of_verse: Verse 1}
Line one
Line two
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].label shouldBe "Verse 1"
song.sections[0].lines shouldHaveSize 2
}
@Test
fun `blank lines between metadata do not create empty sections`() {
val input = """
{title: Song}
{lyricist: Someone}
{composer: Someone Else}
[Am]Hello world
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].lines shouldHaveSize 1
}
@Test
fun `mixed explicit and implicit sections with blank lines`() {
val input = """
{title: Song}
{start_of_chorus}
[C]Chorus line
Still in chorus
{end_of_chorus}
Implicit verse one
Implicit verse two
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 3
song.sections[0].type shouldBe SectionType.CHORUS
song.sections[0].lines shouldHaveSize 2
song.sections[1].type shouldBe SectionType.VERSE
song.sections[1].lines shouldHaveSize 1
song.sections[1].lines[0].segments[0].text shouldBe "Implicit verse one"
song.sections[2].type shouldBe SectionType.VERSE
song.sections[2].lines shouldHaveSize 1
song.sections[2].lines[0].segments[0].text shouldBe "Implicit verse two"
}
@Test
fun `multiple blank lines between implicit verses`() {
val input = """
{title: Song}
First verse line
Second verse line
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 2
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].lines shouldHaveSize 1
song.sections[1].type shouldBe SectionType.VERSE
song.sections[1].lines shouldHaveSize 1
}
@Test
fun `three implicit verses separated by blank lines`() {
val input = """
{title: Song}
[Am]Verse one line one
Verse one line two
[C]Verse two line one
Verse two line two
[G]Verse three line one
Verse three line two
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 3
song.sections.forEach { section ->
section.type shouldBe SectionType.VERSE
section.lines shouldHaveSize 2
}
}
@Test
fun `blank lines within explicit chorus are ignored`() {
val input = """
{title: Song}
{start_of_chorus}
Line one
Line two
Line three
{end_of_chorus}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.CHORUS
song.sections[0].lines shouldHaveSize 3
}
} }

View File

@@ -214,31 +214,6 @@ 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 = """
@@ -251,21 +226,4 @@ 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,53 +206,4 @@ 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

@@ -26,24 +26,11 @@ class PdfBookRenderer : BookRenderer {
val writer = PdfWriter.getInstance(document, output) val writer = PdfWriter.getInstance(document, output)
document.open() document.open()
// Render intro page (title page) if configured // Render TOC first
if (layout.introPages > 0) {
val cb = writer.directContent
renderIntroPage(cb, fontMetrics, config, pageSize)
// Blank back of intro page (for double-sided printing)
document.newPage()
writer.directContent.let { c -> c.beginText(); c.endText() }
document.newPage()
}
// Render TOC
if (layout.tocEntries.isNotEmpty()) { if (layout.tocEntries.isNotEmpty()) {
tocRenderer.render(document, writer, layout.tocEntries) tocRenderer.render(document, writer, layout.tocEntries)
// Pad with blank pages to fill the allocated TOC page count. // Add blank pages to fill TOC allocation
// The table auto-paginates, so we only add the difference. repeat(layout.tocPages - 1) {
val tocPagesUsed = writer.pageNumber - layout.introPages
val paddingNeeded = maxOf(0, layout.tocPages - tocPagesUsed)
repeat(paddingNeeded) {
document.newPage() document.newPage()
// Force new page even if empty // Force new page even if empty
writer.directContent.let { cb -> writer.directContent.let { cb ->
@@ -55,7 +42,7 @@ class PdfBookRenderer : BookRenderer {
} }
// Render content pages // Render content pages
var currentPageNum = layout.introPages + layout.tocPages + 1 var currentPageNum = layout.tocPages + 1
for (pageContent in layout.pages) { for (pageContent in layout.pages) {
// Swap margins for left/right pages // Swap margins for left/right pages
val isRightPage = currentPageNum % 2 == 1 val isRightPage = currentPageNum % 2 == 1
@@ -76,7 +63,7 @@ class PdfBookRenderer : BookRenderer {
renderSongPage( renderSongPage(
cb, chordLyricRenderer, fontMetrics, config, cb, chordLyricRenderer, fontMetrics, config,
pageContent.song, pageContent.pageIndex, pageContent.song, pageContent.pageIndex,
contentTop, leftMargin, contentWidth, marginBottom contentTop, leftMargin, contentWidth
) )
} }
is PageContent.FillerImage -> { is PageContent.FillerImage -> {
@@ -104,165 +91,6 @@ class PdfBookRenderer : BookRenderer {
document.close() document.close()
} }
/**
* Computes the index of the first section that should be rendered on page 1.
* All sections before this index render on page 0; sections from this index
* onward render on page 1.
*
* If all sections fit on page 0, returns song.sections.size (i.e., nothing on page 1).
*/
private fun computeSplitIndex(
song: Song,
fontMetrics: PdfFontMetrics,
config: BookConfig,
contentWidth: Float,
availableHeightOnPage0: Float
): Int {
var consumed = 0f
// Header: title
consumed += config.fonts.title.size * 1.5f
// Top metadata
val renderMetaAtBottom = config.layout.metadataPosition == "bottom"
if (!renderMetaAtBottom) {
val metaParts = buildMetadataLines(song, config)
if (metaParts.isNotEmpty()) {
consumed += config.fonts.metadata.size * 1.8f * metaParts.size
}
}
// Key/capo
if (song.key != null || song.capo != null) {
consumed += config.fonts.metadata.size * 1.8f
}
consumed += 4f // gap before sections
for ((index, section) in song.sections.withIndex()) {
val sectionHeight = calculateSectionHeight(section, fontMetrics, config, contentWidth)
if (consumed + sectionHeight > availableHeightOnPage0) {
// This section doesn't fit on page 0
return index
}
consumed += sectionHeight
// Add verse spacing
consumed += config.layout.verseSpacing / 0.3528f
}
return song.sections.size
}
/**
* Calculates the height in PDF points that a section will consume when rendered.
*/
private fun calculateSectionHeight(
section: SongSection,
fontMetrics: PdfFontMetrics,
config: BookConfig,
contentWidth: Float
): Float {
var height = 0f
val metaSize = config.fonts.metadata.size
// Section label
if (section.label != null || section.type == SectionType.CHORUS) {
val labelText = section.label ?: when (section.type) {
SectionType.CHORUS -> "Refrain"
SectionType.REPEAT -> "Wiederholung"
else -> null
}
if (labelText != null) {
height += metaSize * 1.5f
}
}
// Empty chorus (reference)
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
height += metaSize * 1.8f
return height
}
// Repeat start marker (contributes no extra height - drawn at current y)
// Lines
val chordSize = config.fonts.chords.size
val lyricSize = config.fonts.lyrics.size
val chordLineHeight = chordSize * 1.2f
val lyricLineHeight = lyricSize * 1.2f
val chordLyricGap = config.layout.chordLineSpacing / 0.3528f
for (line in section.lines) {
if (line.imagePath != null) {
// Inline image: 40mm max height + gaps
val maxImageHeight = 40f / 0.3528f
height += maxImageHeight + 6f
} else {
val hasChords = line.segments.any { it.chord != null }
var lineHeight = lyricLineHeight
if (hasChords) {
lineHeight += chordLineHeight + chordLyricGap
}
height += lineHeight + 1f // 1pt gap between lines
}
}
// Repeat end marker
if (section.type == SectionType.REPEAT) {
height += metaSize * 1.5f
}
return height
}
/**
* Calculates the space (in PDF points) that must be reserved at the bottom of
* the last page of a song for notes, bottom-position metadata, and reference footer.
*/
private fun calculateFooterReservation(
song: Song,
fontMetrics: PdfFontMetrics,
config: BookConfig,
contentWidth: Float
): Float {
var reserved = 0f
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
// Notes
if (song.notes.isNotEmpty()) {
reserved += 4f // gap before notes
val noteLineHeight = metaSize * 1.5f
for ((idx, note) in song.notes.withIndex()) {
val wrappedLines = wrapText(note, metaFont, metaSize, contentWidth)
reserved += noteLineHeight * wrappedLines.size
if (idx < song.notes.size - 1) {
reserved += noteLineHeight * 0.3f
}
}
}
// Bottom metadata
if (config.layout.metadataPosition == "bottom") {
val metaParts = buildMetadataLines(song, config)
if (metaParts.isNotEmpty()) {
reserved += 4f
for (metaLine in metaParts) {
val wrappedLines = wrapText(metaLine, metaFont, metaSize, contentWidth)
reserved += metaSize * 1.5f * wrappedLines.size
}
}
}
// Reference footer: gap + separator line + abbreviation row + page number row
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
val lineHeight = metaSize * 1.4f
reserved += 4f // gap before footer
reserved += lineHeight * 2 // two rows (headers + numbers)
}
return reserved
}
private fun renderSongPage( private fun renderSongPage(
cb: PdfContentByte, cb: PdfContentByte,
chordLyricRenderer: ChordLyricRenderer, chordLyricRenderer: ChordLyricRenderer,
@@ -272,28 +100,10 @@ class PdfBookRenderer : BookRenderer {
pageIndex: Int, // 0 for first page, 1 for second page of 2-page songs pageIndex: Int, // 0 for first page, 1 for second page of 2-page songs
contentTop: Float, contentTop: Float,
leftMargin: Float, leftMargin: Float,
contentWidth: Float, contentWidth: Float
bottomMargin: Float
) { ) {
var y = contentTop var y = contentTop
val renderMetaAtBottom = config.layout.metadataPosition == "bottom"
// Calculate the footer reservation for the last page
val footerReservation = calculateFooterReservation(song, fontMetrics, config, contentWidth)
// Compute the split index to determine which sections go on which page.
// Page 0 gets sections 0..<splitIndex, page 1 gets sections splitIndex..<size.
// Footer space is reserved on the last page only.
val availableOnPage0 = contentTop - bottomMargin -
(if (song.sections.size > 0) footerReservation else 0f)
val splitIndex = computeSplitIndex(song, fontMetrics, config, contentWidth, availableOnPage0)
val isTwoPageSong = splitIndex < song.sections.size
val isLastPage = if (isTwoPageSong) pageIndex == 1 else pageIndex == 0
// Bottom boundary for content on this page
val yMin = bottomMargin + (if (isLastPage) footerReservation else 0f)
if (pageIndex == 0) { if (pageIndex == 0) {
// Render title // Render title
val titleFont = fontMetrics.getBaseFont(config.fonts.title) val titleFont = fontMetrics.getBaseFont(config.fonts.title)
@@ -306,24 +116,21 @@ class PdfBookRenderer : BookRenderer {
cb.endText() cb.endText()
y -= titleSize * 1.5f y -= titleSize * 1.5f
// Render metadata line (composer/lyricist) - at top position only // Render metadata line (composer/lyricist)
if (!renderMetaAtBottom) { val metaParts = mutableListOf<String>()
val metaParts = buildMetadataLines(song, config) song.composer?.let { metaParts.add("M: $it") }
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) {
if (y - metaSize * 1.8f < yMin) break
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(metaLine) cb.showText(metaParts.joinToString(" / "))
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>()
@@ -332,7 +139,6 @@ class PdfBookRenderer : BookRenderer {
if (infoParts.isNotEmpty()) { if (infoParts.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
if (y - metaSize * 1.8f >= yMin) {
cb.beginText() cb.beginText()
cb.setFontAndSize(metaFont, metaSize) cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY) cb.setColorFill(Color.GRAY)
@@ -341,22 +147,16 @@ class PdfBookRenderer : BookRenderer {
cb.endText() cb.endText()
y -= metaSize * 1.8f y -= metaSize * 1.8f
} }
}
y -= 4f // gap before sections y -= 4f // gap before sections
} }
// Determine which sections to render on this page // Determine which sections to render on this page
val sections = if (pageIndex == 0) { // For simplicity in this implementation, render all sections on pageIndex 0
song.sections.subList(0, splitIndex) // A more sophisticated implementation would split sections across pages
} else { val sections = if (pageIndex == 0) song.sections else emptyList()
song.sections.subList(splitIndex, song.sections.size)
}
for (section in sections) { for (section in sections) {
// Safety check: stop rendering if we've gone below the boundary
if (y < yMin) break
// Section label // Section label
if (section.label != null || section.type == SectionType.CHORUS) { if (section.label != null || section.type == SectionType.CHORUS) {
val labelText = section.label ?: when (section.type) { val labelText = section.label ?: when (section.type) {
@@ -367,7 +167,6 @@ class PdfBookRenderer : BookRenderer {
if (labelText != null) { if (labelText != null) {
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
if (y - metaSize * 1.5f < yMin) break
cb.beginText() cb.beginText()
cb.setFontAndSize(metaFont, metaSize) cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY) cb.setColorFill(Color.DARK_GRAY)
@@ -382,7 +181,6 @@ class PdfBookRenderer : BookRenderer {
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) { if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
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
if (y - metaSize * 1.8f < yMin) break
cb.beginText() cb.beginText()
cb.setFontAndSize(metaFont, metaSize) cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY) cb.setColorFill(Color.DARK_GRAY)
@@ -407,22 +205,14 @@ class PdfBookRenderer : BookRenderer {
// Render lines // Render lines
for (line in section.lines) { for (line in section.lines) {
if (y < yMin) break
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) {
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
if (y - metaSize * 1.5f >= yMin) {
cb.beginText() cb.beginText()
cb.setFontAndSize(metaFont, metaSize) cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY) cb.setColorFill(Color.DARK_GRAY)
@@ -431,169 +221,24 @@ class PdfBookRenderer : BookRenderer {
cb.endText() cb.endText()
y -= metaSize * 1.5f y -= metaSize * 1.5f
} }
}
// Verse spacing // Verse spacing
y -= config.layout.verseSpacing / 0.3528f y -= config.layout.verseSpacing / 0.3528f
} }
// Render footer elements (notes, metadata, references) anchored to the bottom of the page. // Render notes at the bottom
// Instead of flowing from the current y position after song content, we compute a fixed if (pageIndex == 0 && song.notes.isNotEmpty()) {
// starting Y at the top of the footer area (bottomMargin + footerReservation) and render y -= 4f
// top-down: notes -> metadata -> references. This ensures footer elements always appear
// at the same vertical position regardless of how much song content is on the page.
if (isLastPage && footerReservation > 0f) {
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) {
// The footer area spans from bottomMargin to bottomMargin + footerReservation.
// Start rendering from the top of this area, flowing downward.
var footerY = bottomMargin + footerReservation
// Render notes (topmost footer element)
if (song.notes.isNotEmpty()) {
footerY -= 4f // gap before 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, footerY - metaSize) cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(wrappedLine) cb.showText(note)
cb.endText()
footerY -= noteLineHeight
}
if (idx < song.notes.size - 1) {
footerY -= noteLineHeight * 0.3f
}
}
}
// Render metadata (Worte/Weise) below notes, if configured at bottom
if (renderMetaAtBottom) {
val metaParts = buildMetadataLines(song, config)
if (metaParts.isNotEmpty()) {
footerY -= 4f // gap before metadata
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, footerY - metaSize)
cb.showText(wrappedLine)
cb.endText()
footerY -= metaSize * 1.5f
}
}
}
}
// Render reference book footer (bottommost footer element, just above page number)
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
footerY -= 4f // gap before reference footer
renderReferenceFooter(
cb, fontMetrics, config, song,
leftMargin, footerY, contentWidth
)
}
}
}
/**
* 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
* on the song page, positioned below the current content at [topY].
* Layout (top to bottom): separator line, abbreviation row, page number row.
*/
private fun renderReferenceFooter(
cb: PdfContentByte,
fontMetrics: PdfFontMetrics,
config: BookConfig,
song: Song,
leftMargin: Float,
topY: Float,
contentWidth: Float
) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
val lineHeight = metaSize * 1.4f
val books = config.referenceBooks
// Calculate column widths: evenly distribute across content width
val colWidth = contentWidth / books.size
// Draw a thin separator line at the top of the footer
val lineY = topY
cb.setLineWidth(0.3f)
cb.setColorStroke(Color.LIGHT_GRAY)
cb.moveTo(leftMargin, lineY)
cb.lineTo(leftMargin + contentWidth, lineY)
cb.stroke()
// Row 1: Abbreviation headers (below the separator line)
val abbrY = lineY - lineHeight
for ((i, book) in books.withIndex()) {
val x = leftMargin + i * colWidth
val abbr = book.abbreviation
val textWidth = metaFont.getWidthPoint(abbr, metaSize)
val textX = x + (colWidth - textWidth) / 2
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(textX, abbrY)
cb.showText(abbr)
cb.endText()
}
// Row 2: Page numbers (below abbreviation headers)
val numY = abbrY - lineHeight
for ((i, book) in books.withIndex()) {
val x = leftMargin + i * colWidth
val pageNum = song.references[book.id]
if (pageNum != null) {
val pageText = pageNum.toString()
val textWidth = metaFont.getWidthPoint(pageText, metaSize)
val textX = x + (colWidth - textWidth) / 2
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(textX, numY)
cb.showText(pageText)
cb.endText() cb.endText()
y -= metaSize * 1.5f
} }
} }
} }
@@ -711,95 +356,6 @@ 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 renderIntroPage(
cb: PdfContentByte,
fontMetrics: PdfFontMetrics,
config: BookConfig,
pageSize: Rectangle
) {
val pageWidth = pageSize.width
val pageHeight = pageSize.height
// Title centered on the page
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
val titleSize = config.fonts.title.size * 2.5f
val title = config.book.title
val titleWidth = titleFont.getWidthPoint(title, titleSize)
val titleX = (pageWidth - titleWidth) / 2
val titleY = pageHeight * 0.55f
cb.beginText()
cb.setFontAndSize(titleFont, titleSize)
cb.setColorFill(Color.BLACK)
cb.setTextMatrix(titleX, titleY)
cb.showText(title)
cb.endText()
// Subtitle below the title
if (config.book.subtitle != null) {
val subtitleSize = config.fonts.title.size * 1.2f
val subtitle = config.book.subtitle!!
val subtitleWidth = titleFont.getWidthPoint(subtitle, subtitleSize)
val subtitleX = (pageWidth - subtitleWidth) / 2
val subtitleY = titleY - titleSize * 1.8f
cb.beginText()
cb.setFontAndSize(titleFont, subtitleSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(subtitleX, subtitleY)
cb.showText(subtitle)
cb.endText()
}
// Edition at the bottom of the page
if (config.book.edition != null) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size * 1.2f
val edition = config.book.edition!!
val editionWidth = metaFont.getWidthPoint(edition, metaSize)
val editionX = (pageWidth - editionWidth) / 2
val editionY = pageHeight * 0.1f
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY)
cb.setTextMatrix(editionX, editionY)
cb.showText(edition)
cb.endText()
}
}
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,21 +3,15 @@ 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 fontFile = font.file val key = font.file ?: font.family
val key = if (fontFile != null) File(fontFile).canonicalPath else font.family
return fontCache.getOrPut(key) { return fontCache.getOrPut(key) {
if (fontFile != null) { if (font.file != null) {
val file = File(fontFile) BaseFont.createFont(font.file, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
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

@@ -12,32 +12,6 @@ class TocRenderer(
// Light gray background for the highlighted column // Light gray background for the highlighted column
private val highlightColor = Color(220, 220, 220) private val highlightColor = Color(220, 220, 220)
/**
* Pre-renders the TOC to a temporary document and returns the number of pages needed,
* rounded up to an even number for double-sided printing.
*/
fun measurePages(tocEntries: List<TocEntry>): Int {
val pageSize = if (config.book.format == "A5") PageSize.A5 else PageSize.A4
val marginInner = config.layout.margins.inner / 0.3528f
val marginOuter = config.layout.margins.outer / 0.3528f
val marginTop = config.layout.margins.top / 0.3528f
val marginBottom = config.layout.margins.bottom / 0.3528f
val baos = java.io.ByteArrayOutputStream()
val doc = Document(pageSize, marginInner, marginOuter, marginTop, marginBottom)
val writer = PdfWriter.getInstance(doc, baos)
doc.open()
render(doc, writer, tocEntries)
doc.close()
val reader = PdfReader(baos.toByteArray())
val pageCount = reader.numberOfPages
reader.close()
// Round to even for double-sided printing
return if (pageCount % 2 == 0) pageCount else pageCount + 1
}
fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) { fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) {
val tocFont = fontMetrics.getBaseFont(config.fonts.toc) val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc) val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
@@ -56,18 +30,19 @@ class TocRenderer(
val table = PdfPTable(numCols) val table = PdfPTable(numCols)
table.widthPercentage = 100f table.widthPercentage = 100f
// Set column widths: title takes most space, ref columns need room for 3-digit numbers // Set column widths: title takes most space
val widths = FloatArray(numCols) val widths = FloatArray(numCols)
widths[0] = 12f // title widths[0] = 10f // title
widths[1] = 1.5f // page widths[1] = 1.5f // page
for (i in refBooks.indices) { for (i in refBooks.indices) {
widths[2 + i] = 1.5f // enough for 3-digit page numbers; headers are rotated 90° widths[2 + i] = 1.5f
} }
table.setWidths(widths) table.setWidths(widths)
// Determine which column index should be highlighted // Determine which column index should be highlighted
val highlightAbbrev = config.toc.highlightColumn val highlightAbbrev = config.toc.highlightColumn
val highlightColumnIndex: Int? = if (highlightAbbrev != null) { val highlightColumnIndex: Int? = if (highlightAbbrev != null) {
// Check "Seite" (page) column first - the current book's page number column
if (highlightAbbrev == "Seite") { if (highlightAbbrev == "Seite") {
1 1
} else { } else {
@@ -76,13 +51,13 @@ class TocRenderer(
} }
} else null } else null
// Header row — reference book columns are rotated 90° // Header row
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD) val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
table.addCell(headerCell("Titel", headerFont, isHighlighted = false)) table.addCell(headerCell("Titel", headerFont, isHighlighted = false))
table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1)) table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1))
for ((i, book) in refBooks.withIndex()) { for ((i, book) in refBooks.withIndex()) {
val isHighlighted = highlightColumnIndex == 2 + i val isHighlighted = highlightColumnIndex == 2 + i
table.addCell(rotatedHeaderCell(book.abbreviation, headerFont, isHighlighted)) table.addCell(headerCell(book.abbreviation, headerFont, isHighlighted = isHighlighted))
} }
table.headerRows = 1 table.headerRows = 1
@@ -96,7 +71,7 @@ class TocRenderer(
for ((i, book) in refBooks.withIndex()) { for ((i, book) in refBooks.withIndex()) {
val ref = entry.references[book.abbreviation] val ref = entry.references[book.abbreviation]
val isHighlighted = highlightColumnIndex == 2 + i val isHighlighted = highlightColumnIndex == 2 + i
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_CENTER, isHighlighted = isHighlighted)) table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT, isHighlighted = isHighlighted))
} }
} }
@@ -108,27 +83,6 @@ class TocRenderer(
cell.borderWidth = 0f cell.borderWidth = 0f
cell.borderWidthBottom = 0.5f cell.borderWidthBottom = 0.5f
cell.paddingBottom = 4f cell.paddingBottom = 4f
cell.verticalAlignment = Element.ALIGN_BOTTOM
if (isHighlighted) {
cell.backgroundColor = highlightColor
}
return cell
}
/**
* Creates a header cell with text rotated 90° counterclockwise.
* Used for reference book column headers to save horizontal space.
*/
private fun rotatedHeaderCell(text: String, font: Font, isHighlighted: Boolean): PdfPCell {
val cell = PdfPCell(Phrase(text, font))
cell.borderWidth = 0f
cell.borderWidthBottom = 0.5f
cell.rotation = 90
cell.horizontalAlignment = Element.ALIGN_CENTER
cell.verticalAlignment = Element.ALIGN_MIDDLE
// Ensure cell is tall enough for the rotated text
val textWidth = font.baseFont.getWidthPoint(text, font.size)
cell.minimumHeight = textWidth + 8f
if (isHighlighted) { if (isHighlighted) {
cell.backgroundColor = highlightColor cell.backgroundColor = highlightColor
} }

View File

@@ -417,213 +417,4 @@ class PdfBookRendererTest {
baos.size() shouldBeGreaterThan 0 baos.size() shouldBeGreaterThan 0
} }
// --- Content splitting tests ---
private fun createLongSong(title: String = "Long Song"): Song {
// Create a song with many sections that will exceed one A5 page
val sections = (1..20).map { i ->
SongSection(
type = SectionType.VERSE,
label = "Verse $i",
lines = (1..4).map {
SongLine(
listOf(
LineSegment(chord = "Am", text = "Some text with chords "),
LineSegment(chord = "G", text = "and more text here")
)
)
}
)
}
return Song(title = title, sections = sections)
}
@Test
fun `render splits content across pages for two-page song`() {
val song = createLongSong()
val layout = LayoutResult(
tocPages = 0,
pages = listOf(
PageContent.SongPage(song, 0),
PageContent.SongPage(song, 1)
),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, BookConfig(), baos)
baos.size() shouldBeGreaterThan 0
val bytes = baos.toByteArray()
val header = String(bytes.sliceArray(0..4))
header shouldBe "%PDF-"
}
@Test
fun `render does not overflow below bottom margin for very long song`() {
// Create an extremely long song
val sections = (1..40).map { i ->
SongSection(
type = SectionType.VERSE,
label = "Verse $i",
lines = (1..6).map {
SongLine(
listOf(
LineSegment(chord = "C", text = "A long line of text that should be rendered properly "),
LineSegment(chord = "G", text = "with chords above each segment")
)
)
}
)
}
val song = Song(title = "Very Long Song", sections = sections)
val layout = LayoutResult(
tocPages = 0,
pages = listOf(
PageContent.SongPage(song, 0),
PageContent.SongPage(song, 1)
),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, BookConfig(), baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render places metadata at bottom of last page for two-page song`() {
val config = BookConfig(
layout = LayoutConfig(metadataPosition = "bottom")
)
val song = createLongSong().copy(
composer = "Bach",
lyricist = "Goethe"
)
val layout = LayoutResult(
tocPages = 0,
pages = listOf(
PageContent.SongPage(song, 0),
PageContent.SongPage(song, 1)
),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, config, baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render places notes on last page of two-page song`() {
val song = createLongSong().copy(
notes = listOf("This is a note that should appear on the last page")
)
val layout = LayoutResult(
tocPages = 0,
pages = listOf(
PageContent.SongPage(song, 0),
PageContent.SongPage(song, 1)
),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, BookConfig(), baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render places reference footer on last page of two-page song`() {
val config = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mo", name = "Mundorgel", abbreviation = "MO"),
ReferenceBook(id = "pl", name = "Pfadfinderlied", abbreviation = "PL")
)
)
val song = createLongSong().copy(
references = mapOf("mo" to 42, "pl" to 17)
)
val layout = LayoutResult(
tocPages = 0,
pages = listOf(
PageContent.SongPage(song, 0),
PageContent.SongPage(song, 1)
),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, config, baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render handles short song that fits on one page without splitting`() {
// A simple short song should still work correctly after split logic is added
val song = Song(
title = "Short Song",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(
SongLine(listOf(LineSegment(chord = "Am", text = "One line")))
)
)
)
)
val layout = LayoutResult(
tocPages = 0,
pages = listOf(PageContent.SongPage(song, 0)),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, BookConfig(), baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render two-page song with bottom metadata and references`() {
val config = BookConfig(
layout = LayoutConfig(
metadataPosition = "bottom",
metadataLabels = "german"
),
referenceBooks = listOf(
ReferenceBook(id = "mo", name = "Mundorgel", abbreviation = "MO")
)
)
val song = createLongSong().copy(
composer = "Bach",
lyricist = "Goethe",
notes = listOf("Play softly", "Repeat last verse"),
references = mapOf("mo" to 55)
)
val layout = LayoutResult(
tocPages = 0,
pages = listOf(
PageContent.SongPage(song, 0),
PageContent.SongPage(song, 1)
),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, config, baos)
baos.size() shouldBeGreaterThan 0
}
} }

View File

@@ -6,7 +6,6 @@ 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 {
@@ -159,73 +158,4 @@ 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
}
} }

View File

@@ -14,16 +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 }
chord_line_spacing: 1 chord_line_spacing: 3
verse_spacing: 6 verse_spacing: 4
page_number_position: bottom-outer page_number_position: bottom-outer
images: images:
@@ -37,9 +32,6 @@ reference_books:
name: "Pfadfinderliederbuch" name: "Pfadfinderliederbuch"
abbreviation: "PfLB" abbreviation: "PfLB"
toc:
highlight_column: "Seite"
output: output:
directory: "./output" directory: "./output"
filename: "liederbuch.pdf" filename: "liederbuch.pdf"