4 Commits

Author SHA1 Message Date
shahondin1624
5a63067b93 Add foreword page and image placement commands
- Introductory page with quote, horizontal rule, and foreword text
- \fillerpage{path} for full-page centered filler images
- \songimage{path} for inline images within song pages
- \fullpageimage{path} for borderless full-page images
- Added graphicx and csquotes packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:54:21 +02:00
shahondin1624
cae0c52b67 Fix TOC page references and song page footer
- Move \label outside dedup guard so it survives the real pass
  (measurement pass labels are discarded inside vbox)
- Song page footer now shows MO/PfLB/Liederbuch table matching TOC
- Fix TEXINPUTS to include output/ for .songtoc file access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:23:04 +02:00
shahondin1624
692be693e9 Add matrix TOC with cross-reference columns
Replace the default LaTeX TOC with a table matching the Carmina
Leonis style: song titles with columns for reference books (MO,
PfLB) and the current songbook page number. Uses expl3 iow to
write song data to a .songtoc file during compilation and reads
it back on the second pass. Alternating row colors via rowcolors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:06:05 +02:00
shahondin1624
4024d0e421 Rewrite songbook as pure LaTeX project (Carmina Leonis style)
Replace the Kotlin/Gradle multi-module pipeline with a pure LaTeX
songbook using the leadsheets package and LuaLaTeX. Style matches
the Carmina Leonis (CL6) scout songbook: Fraktur titles, chords
above lyrics, metadata at page bottom, reference book footer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 08:23:57 +02:00
78 changed files with 571 additions and 8102 deletions

34
.gitignore vendored
View File

@@ -1,22 +1,28 @@
# Gradle # LaTeX build artifacts
.gradle/ *.aux
build/ *.log
buildSrc/build/ *.out
*.toc
*.fls
*.fdb_latexmk
*.synctex.gz
*.synctex(busy)
*.sxd
*.sxc
# IDE # Output directory
.idea/ output/
*.iml
.vscode/
# OS # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Output # Editor files
output/ .idea/
*.iml
# Kotlin .vscode/
*.class *~
*.swp
# Claude # Claude
.claude/ .claude/

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`.

138
CLAUDE.md
View File

@@ -2,105 +2,83 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Test Commands ## Build Commands
```bash ```bash
# Build everything # Build the songbook PDF (two-pass for TOC)
gradle build make
# Run all tests # Remove auxiliary files
gradle test make clean
# Run tests for a specific module # Remove everything including PDF
gradle :parser:test make distclean
gradle :layout:test
gradle :renderer-pdf:test
gradle :app:test
# Run a single test class
gradle :parser:test --tests ChordProParserTest
# Run a single test method
gradle :parser:test --tests "ChordProParserTest.parse complete song"
# Build and run CLI
gradle :cli:run --args="build -d /path/to/project"
gradle :cli:run --args="validate -d /path/to/project"
# Launch GUI
gradle :gui:run
``` ```
Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.1. Requires LuaLaTeX (TeX Live) and the `leadsheets` package.
## Architecture ## Project Structure
**Pipeline:** Parse → Validate → Measure → Paginate → Render
`SongbookPipeline` (in `app`) orchestrates the full flow:
1. `ConfigParser` reads `songbook.yaml``BookConfig`
2. `ChordProParser` reads `.chopro`/`.cho`/`.crd` files → `Song` objects
3. `ForewordParser` reads optional `foreword.txt``Foreword` (if configured)
4. `Validator` checks config and songs
5. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
6. `TocGenerator` estimates TOC page count and creates entries
7. `PaginationEngine` arranges songs into pages (greedy spread packing)
8. `PdfBookRenderer` generates the PDF via OpenPDF
**Module dependency graph:**
``` ```
model ← parser songbook.tex # Main document (title page, TOC, song inputs)
model ← layout songbook-style.sty # Style package (geometry, fonts, leadsheets config)
model ← renderer-pdf songs/ # One .tex file per song
parser, layout, renderer-pdf ← app fonts/ # Font files (UnifrakturMaguntia for titles)
app ← cli (Clikt) images/ # Filler images (empty for now)
app, parser ← gui (Compose Desktop) Makefile # Build rules (lualatex, two passes)
output/ # Generated PDF (gitignored)
``` ```
`model` is the foundation with no dependencies — all data classes, the `FontMetrics` interface, and the `BookRenderer` interface live here. The `FontMetrics` abstraction decouples layout from rendering: `PdfFontMetrics` is the real implementation (in renderer-pdf), `StubFontMetrics` is used in layout tests. ## How It Works
**Pagination constraint:** Songs spanning 2 pages must start on a left (even) page. The `PaginationEngine` inserts filler images or blank pages to enforce this. Pure LaTeX songbook using the `leadsheets` package with LuaLaTeX. The style matches the Carmina Leonis songbook format:
- Song titles in Fraktur/blackletter font (UnifrakturMaguntia)
## Key Types - Chords above lyrics in regular weight, black
- No verse labels (verses separated by blank lines)
- `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` - Metadata (Worte/Weise) at bottom of each song page
- `SongLine` — holds `segments` plus optional `imagePath` (when set, the line is an inline image) - Reference book cross-references (MO, PfLB) in footer
- `Foreword``quote`, `paragraphs`, `signatures` — parsed from a plain-text file - Each song starts on a new page
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`, `ForewordPage` - A5 twoside format with page numbers at bottom-outer
- `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
## 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. Each song uses the `leadsheets` `song` environment:
**Metadata directives:** `{title: }` / `{t: }`, `{alias: }`, `{lyricist: }`, `{composer: }`, `{key: }`, `{tags: }`, `{note: }`, `{capo: }` ```latex
\begin{song}{
title = Song Title,
lyrics = Lyricist,
composer = Composer,
key = G,
mundorgel = 42,
pfadfinderliederbuch = 118,
note = {Optional note text.},
}
**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. \begin{verse}
\chord{G}Lyrics with \chord{D}chords above. \\
Next \chord{C}line here.
\end{verse}
**Notes block:** `{start_of_notes}` / `{son}``{end_of_notes}` / `{eon}` — multi-paragraph rich-text notes rendered at the end of a song. \begin{verse}
Second verse without chords (or with).
\end{verse}
**Inline image:** `{image: path}` — embeds an image within a song section. \end{song}
```
**Reference:** `{ref: bookId pageNumber}` — cross-reference to a page in another songbook (configured in `reference_books`). **Important constraints:**
- Use `\\` for line breaks within verses (not blank lines)
- Never place two `\chord{}` commands without a space between them — split compound words with a hyphen: `\chord{D}Abend- \chord{A}zeit.`
- Custom properties: `alias`, `note`, `mundorgel`, `pfadfinderliederbuch`
- Verse types: `verse` (no label), `verse*` (for custom-labeled sections like Kanon, Ref.)
- `musicsymbols` library skipped (requires `musix11` font not installed)
## Configuration ## Style Details (songbook-style.sty)
`songbook.yaml` at the project root. Key options beyond the basics: - Page geometry: A5, margins (top 15mm, bottom 20mm, inner 20mm, outer 12mm)
- Body font: TeX Gyre Heros (Helvetica clone)
- `fonts.<role>.file` — path to a custom font file (TTF/OTF) for any font role (`lyrics`, `chords`, `title`, `metadata`, `toc`) - Title font: UnifrakturMaguntia (Fraktur/blackletter, from `fonts/` directory)
- `layout.metadata_labels``"abbreviated"` (M:/T:) or `"german"` (Worte:/Weise:) - Chord format: small, regular weight, black
- `layout.metadata_position``"top"` (after title) or `"bottom"` (bottom of last page) - Song title template: Fraktur title only (metadata rendered at bottom via `after-song` hook)
- `toc.highlight_column` — abbreviation of the reference-book column to highlight (e.g. `"CL"`) - Reference style based on Carmina Leonis (Pfadfinder scout songbook)
- `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
Tests use `kotlin.test` annotations with Kotest assertions (`shouldBe`, `shouldHaveSize`, etc.) on JUnit 5. Layout tests use `StubFontMetrics` to avoid PDF font dependencies. App integration tests create temp directories with song files and config.
## Package
All code under `de.pfadfinder.songbook.*` — subpackages match module names (`.model`, `.parser`, `.layout`, `.renderer.pdf`, `.app`, `.cli`, `.gui`).

23
Makefile Normal file
View File

@@ -0,0 +1,23 @@
MAIN = songbook
ENGINE = lualatex
OUTDIR = output
FLAGS = --output-directory=$(OUTDIR) --interaction=nonstopmode
.PHONY: all clean distclean
all: $(OUTDIR)/$(MAIN).pdf
$(OUTDIR):
mkdir -p $(OUTDIR)
$(OUTDIR)/$(MAIN).pdf: $(MAIN).tex songbook-style.sty songs/*.tex | $(OUTDIR)
TEXINPUTS=.:$(shell pwd):$(shell pwd)/$(OUTDIR): $(ENGINE) $(FLAGS) $(MAIN).tex
TEXINPUTS=.:$(shell pwd):$(shell pwd)/$(OUTDIR): $(ENGINE) $(FLAGS) $(MAIN).tex
clean:
rm -f $(OUTDIR)/*.aux $(OUTDIR)/*.log $(OUTDIR)/*.out \
$(OUTDIR)/*.toc $(OUTDIR)/*.fls $(OUTDIR)/*.fdb_latexmk \
$(OUTDIR)/*.sxd $(OUTDIR)/*.sxc $(OUTDIR)/*.songtoc
distclean: clean
rm -f $(OUTDIR)/$(MAIN).pdf

View File

@@ -1,16 +0,0 @@
plugins {
id("songbook-conventions")
}
dependencies {
implementation(project(":model"))
implementation(project(":parser"))
implementation(project(":layout"))
implementation(project(":renderer-pdf"))
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("ch.qos.logback:logback-classic:1.5.16")
testImplementation(kotlin("test"))
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
}

View File

@@ -1,259 +0,0 @@
package de.pfadfinder.songbook.app
import de.pfadfinder.songbook.model.*
import de.pfadfinder.songbook.parser.*
import de.pfadfinder.songbook.parser.ForewordParser
import de.pfadfinder.songbook.layout.*
import de.pfadfinder.songbook.renderer.pdf.PdfBookRenderer
import de.pfadfinder.songbook.renderer.pdf.PdfFontMetrics
import de.pfadfinder.songbook.renderer.pdf.TocRenderer
import mu.KotlinLogging
import java.io.File
import java.io.FileOutputStream
private val logger = KotlinLogging.logger {}
data class BuildResult(
val success: Boolean,
val outputFile: File? = null,
val errors: List<ValidationError> = emptyList(),
val songCount: Int = 0,
val pageCount: Int = 0
)
class SongbookPipeline(private val projectDir: File) {
/**
* 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
onProgress?.invoke("Konfiguration wird geladen...")
val configFile = File(projectDir, "songbook.yaml")
if (!configFile.exists()) {
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
}
logger.info { "Parsing config: ${configFile.absolutePath}" }
val rawConfig = ConfigParser.parse(configFile)
// Resolve font file paths relative to the project directory
val config = resolveFontPaths(rawConfig)
// Validate config (including font file existence)
val configErrors = Validator.validateConfig(config)
if (configErrors.isNotEmpty()) {
return BuildResult(false, errors = configErrors)
}
// 2. Parse songs
val songsDir = File(projectDir, config.songs.directory)
if (!songsDir.exists() || !songsDir.isDirectory) {
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "Songs directory not found")))
}
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
?.sortedBy { it.name }
?: emptyList()
if (songFiles.isEmpty()) {
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" }
val songsByFileName = mutableMapOf<String, Song>()
val allErrors = mutableListOf<ValidationError>()
for ((index, file) in songFiles.withIndex()) {
if (index > 0 && index % 50 == 0) {
onProgress?.invoke("Lieder werden importiert... ($index/${songFiles.size})")
}
try {
val song = ChordProParser.parseFile(file)
val songErrors = Validator.validateSong(song, file.name)
if (songErrors.isNotEmpty()) {
allErrors.addAll(songErrors)
} else {
songsByFileName[file.name] = song
}
} catch (e: Exception) {
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
}
}
if (allErrors.isNotEmpty()) {
return BuildResult(false, errors = allErrors)
}
val songs = songsByFileName.values.toList()
// 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() }
else -> songs // manual order = file order
}
}
logger.info { "Parsed ${sortedSongs.size} songs" }
// 2b. Parse foreword (if configured)
var foreword: Foreword? = null
val forewordConfig = config.foreword
if (forewordConfig != null) {
val forewordFile = File(projectDir, forewordConfig.file)
if (forewordFile.exists()) {
logger.info { "Parsing foreword: ${forewordFile.absolutePath}" }
foreword = ForewordParser.parseFile(forewordFile)
} else {
logger.warn { "Foreword file not found: ${forewordFile.absolutePath}" }
}
}
// 3. Measure songs
onProgress?.invoke("Layout wird berechnet...")
val fontMetrics = PdfFontMetrics()
val measurementEngine = MeasurementEngine(fontMetrics, config)
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
// 4. Generate TOC and paginate
val tocGenerator = TocGenerator(config)
val estimatedTocPages = 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)
val forewordPages = if (foreword != null) 2 else 0
val headerPages = introPages + estimatedTocPages + forewordPages
val paginationEngine = PaginationEngine(config)
val pages = paginationEngine.paginate(measuredSongs, headerPages)
// Generate initial TOC entries, then measure actual pages needed
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
val allPages = mutableListOf<PageContent>()
if (foreword != null) {
allPages.add(PageContent.ForewordPage(foreword, 0))
allPages.add(PageContent.ForewordPage(foreword, 1))
}
allPages.addAll(pages)
val layoutResult = LayoutResult(
introPages = introPages,
tocPages = tocPages,
pages = allPages,
tocEntries = tocEntries
)
val totalPages = introPages + tocPages + pages.size
logger.info { "Layout: ${introPages} intro, ${tocPages} TOC, ${pages.size} content pages" }
// 5. Render PDF
onProgress?.invoke("PDF wird erzeugt (${sortedSongs.size} Lieder, $totalPages Seiten)...")
val outputDir = File(projectDir, config.output.directory)
outputDir.mkdirs()
val outputFile = File(outputDir, config.output.filename)
logger.info { "Rendering PDF: ${outputFile.absolutePath}" }
val renderer = PdfBookRenderer()
FileOutputStream(outputFile).use { fos ->
renderer.render(layoutResult, config, fos)
}
logger.info { "Build complete: ${sortedSongs.size} songs, $totalPages pages" }
return BuildResult(
success = true,
outputFile = outputFile,
songCount = sortedSongs.size,
pageCount = totalPages
)
}
/**
* 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> {
val configFile = File(projectDir, "songbook.yaml")
if (!configFile.exists()) {
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
}
val rawConfig = ConfigParser.parse(configFile)
val config = resolveFontPaths(rawConfig)
val errors = mutableListOf<ValidationError>()
errors.addAll(Validator.validateConfig(config))
val songsDir = File(projectDir, config.songs.directory)
if (!songsDir.exists()) {
errors.add(ValidationError(config.songs.directory, null, "Songs directory not found"))
return errors
}
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
?.sortedBy { it.name }
?: emptyList()
for (file in songFiles) {
try {
val song = ChordProParser.parseFile(file)
errors.addAll(Validator.validateSong(song, file.name))
} catch (e: Exception) {
errors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
}
}
return errors
}
}

View File

@@ -1,534 +0,0 @@
package de.pfadfinder.songbook.app
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.collections.shouldNotBeEmpty
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.io.File
import kotlin.test.Test
class SongbookPipelineTest {
private fun createTempProject(): File {
val dir = kotlin.io.path.createTempDirectory("songbook-test").toFile()
dir.deleteOnExit()
return dir
}
private fun writeConfig(projectDir: File, config: String = defaultConfig()) {
File(projectDir, "songbook.yaml").writeText(config)
}
private fun defaultConfig(
songsDir: String = "./songs",
outputDir: String = "./output",
outputFilename: String = "liederbuch.pdf",
order: String = "alphabetical"
): String = """
book:
title: "Test Liederbuch"
format: "A5"
songs:
directory: "$songsDir"
order: "$order"
fonts:
lyrics:
family: "Helvetica"
size: 10
chords:
family: "Helvetica"
size: 9
title:
family: "Helvetica"
size: 14
metadata:
family: "Helvetica"
size: 8
toc:
family: "Helvetica"
size: 9
layout:
margins:
top: 15
bottom: 15
inner: 20
outer: 12
images:
directory: "./images"
output:
directory: "$outputDir"
filename: "$outputFilename"
""".trimIndent()
private fun writeSongFile(songsDir: File, filename: String, content: String) {
songsDir.mkdirs()
File(songsDir, filename).writeText(content)
}
private fun sampleSong(title: String = "Test Song"): String = """
{title: $title}
{start_of_verse}
[Am]Hello [C]world
This is a test
{end_of_verse}
""".trimIndent()
// --- build() tests ---
@Test
fun `build returns error when songbook yaml is missing`() {
val projectDir = createTempProject()
try {
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors shouldHaveSize 1
result.errors[0].message shouldContain "songbook.yaml not found"
result.outputFile.shouldBeNull()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build returns error when songs directory does not exist`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(songsDir = "./nonexistent"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors shouldHaveSize 1
result.errors[0].message shouldContain "Songs directory not found"
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build returns error when songs directory is empty`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
File(projectDir, "songs").mkdirs()
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors shouldHaveSize 1
result.errors[0].message shouldContain "No song files found"
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build returns error for invalid config with zero margins`() {
val projectDir = createTempProject()
try {
val config = """
book:
title: "Test"
layout:
margins:
top: 0
bottom: 15
inner: 20
outer: 12
""".trimIndent()
writeConfig(projectDir, config)
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors.shouldNotBeEmpty()
result.errors.any { it.message.contains("margin", ignoreCase = true) }.shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build returns error for song with missing title`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "bad_song.chopro", """
{start_of_verse}
[Am]Hello world
{end_of_verse}
""".trimIndent())
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors.shouldNotBeEmpty()
result.errors.any { it.message.contains("title", ignoreCase = true) }.shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build returns error for song with no sections`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "empty_song.chopro", "{title: Empty Song}")
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeFalse()
result.errors.shouldNotBeEmpty()
result.errors.any { it.message.contains("section", ignoreCase = true) }.shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build succeeds with valid project`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong("Alpha Song"))
writeSongFile(songsDir, "song2.chopro", sampleSong("Beta Song"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.errors.shouldBeEmpty()
result.outputFile.shouldNotBeNull()
result.outputFile!!.exists().shouldBeTrue()
result.songCount shouldBe 2
result.pageCount shouldBeGreaterThan 0
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build creates output directory if it does not exist`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(outputDir = "./out/build"))
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong())
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
File(projectDir, "out/build").exists().shouldBeTrue()
result.outputFile!!.exists().shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build with alphabetical order sorts songs by title`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(order = "alphabetical"))
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "z_first.chopro", sampleSong("Zebra Song"))
writeSongFile(songsDir, "a_second.chopro", sampleSong("Alpha Song"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.songCount shouldBe 2
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build with manual order preserves file order`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(order = "manual"))
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "02_second.chopro", sampleSong("Second Song"))
writeSongFile(songsDir, "01_first.chopro", sampleSong("First Song"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.songCount shouldBe 2
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build recognizes cho extension`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.cho", sampleSong("Cho Song"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.songCount shouldBe 1
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build recognizes crd extension`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.crd", sampleSong("Crd Song"))
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.songCount shouldBe 1
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build ignores non-song files in songs directory`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong("Real Song"))
writeSongFile(songsDir, "readme.txt", "Not a song")
writeSongFile(songsDir, "notes.md", "# Notes")
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.songCount shouldBe 1
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build output file has correct name`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(outputFilename = "my-book.pdf"))
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong())
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
result.outputFile!!.name shouldBe "my-book.pdf"
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `build pageCount includes toc pages`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong())
val pipeline = SongbookPipeline(projectDir)
val result = pipeline.build()
result.success.shouldBeTrue()
// At least 1 content page + TOC pages (minimum 2 for even count)
result.pageCount shouldBeGreaterThan 1
} finally {
projectDir.deleteRecursively()
}
}
// --- validate() tests ---
@Test
fun `validate returns error when songbook yaml is missing`() {
val projectDir = createTempProject()
try {
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors shouldHaveSize 1
errors[0].message shouldContain "songbook.yaml not found"
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate returns error when songs directory does not exist`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir, defaultConfig(songsDir = "./nonexistent"))
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors.shouldNotBeEmpty()
errors.any { it.message.contains("Songs directory not found") }.shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate returns empty list for valid project`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "song1.chopro", sampleSong())
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors.shouldBeEmpty()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate reports config errors`() {
val projectDir = createTempProject()
try {
val config = """
layout:
margins:
top: 0
bottom: 0
inner: 0
outer: 0
""".trimIndent()
writeConfig(projectDir, config)
// Still need songs dir to exist for full validate
File(projectDir, "./songs").mkdirs()
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors shouldHaveSize 4
errors.all { it.message.contains("margin", ignoreCase = true) }.shouldBeTrue()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate reports song validation errors`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "bad_song.chopro", "{title: }")
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors.shouldNotBeEmpty()
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate reports errors for multiple invalid songs`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
val songsDir = File(projectDir, "songs")
writeSongFile(songsDir, "bad1.chopro", "{title: Good Title}") // no sections
writeSongFile(songsDir, "bad2.chopro", "{title: Another Title}") // no sections
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
errors.shouldNotBeEmpty()
errors.size shouldBeGreaterThan 1
} finally {
projectDir.deleteRecursively()
}
}
@Test
fun `validate with empty songs directory returns no song errors`() {
val projectDir = createTempProject()
try {
writeConfig(projectDir)
File(projectDir, "songs").mkdirs()
val pipeline = SongbookPipeline(projectDir)
val errors = pipeline.validate()
// No errors because there are no song files to validate
errors.shouldBeEmpty()
} finally {
projectDir.deleteRecursively()
}
}
// --- BuildResult data class tests ---
@Test
fun `BuildResult defaults are correct`() {
val result = BuildResult(success = false)
result.success.shouldBeFalse()
result.outputFile.shouldBeNull()
result.errors.shouldBeEmpty()
result.songCount shouldBe 0
result.pageCount shouldBe 0
}
@Test
fun `BuildResult with all fields set`() {
val file = File("/tmp/test.pdf")
val errors = listOf(de.pfadfinder.songbook.parser.ValidationError("test", 1, "error"))
val result = BuildResult(
success = true,
outputFile = file,
errors = errors,
songCount = 5,
pageCount = 10
)
result.success.shouldBeTrue()
result.outputFile shouldBe file
result.errors shouldHaveSize 1
result.songCount shouldBe 5
result.pageCount shouldBe 10
}
}

View File

@@ -1,4 +0,0 @@
plugins {
id("org.jetbrains.compose") version "1.7.3" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false
}

View File

@@ -1,12 +0,0 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
gradlePluginPortal()
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10")
}

View File

@@ -1,18 +0,0 @@
plugins {
kotlin("jvm")
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
}
}
tasks.withType<Test> {
useJUnitPlatform()
}

View File

@@ -1,17 +0,0 @@
plugins {
id("songbook-conventions")
application
}
application {
mainClass.set("de.pfadfinder.songbook.cli.MainKt")
}
dependencies {
implementation(project(":app"))
implementation(project(":model"))
implementation(project(":parser"))
implementation("com.github.ajalt.clikt:clikt:5.0.3")
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("ch.qos.logback:logback-classic:1.5.16")
}

View File

@@ -1,37 +0,0 @@
package de.pfadfinder.songbook.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import de.pfadfinder.songbook.app.SongbookPipeline
import java.io.File
class BuildCommand : CliktCommand(name = "build") {
override fun help(context: Context) = "Build the songbook PDF"
private val projectDir by option("-d", "--dir", help = "Project directory").default(".")
override fun run() {
val dir = File(projectDir).absoluteFile
echo("Building songbook from: ${dir.path}")
val pipeline = SongbookPipeline(dir)
val result = pipeline.build(onProgress = { msg -> echo(msg) })
if (result.success) {
echo("Build successful!")
echo(" Songs: ${result.songCount}")
echo(" Pages: ${result.pageCount}")
echo(" Output: ${result.outputFile?.absolutePath}")
} else {
echo("Build failed with ${result.errors.size} error(s):", err = true)
for (error in result.errors) {
val location = listOfNotNull(error.file, error.line?.toString()).joinToString(":")
echo(" [$location] ${error.message}", err = true)
}
throw ProgramResult(1)
}
}
}

View File

@@ -1,15 +0,0 @@
package de.pfadfinder.songbook.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.main
import com.github.ajalt.clikt.core.subcommands
class SongbookCli : CliktCommand(name = "songbook") {
override fun run() = Unit
}
fun main(args: Array<String>) {
SongbookCli()
.subcommands(BuildCommand(), ValidateCommand())
.main(args)
}

View File

@@ -1,34 +0,0 @@
package de.pfadfinder.songbook.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import de.pfadfinder.songbook.app.SongbookPipeline
import java.io.File
class ValidateCommand : CliktCommand(name = "validate") {
override fun help(context: Context) = "Validate all song files"
private val projectDir by option("-d", "--dir", help = "Project directory").default(".")
override fun run() {
val dir = File(projectDir).absoluteFile
echo("Validating songbook in: ${dir.path}")
val pipeline = SongbookPipeline(dir)
val errors = pipeline.validate()
if (errors.isEmpty()) {
echo("All songs are valid!")
} else {
echo("Found ${errors.size} error(s):", err = true)
for (error in errors) {
val location = listOfNotNull(error.file, error.line?.toString()).joinToString(":")
echo(" [$location] ${error.message}", err = true)
}
throw ProgramResult(1)
}
}
}

Binary file not shown.

View File

@@ -1 +0,0 @@
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk

Binary file not shown.

View File

@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored
View File

@@ -1,248 +0,0 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
gradlew.bat vendored
View File

@@ -1,93 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -1,21 +0,0 @@
plugins {
id("songbook-conventions")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
}
dependencies {
implementation(project(":app"))
implementation(project(":model"))
implementation(project(":parser"))
implementation(compose.desktop.currentOs)
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("ch.qos.logback:logback-classic:1.5.16")
implementation("org.apache.pdfbox:pdfbox:3.0.4")
}
compose.desktop {
application {
mainClass = "de.pfadfinder.songbook.gui.AppKt"
}
}

View File

@@ -1,447 +0,0 @@
package de.pfadfinder.songbook.gui
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollbarAdapter
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import de.pfadfinder.songbook.app.BuildResult
import de.pfadfinder.songbook.app.SongbookPipeline
import de.pfadfinder.songbook.parser.ChordProParser
import de.pfadfinder.songbook.parser.ConfigParser
import de.pfadfinder.songbook.parser.ValidationError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.Desktop
import java.io.File
import javax.swing.JFileChooser
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Songbook Builder"
) {
App()
}
}
data class SongEntry(val fileName: String, val title: String)
@Composable
@Preview
fun App() {
var projectPath by remember { mutableStateOf("") }
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 isRunning by remember { mutableStateOf(false) }
var isLoadingSongs by remember { mutableStateOf(false) }
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
val previewState = remember { PdfPreviewState() }
val scope = rememberCoroutineScope()
val reorderEnabled = songsOrderConfig != "alphabetical"
fun loadSongs(path: String) {
val projectDir = File(path)
songs = emptyList()
originalSongs = emptyList()
isCustomOrder = false
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")
var songsDir: File
var orderConfig = "alphabetical"
if (configFile.exists()) {
try {
val config = ConfigParser.parse(configFile)
songsDir = File(projectDir, config.songs.directory)
orderConfig = config.songs.order
} catch (_: Exception) {
songsDir = File(projectDir, "songs")
}
} else {
songsDir = File(projectDir, "songs")
}
if (!songsDir.isDirectory) return@withContext Pair(emptyList<SongEntry>(), orderConfig)
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
?.sortedBy { it.name }
?: emptyList()
val loaded = songFiles.mapNotNull { file ->
try {
val song = ChordProParser.parseFile(file)
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
} catch (_: Exception) {
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 {
Surface(modifier = Modifier.fillMaxSize()) {
SelectionContainer {
Column(modifier = Modifier.padding(16.dp)) {
// Project directory selection
Text(
text = "Songbook Builder",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
Text("Projektverzeichnis:", fontWeight = FontWeight.Medium)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = projectPath,
onValueChange = {
projectPath = it
loadSongs(it)
},
modifier = Modifier.weight(1f),
singleLine = true,
placeholder = { Text("Pfad zum Projektverzeichnis...") }
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
val chooser = JFileChooser().apply {
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
dialogTitle = "Projektverzeichnis auswählen"
if (projectPath.isNotBlank()) {
currentDirectory = File(projectPath)
}
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
projectPath = chooser.selectedFile.absolutePath
loadSongs(projectPath)
}
},
enabled = !isRunning
) {
Text("Durchsuchen...")
}
}
Spacer(modifier = Modifier.height(16.dp))
// Central content area: song list or preview panel
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 = "Lieder (${songs.size}):",
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))
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()) {
Text(
"Keine Lieder gefunden. Bitte Projektverzeichnis pruefen.",
color = Color.Gray,
modifier = Modifier.padding(8.dp)
)
}
} else if (projectPath.isBlank()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Text(
"Bitte ein Projektverzeichnis auswaehlen.",
color = Color.Gray,
modifier = Modifier.padding(8.dp)
)
}
} else {
ReorderableSongList(
songs = songs,
reorderEnabled = reorderEnabled,
onReorder = { newList ->
songs = newList
isCustomOrder = true
},
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Action buttons
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
if (projectPath.isBlank()) return@Button
isRunning = true
lastBuildResult = null
statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO))
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) {
try {
SongbookPipeline(File(projectPath)).build(customOrder) { msg ->
statusMessages = listOf(StatusMessage(msg, MessageType.INFO))
}
} catch (e: Exception) {
BuildResult(
success = false,
errors = listOf(
ValidationError(null, null, "Unerwarteter Fehler: ${e.message}")
)
)
}
}
lastBuildResult = result
statusMessages = if (result.success) {
listOf(
StatusMessage(
"Buch erfolgreich erstellt! ${result.songCount} Lieder, ${result.pageCount} Seiten.",
MessageType.SUCCESS
),
StatusMessage(
"Ausgabedatei: ${result.outputFile?.absolutePath ?: "unbekannt"}",
MessageType.INFO
)
)
} else {
result.errors.map { error ->
val location = buildString {
if (error.file != null) append(error.file)
if (error.line != null) append(":${error.line}")
}
val prefix = if (location.isNotEmpty()) "[$location] " else ""
StatusMessage("$prefix${error.message}", MessageType.ERROR)
}
}
isRunning = false
// Automatically load preview after successful build
if (result.success && result.outputFile != null) {
previewState.loadPdf(result.outputFile!!)
}
}
},
enabled = !isRunning && projectPath.isNotBlank()
) {
Text("Buch erstellen")
}
Button(
onClick = {
if (projectPath.isBlank()) return@Button
isRunning = true
lastBuildResult = null
statusMessages = listOf(StatusMessage("Validierung laeuft...", MessageType.INFO))
scope.launch {
val errors = withContext(Dispatchers.IO) {
try {
SongbookPipeline(File(projectPath)).validate()
} catch (e: Exception) {
listOf(
ValidationError(null, null, "Unerwarteter Fehler: ${e.message}")
)
}
}
statusMessages = if (errors.isEmpty()) {
listOf(StatusMessage("Validierung erfolgreich! Keine Fehler gefunden.", MessageType.SUCCESS))
} else {
errors.map { error ->
val location = buildString {
if (error.file != null) append(error.file)
if (error.line != null) append(":${error.line}")
}
val prefix = if (location.isNotEmpty()) "[$location] " else ""
StatusMessage("$prefix${error.message}", MessageType.ERROR)
}
}
isRunning = false
}
},
enabled = !isRunning && projectPath.isNotBlank()
) {
Text("Validieren")
}
if (lastBuildResult?.success == true && lastBuildResult?.outputFile != null) {
Button(
onClick = {
lastBuildResult?.outputFile?.let { file ->
try {
Desktop.getDesktop().open(file)
} catch (e: Exception) {
statusMessages = statusMessages + StatusMessage(
"PDF konnte nicht geoeffnet werden: ${e.message}",
MessageType.ERROR
)
}
}
},
enabled = !isRunning
) {
Text("PDF oeffnen")
}
// 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")
}
}
if (isRunning) {
Spacer(modifier = Modifier.width(8.dp))
CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.CenterVertically),
strokeWidth = 2.dp
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Status/log area
Text("Status:", fontWeight = FontWeight.Medium)
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
) {
val logListState = rememberLazyListState()
LazyColumn(
state = logListState,
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
) {
if (statusMessages.isEmpty()) {
item {
Text(
"Bereit.",
color = Color.Gray,
modifier = Modifier.padding(4.dp)
)
}
}
items(statusMessages) { msg ->
Text(
text = msg.text,
color = when (msg.type) {
MessageType.ERROR -> MaterialTheme.colors.error
MessageType.SUCCESS -> Color(0xFF2E7D32)
MessageType.INFO -> Color.Unspecified
},
fontSize = 13.sp,
modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp)
)
}
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
adapter = rememberScrollbarAdapter(logListState)
)
}
}
}
}
}
}
enum class MessageType {
INFO, SUCCESS, ERROR
}
data class StatusMessage(val text: String, val type: MessageType)

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

@@ -1,10 +0,0 @@
plugins {
id("songbook-conventions")
}
dependencies {
implementation(project(":model"))
testImplementation(kotlin("test"))
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
}

View File

@@ -1,13 +0,0 @@
package de.pfadfinder.songbook.layout
import java.io.File
object GapFiller {
fun findImages(directory: String): List<String> {
val dir = File(directory)
if (!dir.exists() || !dir.isDirectory) return emptyList()
return dir.listFiles { f ->
f.extension.lowercase() in listOf("png", "jpg", "jpeg")
}?.map { it.absolutePath }?.sorted() ?: emptyList()
}
}

View File

@@ -1,104 +0,0 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
class MeasurementEngine(
private val fontMetrics: FontMetrics,
private val config: BookConfig
) {
// A5 content height = 210mm - top margin - bottom margin
private val contentHeightMm: Float = 210f - config.layout.margins.top - config.layout.margins.bottom
fun measure(song: Song): MeasuredSong {
var heightMm = 0f
// Title height
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
if (song.composer != null || song.lyricist != null) {
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
val useGerman = config.layout.metadataLabels == "german"
if (useGerman && song.lyricist != null && song.composer != null && song.lyricist != song.composer) {
// Two separate lines: "Worte: ..." and "Weise: ..."
heightMm += metaLineHeight * 2
} else {
heightMm += metaLineHeight
}
}
// Key/capo line
if (song.key != null || song.capo != null) {
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
}
// Gap before sections
heightMm += 1.5f // ~4pt in mm
// Sections
for (section in song.sections) {
// Section label
if (section.label != null || section.type == SectionType.CHORUS) {
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
}
// Chorus repeat reference (no lines)
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
continue
}
// Lines in section
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 lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size)
if (hasChords) {
val chordHeight = fontMetrics.measureLineHeight(config.fonts.chords, config.fonts.chords.size)
heightMm += chordHeight + config.layout.chordLineSpacing + lyricHeight
} else {
heightMm += lyricHeight
}
heightMm += 0.35f // ~1pt gap between lines
}
}
// Verse spacing
heightMm += config.layout.verseSpacing
}
// Notes at bottom (with word-wrap estimation for multi-paragraph notes)
if (song.notes.isNotEmpty()) {
heightMm += 1.5f // gap before notes
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
// A5 content width in mm = 148 - inner margin - outer margin
val contentWidthMm = 148f - config.layout.margins.inner - config.layout.margins.outer
for ((idx, note) in song.notes.withIndex()) {
// Estimate how many wrapped lines this note paragraph needs
val noteWidthMm = fontMetrics.measureTextWidth(note, config.fonts.metadata, config.fonts.metadata.size)
val estimatedLines = maxOf(1, kotlin.math.ceil((noteWidthMm / contentWidthMm).toDouble()).toInt())
heightMm += metaLineHeight * estimatedLines
// Paragraph spacing between note paragraphs
if (idx < song.notes.size - 1) {
heightMm += metaLineHeight * 0.3f
}
}
}
// 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
return MeasuredSong(song, heightMm, pageCount)
}
}

View File

@@ -1,53 +0,0 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
import java.io.File
class PaginationEngine(private val config: BookConfig) {
fun paginate(measuredSongs: List<MeasuredSong>, tocPages: Int): List<PageContent> {
val pages = mutableListOf<PageContent>()
// Current page number (1-based, after TOC)
// TOC occupies pages 1..tocPages
// Content starts at page tocPages + 1
var currentPage = tocPages + 1
// Collect available filler images
val imageDir = File(config.images.directory)
val images = if (imageDir.exists() && imageDir.isDirectory) {
imageDir.listFiles { f -> f.extension.lowercase() in listOf("png", "jpg", "jpeg", "svg") }
?.map { it.absolutePath }
?.shuffled()
?.toMutableList()
?: mutableListOf()
} else {
mutableListOf()
}
var imageIndex = 0
for (ms in measuredSongs) {
if (ms.pageCount == 1) {
pages.add(PageContent.SongPage(ms.song, 0))
currentPage++
} else {
// 2-page song: must start on left page (even page number)
val isLeftPage = currentPage % 2 == 0
if (!isLeftPage) {
// Insert filler on the right page
if (images.isNotEmpty()) {
pages.add(PageContent.FillerImage(images[imageIndex % images.size]))
imageIndex++
} else {
pages.add(PageContent.BlankPage)
}
currentPage++
}
pages.add(PageContent.SongPage(ms.song, 0))
pages.add(PageContent.SongPage(ms.song, 1))
currentPage += 2
}
}
return pages
}
}

View File

@@ -1,52 +0,0 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
class TocGenerator(private val config: BookConfig) {
fun generate(pages: List<PageContent>, tocStartPage: Int): List<TocEntry> {
val entries = mutableListOf<TocEntry>()
val refAbbreviations = config.referenceBooks.associate { it.id to it.abbreviation }
// Map songs to their page numbers
val songPages = mutableMapOf<String, Int>() // song title -> first page number
var currentPageNum = tocStartPage
for (page in pages) {
currentPageNum++
if (page is PageContent.SongPage && page.pageIndex == 0) {
songPages[page.song.title] = currentPageNum
}
}
// Create entries for each song
for ((title, pageNumber) in songPages) {
// Find the song to get aliases and references
val song = pages.filterIsInstance<PageContent.SongPage>()
.find { it.song.title == title && it.pageIndex == 0 }?.song
?: continue
// Map references from book IDs to abbreviations
val refs = song.references.mapKeys { (bookId, _) ->
refAbbreviations[bookId] ?: bookId
}
entries.add(TocEntry(title = title, pageNumber = pageNumber, references = refs))
// Add alias entries
for (alias in song.aliases) {
entries.add(TocEntry(title = alias, pageNumber = pageNumber, isAlias = true, references = refs))
}
}
return entries.sortedBy { it.title.lowercase() }
}
fun estimateTocPages(songs: List<Song>): Int {
// Rough estimate: count total titles + aliases
val totalEntries = songs.sumOf { 1 + it.aliases.size }
// Assume ~40 entries per A5 page
val pages = (totalEntries / 40) + 1
// TOC should be even number of pages (for double-sided printing)
return if (pages % 2 == 0) pages else pages + 1
}
}

View File

@@ -1,74 +0,0 @@
package de.pfadfinder.songbook.layout
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class GapFillerTest {
@Test
fun `findImages returns empty for nonexistent directory`() {
val images = GapFiller.findImages("/nonexistent/path/to/images")
images.shouldBeEmpty()
}
@Test
fun `findImages returns empty for empty directory`() {
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-empty").toFile()
try {
val images = GapFiller.findImages(tempDir.absolutePath)
images.shouldBeEmpty()
} finally {
tempDir.deleteRecursively()
}
}
@Test
fun `findImages returns image files sorted`() {
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-images").toFile()
try {
java.io.File(tempDir, "c_image.png").writeText("fake")
java.io.File(tempDir, "a_image.jpg").writeText("fake")
java.io.File(tempDir, "b_image.jpeg").writeText("fake")
val images = GapFiller.findImages(tempDir.absolutePath)
images shouldHaveSize 3
// Should be sorted by absolute path (which means sorted by filename here)
images[0] shouldBe java.io.File(tempDir, "a_image.jpg").absolutePath
images[1] shouldBe java.io.File(tempDir, "b_image.jpeg").absolutePath
images[2] shouldBe java.io.File(tempDir, "c_image.png").absolutePath
} finally {
tempDir.deleteRecursively()
}
}
@Test
fun `findImages ignores non-image files`() {
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-nonimage").toFile()
try {
java.io.File(tempDir, "image.png").writeText("fake")
java.io.File(tempDir, "document.txt").writeText("fake")
java.io.File(tempDir, "data.json").writeText("fake")
java.io.File(tempDir, "photo.jpg").writeText("fake")
val images = GapFiller.findImages(tempDir.absolutePath)
images shouldHaveSize 2
} finally {
tempDir.deleteRecursively()
}
}
@Test
fun `findImages returns empty when directory is a file`() {
val tempFile = kotlin.io.path.createTempFile("songbook-test-file").toFile()
try {
val images = GapFiller.findImages(tempFile.absolutePath)
images.shouldBeEmpty()
} finally {
tempFile.delete()
}
}
}

View File

@@ -1,363 +0,0 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.floats.shouldBeGreaterThan
import io.kotest.matchers.floats.shouldBeLessThan
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class MeasurementEngineTest {
private val fontMetrics = StubFontMetrics()
private val config = BookConfig()
private val engine = MeasurementEngine(fontMetrics, config)
// Content height = 210 - 15 (top) - 15 (bottom) = 180mm
private val contentHeight = 210f - config.layout.margins.top - config.layout.margins.bottom
@Test
fun `simple song with one verse and no chords fits on one page`() {
val song = Song(
title = "Simple Song",
sections = listOf(
SongSection(
type = SectionType.VERSE,
label = "Verse 1",
lines = listOf(
SongLine(listOf(LineSegment(text = "This is a simple line"))),
SongLine(listOf(LineSegment(text = "Another simple line")))
)
)
)
)
val result = engine.measure(song)
result.pageCount shouldBe 1
result.song shouldBe song
result.totalHeightMm shouldBeGreaterThan 0f
result.totalHeightMm shouldBeLessThan contentHeight
}
@Test
fun `song with many sections exceeds one page`() {
// Create a song with many sections to exceed content height
val sections = (1..30).map { i ->
SongSection(
type = SectionType.VERSE,
label = "Verse $i",
lines = (1..5).map {
SongLine(
listOf(
LineSegment(chord = "Am", text = "Some "),
LineSegment(chord = "G", text = "text with chords")
)
)
}
)
}
val song = Song(title = "Long Song", sections = sections)
val result = engine.measure(song)
result.pageCount shouldBe 2
result.totalHeightMm shouldBeGreaterThan contentHeight
}
@Test
fun `font metrics is used for title measurement`() {
val song = Song(title = "Title Only")
val result = engine.measure(song)
// Title contributes: measureLineHeight(title font, 14f) * 1.5
val expectedTitleHeight = fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
// Plus gap before sections
val expectedMinHeight = expectedTitleHeight + 1.5f
result.totalHeightMm shouldBeGreaterThan (expectedMinHeight - 0.01f)
}
@Test
fun `composer and lyricist add metadata height`() {
val songWithoutMeta = Song(title = "No Meta")
val songWithMeta = Song(title = "With Meta", composer = "Bach", lyricist = "Goethe")
val heightWithout = engine.measure(songWithoutMeta).totalHeightMm
val heightWith = engine.measure(songWithMeta).totalHeightMm
val metadataLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
heightWith shouldBeGreaterThan heightWithout
// The difference should be approximately the metadata line height
val diff = heightWith - heightWithout
diff shouldBeGreaterThan (metadataLineHeight - 0.01f)
diff shouldBeLessThan (metadataLineHeight + 0.01f)
}
@Test
fun `key and capo add metadata height`() {
val songWithoutKeyCap = Song(title = "No Key")
val songWithKey = Song(title = "With Key", key = "Am")
val heightWithout = engine.measure(songWithoutKeyCap).totalHeightMm
val heightWith = engine.measure(songWithKey).totalHeightMm
heightWith shouldBeGreaterThan heightWithout
}
@Test
fun `capo alone adds metadata height`() {
val songWithout = Song(title = "No Capo")
val songWith = Song(title = "With Capo", capo = 2)
val heightWithout = engine.measure(songWithout).totalHeightMm
val heightWith = engine.measure(songWith).totalHeightMm
heightWith shouldBeGreaterThan heightWithout
}
@Test
fun `chords add extra height compared to lyrics only`() {
val songWithoutChords = Song(
title = "No Chords",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Just lyrics"))))
)
)
)
val songWithChords = Song(
title = "With Chords",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(chord = "Am", text = "With chords"))))
)
)
)
val heightWithout = engine.measure(songWithoutChords).totalHeightMm
val heightWith = engine.measure(songWithChords).totalHeightMm
heightWith shouldBeGreaterThan heightWithout
}
@Test
fun `chorus section label adds height`() {
val songWithChorus = Song(
title = "Chorus Song",
sections = listOf(
SongSection(
type = SectionType.CHORUS,
lines = listOf(SongLine(listOf(LineSegment(text = "Chorus line"))))
)
)
)
val songWithVerse = Song(
title = "Verse Song",
sections = listOf(
SongSection(
type = SectionType.VERSE,
// No label, type is VERSE - no label height added
lines = listOf(SongLine(listOf(LineSegment(text = "Verse line"))))
)
)
)
val chorusHeight = engine.measure(songWithChorus).totalHeightMm
val verseHeight = engine.measure(songWithVerse).totalHeightMm
// Chorus always gets a section label, verse without label does not
chorusHeight shouldBeGreaterThan verseHeight
}
@Test
fun `empty chorus repeat reference adds height without lines`() {
val song = Song(
title = "Repeat Song",
sections = listOf(
SongSection(
type = SectionType.CHORUS,
lines = emptyList() // chorus repeat reference
)
)
)
val result = engine.measure(song)
// Should have title + gap + chorus label height + chorus repeat height + verse spacing
result.totalHeightMm shouldBeGreaterThan 0f
}
@Test
fun `notes add height at bottom`() {
val songWithout = Song(title = "No Notes")
val songWith = Song(title = "With Notes", notes = listOf("Note 1", "Note 2"))
val heightWithout = engine.measure(songWithout).totalHeightMm
val heightWith = engine.measure(songWith).totalHeightMm
heightWith shouldBeGreaterThan heightWithout
}
@Test
fun `verse spacing is added per section`() {
val oneSectionSong = Song(
title = "One Section",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val twoSectionSong = Song(
title = "Two Sections",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
),
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val oneHeight = engine.measure(oneSectionSong).totalHeightMm
val twoHeight = engine.measure(twoSectionSong).totalHeightMm
twoHeight shouldBeGreaterThan oneHeight
}
@Test
fun `section with label adds label height`() {
val songWithLabel = Song(
title = "Labeled",
sections = listOf(
SongSection(
type = SectionType.VERSE,
label = "Verse 1",
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val songWithoutLabel = Song(
title = "Unlabeled",
sections = listOf(
SongSection(
type = SectionType.VERSE,
label = null,
lines = listOf(SongLine(listOf(LineSegment(text = "Line"))))
)
)
)
val labeledHeight = engine.measure(songWithLabel).totalHeightMm
val unlabeledHeight = engine.measure(songWithoutLabel).totalHeightMm
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

@@ -1,205 +0,0 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import kotlin.test.Test
class PaginationEngineTest {
private val config = BookConfig(images = ImagesConfig(directory = "/nonexistent/images"))
private val engine = PaginationEngine(config)
private fun song(title: String) = Song(title = title)
private fun onePage(song: Song) = MeasuredSong(song, 100f, 1)
private fun twoPage(song: Song) = MeasuredSong(song, 200f, 2)
@Test
fun `single page songs are placed sequentially`() {
val songs = listOf(
onePage(song("Song A")),
onePage(song("Song B")),
onePage(song("Song C"))
)
val pages = engine.paginate(songs, tocPages = 2)
pages shouldHaveSize 3
pages.forEach { it.shouldBeInstanceOf<PageContent.SongPage>() }
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[2] as PageContent.SongPage).song.title shouldBe "Song C"
}
@Test
fun `single page songs all have pageIndex 0`() {
val songs = listOf(
onePage(song("Song A")),
onePage(song("Song B"))
)
val pages = engine.paginate(songs, tocPages = 2)
pages.forEach {
(it as PageContent.SongPage).pageIndex shouldBe 0
}
}
@Test
fun `two page song starting on left page has no filler`() {
// tocPages = 2, so content starts at page 3 (odd/right page)
// First one-page song occupies page 3, next page is 4 (even/left)
val songs = listOf(
onePage(song("Song A")),
twoPage(song("Song B"))
)
val pages = engine.paginate(songs, tocPages = 2)
// Song A at page 3, Song B starts at page 4 (even = left)
pages shouldHaveSize 3
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
(pages[2] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
}
@Test
fun `two page song on odd page gets blank filler before it`() {
// tocPages = 2, content starts at page 3 (odd/right)
// First 2-page song needs to start on even page, so filler at page 3
val songs = listOf(
twoPage(song("Song A"))
)
val pages = engine.paginate(songs, tocPages = 2)
// Blank at page 3, Song A at pages 4-5
pages shouldHaveSize 3
pages[0].shouldBeInstanceOf<PageContent.BlankPage>()
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
(pages[2] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
}
@Test
fun `two page song after two single page songs does not need filler`() {
// tocPages = 2, content starts at page 3
// Song A at page 3, Song B at page 4, Song C (2-page) should start at page 5 (odd)
// Page 5 is odd, so it needs filler
val songs = listOf(
onePage(song("Song A")),
onePage(song("Song B")),
twoPage(song("Song C"))
)
val pages = engine.paginate(songs, tocPages = 2)
// Song A at 3, Song B at 4, filler at 5, Song C at 6-7
pages shouldHaveSize 5
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
pages[2].shouldBeInstanceOf<PageContent.BlankPage>()
(pages[3] as PageContent.SongPage).song.title shouldBe "Song C"
(pages[4] as PageContent.SongPage).song.title shouldBe "Song C"
}
@Test
fun `two consecutive two-page songs are placed correctly`() {
// tocPages = 2, content starts at page 3 (odd)
// Song A (2-page): needs even start -> filler at 3, Song A at 4-5
// Song B (2-page): next page is 6 (even/left) -> no filler, Song B at 6-7
val songs = listOf(
twoPage(song("Song A")),
twoPage(song("Song B"))
)
val pages = engine.paginate(songs, tocPages = 2)
pages shouldHaveSize 5
pages[0].shouldBeInstanceOf<PageContent.BlankPage>()
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[2] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[3] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[4] as PageContent.SongPage).song.title shouldBe "Song B"
}
@Test
fun `empty input produces empty output`() {
val pages = engine.paginate(emptyList(), tocPages = 2)
pages shouldHaveSize 0
}
@Test
fun `tocPages affects page numbering for alignment`() {
// tocPages = 3, content starts at page 4 (even/left)
// 2-page song should start directly on page 4 (even) - no filler needed
val songs = listOf(
twoPage(song("Song A"))
)
val pages = engine.paginate(songs, tocPages = 3)
// Page 4 is even -> no filler needed
pages shouldHaveSize 2
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[0] as PageContent.SongPage).pageIndex shouldBe 0
(pages[1] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).pageIndex shouldBe 1
}
@Test
fun `filler uses image when images directory exists`() {
// Create a temp directory with an image file
val tempDir = kotlin.io.path.createTempDirectory("songbook-test-images").toFile()
try {
val imageFile = java.io.File(tempDir, "filler.png")
imageFile.writeText("fake image")
val configWithImages = BookConfig(images = ImagesConfig(directory = tempDir.absolutePath))
val engineWithImages = PaginationEngine(configWithImages)
val songs = listOf(twoPage(song("Song A")))
val pages = engineWithImages.paginate(songs, tocPages = 2)
// tocPages=2, start at page 3 (odd), needs filler
pages shouldHaveSize 3
val filler = pages[0]
filler.shouldBeInstanceOf<PageContent.FillerImage>()
(filler as PageContent.FillerImage).imagePath shouldBe imageFile.absolutePath
} finally {
tempDir.deleteRecursively()
}
}
@Test
fun `mixed single and two-page songs layout correctly`() {
// tocPages = 4, content starts at page 5 (odd)
val songs = listOf(
onePage(song("Song A")), // page 5
twoPage(song("Song B")), // starts page 6 (even) - no filler
onePage(song("Song C")), // page 8
onePage(song("Song D")), // page 9
twoPage(song("Song E")) // starts page 10 (even) - no filler
)
val pages = engine.paginate(songs, tocPages = 4)
pages shouldHaveSize 7
(pages[0] as PageContent.SongPage).song.title shouldBe "Song A"
(pages[1] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[1] as PageContent.SongPage).pageIndex shouldBe 0
(pages[2] as PageContent.SongPage).song.title shouldBe "Song B"
(pages[2] as PageContent.SongPage).pageIndex shouldBe 1
(pages[3] as PageContent.SongPage).song.title shouldBe "Song C"
(pages[4] as PageContent.SongPage).song.title shouldBe "Song D"
(pages[5] as PageContent.SongPage).song.title shouldBe "Song E"
(pages[5] as PageContent.SongPage).pageIndex shouldBe 0
(pages[6] as PageContent.SongPage).song.title shouldBe "Song E"
(pages[6] as PageContent.SongPage).pageIndex shouldBe 1
}
}

View File

@@ -1,12 +0,0 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.FontMetrics
import de.pfadfinder.songbook.model.FontSpec
class StubFontMetrics : FontMetrics {
override fun measureTextWidth(text: String, font: FontSpec, size: Float): Float =
text.length * size * 0.5f * 0.3528f
override fun measureLineHeight(font: FontSpec, size: Float): Float =
size * 1.2f * 0.3528f
}

View File

@@ -1,211 +0,0 @@
package de.pfadfinder.songbook.layout
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class TocGeneratorTest {
private val config = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO"),
ReferenceBook(id = "kljb", name = "KLJB Liederbuch", abbreviation = "KLJB")
)
)
private val generator = TocGenerator(config)
@Test
fun `generate creates entries for songs sorted alphabetically`() {
val pages = listOf(
PageContent.SongPage(Song(title = "Zebra Song"), 0),
PageContent.SongPage(Song(title = "Alpha Song"), 0),
PageContent.SongPage(Song(title = "Middle Song"), 0)
)
val entries = generator.generate(pages, tocStartPage = 0)
entries shouldHaveSize 3
entries[0].title shouldBe "Alpha Song"
entries[1].title shouldBe "Middle Song"
entries[2].title shouldBe "Zebra Song"
}
@Test
fun `generate assigns correct page numbers`() {
val pages = listOf(
PageContent.SongPage(Song(title = "Song A"), 0), // page 1
PageContent.SongPage(Song(title = "Song B"), 0), // page 2
PageContent.SongPage(Song(title = "Song C"), 0) // page 3
)
val entries = generator.generate(pages, tocStartPage = 0)
entries.find { it.title == "Song A" }!!.pageNumber shouldBe 1
entries.find { it.title == "Song B" }!!.pageNumber shouldBe 2
entries.find { it.title == "Song C" }!!.pageNumber shouldBe 3
}
@Test
fun `generate with tocStartPage offsets page numbers`() {
val pages = listOf(
PageContent.SongPage(Song(title = "Song A"), 0)
)
val entries = generator.generate(pages, tocStartPage = 4)
entries[0].pageNumber shouldBe 5
}
@Test
fun `generate creates alias entries`() {
val song = Song(title = "Original Title", aliases = listOf("Alias One", "Alias Two"))
val pages = listOf(
PageContent.SongPage(song, 0)
)
val entries = generator.generate(pages, tocStartPage = 0)
entries shouldHaveSize 3
// Sorted: Alias One, Alias Two, Original Title
entries[0].title shouldBe "Alias One"
entries[0].isAlias shouldBe true
entries[0].pageNumber shouldBe 1
entries[1].title shouldBe "Alias Two"
entries[1].isAlias shouldBe true
entries[1].pageNumber shouldBe 1
entries[2].title shouldBe "Original Title"
entries[2].isAlias shouldBe false
entries[2].pageNumber shouldBe 1
}
@Test
fun `generate maps reference book IDs to abbreviations`() {
val song = Song(
title = "Referenced Song",
references = mapOf("mundorgel" to 42, "kljb" to 117)
)
val pages = listOf(PageContent.SongPage(song, 0))
val entries = generator.generate(pages, tocStartPage = 0)
entries shouldHaveSize 1
entries[0].references shouldBe mapOf("MO" to 42, "KLJB" to 117)
}
@Test
fun `generate keeps unknown reference book IDs as-is`() {
val song = Song(
title = "Song",
references = mapOf("unknown_book" to 5)
)
val pages = listOf(PageContent.SongPage(song, 0))
val entries = generator.generate(pages, tocStartPage = 0)
entries[0].references shouldBe mapOf("unknown_book" to 5)
}
@Test
fun `generate skips filler and blank pages for page numbering`() {
val pages = listOf(
PageContent.BlankPage, // page 1
PageContent.SongPage(Song(title = "Song A"), 0), // page 2
PageContent.FillerImage("/path/to/image.png"), // page 3
PageContent.SongPage(Song(title = "Song B"), 0) // page 4
)
val entries = generator.generate(pages, tocStartPage = 0)
entries shouldHaveSize 2
entries.find { it.title == "Song A" }!!.pageNumber shouldBe 2
entries.find { it.title == "Song B" }!!.pageNumber shouldBe 4
}
@Test
fun `generate handles two-page songs correctly`() {
val song = Song(title = "Long Song")
val pages = listOf(
PageContent.SongPage(song, 0), // page 1 - first page of song
PageContent.SongPage(song, 1) // page 2 - second page of song
)
val entries = generator.generate(pages, tocStartPage = 0)
// Should only have one entry pointing to the first page
entries shouldHaveSize 1
entries[0].title shouldBe "Long Song"
entries[0].pageNumber shouldBe 1
}
@Test
fun `generate aliases share references with original song`() {
val song = Song(
title = "Main Song",
aliases = listOf("Alt Name"),
references = mapOf("mundorgel" to 10)
)
val pages = listOf(PageContent.SongPage(song, 0))
val entries = generator.generate(pages, tocStartPage = 0)
entries shouldHaveSize 2
val alias = entries.find { it.isAlias }!!
alias.references shouldBe mapOf("MO" to 10)
val main = entries.find { !it.isAlias }!!
main.references shouldBe mapOf("MO" to 10)
}
@Test
fun `generate with empty pages produces empty entries`() {
val entries = generator.generate(emptyList(), tocStartPage = 0)
entries.shouldBeEmpty()
}
@Test
fun `estimateTocPages returns even number`() {
val songs = (1..10).map { Song(title = "Song $it") }
val pages = generator.estimateTocPages(songs)
(pages % 2) shouldBe 0
}
@Test
fun `estimateTocPages accounts for aliases`() {
val songsWithoutAliases = (1..10).map { Song(title = "Song $it") }
val songsWithAliases = (1..10).map { Song(title = "Song $it", aliases = listOf("Alias $it")) }
val pagesWithout = generator.estimateTocPages(songsWithoutAliases)
val pagesWith = generator.estimateTocPages(songsWithAliases)
pagesWith shouldBe pagesWithout // both under 40 entries, same page count
}
@Test
fun `estimateTocPages with many songs returns more pages`() {
val fewSongs = (1..10).map { Song(title = "Song $it") }
val manySongs = (1..200).map { Song(title = "Song $it") }
val fewPages = generator.estimateTocPages(fewSongs)
val manyPages = generator.estimateTocPages(manySongs)
// 200 songs / 40 per page = 5 + 1 = 6 pages (already even)
manyPages shouldBe 6
fewPages shouldBe 2 // (10/40)+1 = 1, rounded up to 2 for even
}
@Test
fun `generate sorts case-insensitively`() {
val pages = listOf(
PageContent.SongPage(Song(title = "banana"), 0),
PageContent.SongPage(Song(title = "Apple"), 0),
PageContent.SongPage(Song(title = "cherry"), 0)
)
val entries = generator.generate(pages, tocStartPage = 0)
entries[0].title shouldBe "Apple"
entries[1].title shouldBe "banana"
entries[2].title shouldBe "cherry"
}
}

View File

@@ -1,3 +0,0 @@
plugins {
id("songbook-conventions")
}

View File

@@ -1,84 +0,0 @@
package de.pfadfinder.songbook.model
data class BookConfig(
val book: BookMeta = BookMeta(),
val songs: SongsConfig = SongsConfig(),
val fonts: FontsConfig = FontsConfig(),
val layout: LayoutConfig = LayoutConfig(),
val images: ImagesConfig = ImagesConfig(),
val referenceBooks: List<ReferenceBook> = emptyList(),
val output: OutputConfig = OutputConfig(),
val foreword: ForewordConfig? = null,
val toc: TocConfig = TocConfig(),
val intro: IntroConfig? = null
)
data class IntroConfig(
val enabled: Boolean = true
)
data class TocConfig(
val highlightColumn: String? = null // abbreviation of the column to highlight (e.g. "CL")
)
data class ForewordConfig(
val file: String = "./foreword.txt"
)
data class BookMeta(
val title: String = "Liederbuch",
val subtitle: String? = null,
val edition: String? = null,
val format: String = "A5"
)
data class SongsConfig(
val directory: String = "./songs",
val order: String = "alphabetical" // "alphabetical" or "manual"
)
data class FontsConfig(
val lyrics: FontSpec = FontSpec(family = "Helvetica", size = 10f),
val chords: FontSpec = FontSpec(family = "Helvetica", size = 9f, color = "#333333"),
val title: FontSpec = FontSpec(family = "Helvetica", size = 14f),
val metadata: FontSpec = FontSpec(family = "Helvetica", size = 8f),
val toc: FontSpec = FontSpec(family = "Helvetica", size = 9f)
)
data class FontSpec(
val family: String = "Helvetica",
val file: String? = null,
val size: Float = 10f,
val color: String = "#000000"
)
data class LayoutConfig(
val margins: Margins = Margins(),
val chordLineSpacing: Float = 1f, // mm gap between chord line and lyrics text
val verseSpacing: Float = 6f, // mm gap between consecutive song sections
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(
val top: Float = 15f,
val bottom: Float = 15f,
val inner: Float = 20f,
val outer: Float = 12f
)
data class ImagesConfig(
val directory: String = "./images"
)
data class ReferenceBook(
val id: String,
val name: String,
val abbreviation: String
)
data class OutputConfig(
val directory: String = "./output",
val filename: String = "liederbuch.pdf"
)

View File

@@ -1,7 +0,0 @@
package de.pfadfinder.songbook.model
import java.io.OutputStream
interface BookRenderer {
fun render(layout: LayoutResult, config: BookConfig, output: OutputStream)
}

View File

@@ -1,6 +0,0 @@
package de.pfadfinder.songbook.model
interface FontMetrics {
fun measureTextWidth(text: String, font: FontSpec, size: Float): Float
fun measureLineHeight(font: FontSpec, size: Float): Float
}

View File

@@ -1,7 +0,0 @@
package de.pfadfinder.songbook.model
data class Foreword(
val quote: String? = null,
val paragraphs: List<String> = emptyList(),
val signatures: List<String> = emptyList()
)

View File

@@ -1,28 +0,0 @@
package de.pfadfinder.songbook.model
data class MeasuredSong(
val song: Song,
val totalHeightMm: Float,
val pageCount: Int // 1 or 2
)
sealed class PageContent {
data class SongPage(val song: Song, val pageIndex: Int) : PageContent() // pageIndex 0 or 1 for 2-page songs
data class FillerImage(val imagePath: String) : PageContent()
data object BlankPage : PageContent()
data class ForewordPage(val foreword: Foreword, val pageIndex: Int) : PageContent() // pageIndex 0 or 1 for multi-page forewords
}
data class LayoutResult(
val introPages: Int = 0,
val tocPages: Int,
val pages: List<PageContent>,
val tocEntries: List<TocEntry>
)
data class TocEntry(
val title: String,
val pageNumber: Int,
val isAlias: Boolean = false,
val references: Map<String, Int> = emptyMap() // bookAbbrev → page
)

View File

@@ -1,34 +0,0 @@
package de.pfadfinder.songbook.model
data class Song(
val title: String,
val aliases: List<String> = emptyList(),
val lyricist: String? = null,
val composer: String? = null,
val key: String? = null,
val tags: List<String> = emptyList(),
val notes: List<String> = emptyList(),
val references: Map<String, Int> = emptyMap(), // bookId → page number
val capo: Int? = null,
val sections: List<SongSection> = emptyList()
)
data class SongSection(
val type: SectionType,
val label: String? = null,
val lines: List<SongLine> = emptyList()
)
enum class SectionType {
VERSE, CHORUS, BRIDGE, REPEAT
}
data class SongLine(
val segments: List<LineSegment> = emptyList(),
val imagePath: String? = null // when non-null, this "line" is an inline image (segments ignored)
)
data class LineSegment(
val chord: String? = null, // null = no chord above this segment
val text: String
)

View File

@@ -1,13 +0,0 @@
plugins {
id("songbook-conventions")
}
dependencies {
implementation(project(":model"))
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.3")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.3")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3")
testImplementation(kotlin("test"))
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
}

View File

@@ -1,256 +0,0 @@
package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.*
import java.io.File
object ChordProParser {
fun parse(input: String): Song {
val lines = input.lines()
var title: String? = null
val aliases = mutableListOf<String>()
var lyricist: String? = null
var composer: String? = null
var key: String? = null
val tags = mutableListOf<String>()
val notes = mutableListOf<String>()
val references = mutableMapOf<String, Int>()
var capo: Int? = null
val sections = mutableListOf<SongSection>()
// Current section being built
var currentType: SectionType? = null
var currentLabel: String? = null
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() {
if (currentType != null) {
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
currentType = null
currentLabel = null
currentLines = mutableListOf()
explicitSection = false
}
}
for (rawLine in lines) {
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
if (line.trimStart().startsWith("#")) continue
// Blank line: flush implicit sections, skip otherwise
if (line.isBlank()) {
if (currentType != null && !explicitSection) {
flushSection()
}
continue
}
// Directive line
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
val inner = line.trim().removePrefix("{").removeSuffix("}").trim()
val colonIndex = inner.indexOf(':')
val directive: String
val value: String?
if (colonIndex >= 0) {
directive = inner.substring(0, colonIndex).trim().lowercase()
value = inner.substring(colonIndex + 1).trim()
} else {
directive = inner.trim().lowercase()
value = null
}
when (directive) {
"title", "t" -> title = value
"alias" -> if (value != null) aliases.add(value)
"lyricist" -> lyricist = value
"composer" -> composer = value
"key" -> key = value
"tags" -> if (value != null) {
tags.addAll(value.split(",").map { it.trim() }.filter { it.isNotEmpty() })
}
"note" -> if (value != null) notes.add(value)
"capo" -> capo = value?.toIntOrNull()
"ref" -> if (value != null) {
parseReference(value)?.let { (bookId, page) ->
references[bookId] = page
}
}
"start_of_verse", "sov" -> {
flushSection()
currentType = SectionType.VERSE
currentLabel = value
explicitSection = true
}
"end_of_verse", "eov" -> {
flushSection()
}
"start_of_chorus", "soc" -> {
flushSection()
currentType = SectionType.CHORUS
currentLabel = value
explicitSection = true
}
"end_of_chorus", "eoc" -> {
flushSection()
}
"start_of_repeat", "sor" -> {
flushSection()
currentType = SectionType.REPEAT
currentLabel = value
explicitSection = true
}
"end_of_repeat", "eor" -> {
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" -> {
flushSection()
sections.add(SongSection(type = SectionType.CHORUS))
}
"repeat" -> {
// Store repeat count as label on current section or create a new section
if (currentType != null) {
currentLabel = value
}
}
}
continue
}
// Text/chord line: if we're not inside a section, start an implicit VERSE
if (currentType == null) {
currentType = SectionType.VERSE
}
val songLine = parseChordLine(line)
currentLines.add(songLine)
}
// Flush any remaining section
flushSection()
return Song(
title = title ?: "",
aliases = aliases.toList(),
lyricist = lyricist,
composer = composer,
key = key,
tags = tags.toList(),
notes = notes.toList(),
references = references.toMap(),
capo = capo,
sections = sections.toList()
)
}
fun parseFile(file: File): Song = parse(file.readText())
internal fun parseChordLine(line: String): SongLine {
val segments = mutableListOf<LineSegment>()
var i = 0
val len = line.length
// Check if line starts with text before any chord
if (len > 0 && line[0] != '[') {
val nextBracket = line.indexOf('[')
if (nextBracket < 0) {
// No chords at all, entire line is text
segments.add(LineSegment(chord = null, text = line))
return SongLine(segments)
}
segments.add(LineSegment(chord = null, text = line.substring(0, nextBracket)))
i = nextBracket
}
while (i < len) {
if (line[i] == '[') {
val closeBracket = line.indexOf(']', i)
if (closeBracket < 0) {
// Malformed: treat rest as text
segments.add(LineSegment(chord = null, text = line.substring(i)))
break
}
val chord = line.substring(i + 1, closeBracket)
val textStart = closeBracket + 1
val nextBracket = line.indexOf('[', textStart)
val text = if (nextBracket < 0) {
line.substring(textStart)
} else {
line.substring(textStart, nextBracket)
}
segments.add(LineSegment(chord = chord, text = text))
i = if (nextBracket < 0) len else nextBracket
} else {
// Should not happen if logic is correct, but handle gracefully
val nextBracket = line.indexOf('[', i)
if (nextBracket < 0) {
segments.add(LineSegment(chord = null, text = line.substring(i)))
break
}
segments.add(LineSegment(chord = null, text = line.substring(i, nextBracket)))
i = nextBracket
}
}
return SongLine(segments)
}
internal fun parseReference(value: String): Pair<String, Int>? {
val parts = value.trim().split("\\s+".toRegex())
if (parts.size < 2) return null
val page = parts.last().toIntOrNull() ?: return null
val bookId = parts.dropLast(1).joinToString(" ")
return bookId to page
}
}

View File

@@ -1,25 +0,0 @@
package de.pfadfinder.songbook.parser
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import de.pfadfinder.songbook.model.BookConfig
import java.io.File
object ConfigParser {
private val mapper: ObjectMapper = ObjectMapper(YAMLFactory())
.registerKotlinModule()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun parse(file: File): BookConfig {
return mapper.readValue(file, BookConfig::class.java)
}
fun parse(input: String): BookConfig {
return mapper.readValue(input, BookConfig::class.java)
}
}

View File

@@ -1,96 +0,0 @@
package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.Foreword
import java.io.File
object ForewordParser {
/**
* Parses a foreword text file into a [Foreword] object.
*
* Format:
* - Lines starting with `> ` are collected as the quote (multiple lines joined)
* - `---` is a horizontal rule separator (marks end of quote section)
* - Lines starting with `-- ` are signatures
* - Other non-blank lines are body text; blank lines separate paragraphs
*/
fun parse(input: String): Foreword {
val lines = input.lines()
val quoteLines = mutableListOf<String>()
val signatures = mutableListOf<String>()
val paragraphs = mutableListOf<String>()
var currentParagraph = StringBuilder()
var inQuote = true // Start assuming we might be in the quote section
var foundSeparator = false
for (rawLine in lines) {
val line = rawLine.trimEnd()
// Quote lines (before separator)
if (!foundSeparator && line.trimStart().startsWith("> ")) {
quoteLines.add(line.trimStart().removePrefix("> "))
continue
}
// If we had quote lines but now see non-quote content before separator,
// the quote section is done
if (!foundSeparator && quoteLines.isNotEmpty() && line.trimStart().isNotEmpty() && !line.trimStart().startsWith("> ")) {
if (line.trim() == "---") {
foundSeparator = true
inQuote = false
continue
}
}
// Separator line
if (line.trim() == "---") {
foundSeparator = true
inQuote = false
continue
}
// Signature lines
if (line.trimStart().startsWith("-- ")) {
signatures.add(line.trimStart().removePrefix("-- "))
continue
}
// Skip quote processing after we established there are no quotes
if (inQuote && quoteLines.isEmpty() && line.isBlank()) {
continue
}
inQuote = false
// Body paragraphs
if (line.isBlank()) {
if (currentParagraph.isNotEmpty()) {
paragraphs.add(currentParagraph.toString().trim())
currentParagraph = StringBuilder()
}
} else {
if (currentParagraph.isNotEmpty()) {
currentParagraph.append(" ")
}
currentParagraph.append(line.trim())
}
}
// Flush remaining paragraph
if (currentParagraph.isNotEmpty()) {
paragraphs.add(currentParagraph.toString().trim())
}
val quote = if (quoteLines.isNotEmpty()) quoteLines.joinToString(" ") else null
return Foreword(
quote = quote,
paragraphs = paragraphs,
signatures = signatures
)
}
fun parseFile(file: File): Foreword = parse(file.readText())
}

View File

@@ -1,78 +0,0 @@
package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.BookConfig
import de.pfadfinder.songbook.model.FontSpec
import de.pfadfinder.songbook.model.Song
import java.io.File
data class ValidationError(val file: String?, val line: Int?, val message: String)
object Validator {
fun validateSong(song: Song, fileName: String? = null): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
if (song.title.isBlank()) {
errors.add(ValidationError(file = fileName, line = null, message = "Song must have a title"))
}
if (song.sections.isEmpty()) {
errors.add(ValidationError(file = fileName, line = null, message = "Song must have at least one section"))
}
return errors
}
fun validateSong(song: Song, config: BookConfig, fileName: String? = null): List<ValidationError> {
val errors = validateSong(song, fileName).toMutableList()
val knownBookIds = config.referenceBooks.map { it.id }.toSet()
for ((bookId, _) in song.references) {
if (bookId !in knownBookIds) {
errors.add(
ValidationError(
file = fileName,
line = null,
message = "Reference to unknown book '$bookId'. Known books: ${knownBookIds.joinToString(", ")}"
)
)
}
}
return errors
}
fun validateConfig(config: BookConfig): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
with(config.layout.margins) {
if (top <= 0) errors.add(ValidationError(file = null, line = null, message = "Top margin must be greater than 0"))
if (bottom <= 0) errors.add(ValidationError(file = null, line = null, message = "Bottom margin must be greater than 0"))
if (inner <= 0) errors.add(ValidationError(file = null, line = null, message = "Inner 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
}
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

@@ -1,772 +0,0 @@
package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.SectionType
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import kotlin.test.Test
class ChordProParserTest {
@Test
fun `parse complete song`() {
val input = """
# This is a comment
{title: Wonderwall}
{alias: Wonderwall (Oasis)}
{lyricist: Noel Gallagher}
{composer: Noel Gallagher}
{key: F#m}
{tags: pop, rock, 90s}
{note: Play with capo on 2nd fret}
{ref: mundorgel 42}
{capo: 2}
{start_of_verse: Verse 1}
[Em7]Today is [G]gonna be the day
That they're [Dsus4]gonna throw it back to [A7sus4]you
{end_of_verse}
{start_of_chorus}
[C]And all the [D]roads we have to [Em]walk are winding
{end_of_chorus}
{chorus}
""".trimIndent()
val song = ChordProParser.parse(input)
song.title shouldBe "Wonderwall"
song.aliases shouldHaveSize 1
song.aliases[0] shouldBe "Wonderwall (Oasis)"
song.lyricist shouldBe "Noel Gallagher"
song.composer shouldBe "Noel Gallagher"
song.key shouldBe "F#m"
song.tags shouldBe listOf("pop", "rock", "90s")
song.notes shouldHaveSize 1
song.notes[0] shouldBe "Play with capo on 2nd fret"
song.references shouldBe mapOf("mundorgel" to 42)
song.capo shouldBe 2
song.sections shouldHaveSize 3
// Verse 1
val verse = song.sections[0]
verse.type shouldBe SectionType.VERSE
verse.label shouldBe "Verse 1"
verse.lines shouldHaveSize 2
// First line of verse
val firstLine = verse.lines[0]
firstLine.segments shouldHaveSize 2
firstLine.segments[0].chord shouldBe "Em7"
firstLine.segments[0].text shouldBe "Today is "
firstLine.segments[1].chord shouldBe "G"
firstLine.segments[1].text shouldBe "gonna be the day"
// Chorus
val chorus = song.sections[1]
chorus.type shouldBe SectionType.CHORUS
chorus.label.shouldBeNull()
chorus.lines shouldHaveSize 1
// Empty chorus reference
val chorusRef = song.sections[2]
chorusRef.type shouldBe SectionType.CHORUS
chorusRef.lines.shouldBeEmpty()
}
@Test
fun `parse title directive`() {
val input = "{title: My Song}"
val song = ChordProParser.parse(input)
song.title shouldBe "My Song"
}
@Test
fun `parse short title directive`() {
val input = "{t: My Song}"
val song = ChordProParser.parse(input)
song.title shouldBe "My Song"
}
@Test
fun `parse missing title results in empty string`() {
val input = """
{start_of_verse}
Hello world
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.title shouldBe ""
}
@Test
fun `comments are skipped`() {
val input = """
{title: Test}
# This is a comment
{start_of_verse}
Hello world
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.title shouldBe "Test"
song.sections shouldHaveSize 1
song.sections[0].lines shouldHaveSize 1
song.sections[0].lines[0].segments[0].text shouldBe "Hello world"
}
@Test
fun `parse chord line with no chords`() {
val line = ChordProParser.parseChordLine("Just plain text")
line.segments shouldHaveSize 1
line.segments[0].chord.shouldBeNull()
line.segments[0].text shouldBe "Just plain text"
}
@Test
fun `parse chord line starting with chord`() {
val line = ChordProParser.parseChordLine("[Am]Hello [C]World")
line.segments shouldHaveSize 2
line.segments[0].chord shouldBe "Am"
line.segments[0].text shouldBe "Hello "
line.segments[1].chord shouldBe "C"
line.segments[1].text shouldBe "World"
}
@Test
fun `parse chord line starting with text`() {
val line = ChordProParser.parseChordLine("Hello [Am]World")
line.segments shouldHaveSize 2
line.segments[0].chord.shouldBeNull()
line.segments[0].text shouldBe "Hello "
line.segments[1].chord shouldBe "Am"
line.segments[1].text shouldBe "World"
}
@Test
fun `parse chord line with chord at end`() {
val line = ChordProParser.parseChordLine("[Am]Hello [C]")
line.segments shouldHaveSize 2
line.segments[0].chord shouldBe "Am"
line.segments[0].text shouldBe "Hello "
line.segments[1].chord shouldBe "C"
line.segments[1].text shouldBe ""
}
@Test
fun `parse chord line with only chord`() {
val line = ChordProParser.parseChordLine("[Am]")
line.segments shouldHaveSize 1
line.segments[0].chord shouldBe "Am"
line.segments[0].text shouldBe ""
}
@Test
fun `parse multiple aliases`() {
val input = """
{title: Song}
{alias: Alias One}
{alias: Alias Two}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.aliases shouldBe listOf("Alias One", "Alias Two")
}
@Test
fun `parse reference with multi-word book name`() {
val ref = ChordProParser.parseReference("My Big Songbook 123")
ref.shouldNotBeNull()
ref.first shouldBe "My Big Songbook"
ref.second shouldBe 123
}
@Test
fun `parse reference with single word book name`() {
val ref = ChordProParser.parseReference("mundorgel 42")
ref.shouldNotBeNull()
ref.first shouldBe "mundorgel"
ref.second shouldBe 42
}
@Test
fun `parse reference with invalid page returns null`() {
val ref = ChordProParser.parseReference("mundorgel abc")
ref.shouldBeNull()
}
@Test
fun `parse reference with only one token returns null`() {
val ref = ChordProParser.parseReference("mundorgel")
ref.shouldBeNull()
}
@Test
fun `parse tags directive`() {
val input = """
{title: Song}
{tags: folk, german, campfire}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.tags shouldBe listOf("folk", "german", "campfire")
}
@Test
fun `parse tags with extra whitespace`() {
val input = """
{title: Song}
{tags: folk , german , campfire }
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.tags shouldBe listOf("folk", "german", "campfire")
}
@Test
fun `parse chorus directive creates empty section`() {
val input = """
{title: Song}
{start_of_chorus}
[C]La la [G]la
{end_of_chorus}
{chorus}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 2
song.sections[0].type shouldBe SectionType.CHORUS
song.sections[0].lines shouldHaveSize 1
song.sections[1].type shouldBe SectionType.CHORUS
song.sections[1].lines.shouldBeEmpty()
}
@Test
fun `parse capo directive`() {
val input = """
{title: Song}
{capo: 3}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.capo shouldBe 3
}
@Test
fun `parse capo with invalid value results in null`() {
val input = """
{title: Song}
{capo: abc}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.capo.shouldBeNull()
}
@Test
fun `parse repeat section`() {
val input = """
{title: Song}
{start_of_repeat: 2x}
[Am]La la la
{end_of_repeat}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.REPEAT
song.sections[0].label shouldBe "2x"
song.sections[0].lines shouldHaveSize 1
}
@Test
fun `implicit verse for lines outside sections`() {
val input = """
{title: Song}
[Am]Hello [C]World
Just text
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].lines shouldHaveSize 2
}
@Test
fun `multiple sections parsed correctly`() {
val input = """
{title: Song}
{start_of_verse: 1}
Line one
{end_of_verse}
{start_of_verse: 2}
Line two
{end_of_verse}
{start_of_chorus}
Chorus line
{end_of_chorus}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 3
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].label shouldBe "1"
song.sections[1].type shouldBe SectionType.VERSE
song.sections[1].label shouldBe "2"
song.sections[2].type shouldBe SectionType.CHORUS
}
@Test
fun `parse multiple notes`() {
val input = """
{title: Song}
{note: First note}
{note: Second note}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldBe listOf("First note", "Second note")
}
@Test
fun `parse multiple references`() {
val input = """
{title: Song}
{ref: mundorgel 42}
{ref: pfadfinderlied 17}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.references shouldBe mapOf("mundorgel" to 42, "pfadfinderlied" to 17)
}
@Test
fun `parse key directive`() {
val input = """
{title: Song}
{key: Am}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.key shouldBe "Am"
}
@Test
fun `empty input produces song with empty title and no sections`() {
val song = ChordProParser.parse("")
song.title shouldBe ""
song.sections.shouldBeEmpty()
}
@Test
fun `malformed chord bracket treated as text`() {
val line = ChordProParser.parseChordLine("[Am broken text")
line.segments shouldHaveSize 1
line.segments[0].chord.shouldBeNull()
line.segments[0].text shouldBe "[Am broken text"
}
@Test
fun `repeat directive sets label on current section`() {
val input = """
{title: Song}
{start_of_verse}
Line one
{repeat: 3}
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].label shouldBe "3"
}
@Test
fun `parse short directives sov eov soc eoc`() {
val input = """
{title: Song}
{sov: V1}
Line one
{eov}
{soc}
Chorus
{eoc}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 2
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].label shouldBe "V1"
song.sections[1].type shouldBe SectionType.CHORUS
}
@Test
fun `parse short directives sor eor`() {
val input = """
{title: Song}
{sor: 2x}
Repeat line
{eor}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.REPEAT
song.sections[0].label shouldBe "2x"
}
@Test
fun `section without explicit end is flushed at end of input`() {
val input = """
{title: Song}
{start_of_verse}
Line one
Line two
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].lines shouldHaveSize 2
}
@Test
fun `section flushed when new section starts without end directive`() {
val input = """
{title: Song}
{start_of_verse: 1}
Line one
{start_of_verse: 2}
Line two
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 2
song.sections[0].label shouldBe "1"
song.sections[0].lines shouldHaveSize 1
song.sections[1].label shouldBe "2"
song.sections[1].lines shouldHaveSize 1
}
@Test
fun `lyricist and composer directives`() {
val input = """
{title: Song}
{lyricist: John Doe}
{composer: Jane Smith}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.lyricist shouldBe "John Doe"
song.composer shouldBe "Jane Smith"
}
@Test
fun `parse consecutive chords with no text between`() {
val line = ChordProParser.parseChordLine("[Am][C][G]End")
line.segments shouldHaveSize 3
line.segments[0].chord shouldBe "Am"
line.segments[0].text shouldBe ""
line.segments[1].chord shouldBe "C"
line.segments[1].text shouldBe ""
line.segments[2].chord shouldBe "G"
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

@@ -1,271 +0,0 @@
package de.pfadfinder.songbook.parser
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class ConfigParserTest {
private val sampleYaml = """
book:
title: "Pfadfinder Liederbuch"
subtitle: "Ausgabe 2024"
edition: "3. Auflage"
format: A5
songs:
directory: "./songs"
order: alphabetical
fonts:
lyrics: { family: "Garamond", file: "./fonts/Garamond.ttf", size: 10 }
chords: { family: "Garamond", file: "./fonts/Garamond-Bold.ttf", size: 9, color: "#333333" }
title: { family: "Garamond", file: "./fonts/Garamond-Bold.ttf", size: 14 }
metadata: { family: "Garamond", file: "./fonts/Garamond-Italic.ttf", size: 8 }
toc: { family: "Garamond", file: "./fonts/Garamond.ttf", size: 9 }
layout:
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
chord_line_spacing: 3
verse_spacing: 4
page_number_position: bottom-outer
images:
directory: "./images"
reference_books:
- id: mundorgel
name: "Mundorgel"
abbreviation: "MO"
- id: pfadfinderlied
name: "Das Pfadfinderlied"
abbreviation: "PL"
output:
directory: "./output"
filename: "liederbuch.pdf"
""".trimIndent()
@Test
fun `parse full config from yaml string`() {
val config = ConfigParser.parse(sampleYaml)
// Book meta
config.book.title shouldBe "Pfadfinder Liederbuch"
config.book.subtitle shouldBe "Ausgabe 2024"
config.book.edition shouldBe "3. Auflage"
config.book.format shouldBe "A5"
// Songs config
config.songs.directory shouldBe "./songs"
config.songs.order shouldBe "alphabetical"
// Fonts
config.fonts.lyrics.family shouldBe "Garamond"
config.fonts.lyrics.file shouldBe "./fonts/Garamond.ttf"
config.fonts.lyrics.size shouldBe 10f
config.fonts.lyrics.color shouldBe "#000000" // default
config.fonts.chords.family shouldBe "Garamond"
config.fonts.chords.file shouldBe "./fonts/Garamond-Bold.ttf"
config.fonts.chords.size shouldBe 9f
config.fonts.chords.color shouldBe "#333333"
config.fonts.title.family shouldBe "Garamond"
config.fonts.title.size shouldBe 14f
config.fonts.metadata.family shouldBe "Garamond"
config.fonts.metadata.size shouldBe 8f
config.fonts.toc.family shouldBe "Garamond"
config.fonts.toc.size shouldBe 9f
// Layout
config.layout.margins.top shouldBe 15f
config.layout.margins.bottom shouldBe 15f
config.layout.margins.inner shouldBe 20f
config.layout.margins.outer shouldBe 12f
config.layout.chordLineSpacing shouldBe 3f
config.layout.verseSpacing shouldBe 4f
config.layout.pageNumberPosition shouldBe "bottom-outer"
// Images
config.images.directory shouldBe "./images"
// Reference books
config.referenceBooks shouldHaveSize 2
config.referenceBooks[0].id shouldBe "mundorgel"
config.referenceBooks[0].name shouldBe "Mundorgel"
config.referenceBooks[0].abbreviation shouldBe "MO"
config.referenceBooks[1].id shouldBe "pfadfinderlied"
config.referenceBooks[1].name shouldBe "Das Pfadfinderlied"
config.referenceBooks[1].abbreviation shouldBe "PL"
// Output
config.output.directory shouldBe "./output"
config.output.filename shouldBe "liederbuch.pdf"
}
@Test
fun `parse minimal config uses defaults`() {
val yaml = """
book:
title: "Minimal"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.book.title shouldBe "Minimal"
config.book.format shouldBe "A5" // default
config.songs.directory shouldBe "./songs" // default
config.fonts.lyrics.family shouldBe "Helvetica" // default
config.layout.margins.top shouldBe 15f // default
config.output.filename shouldBe "liederbuch.pdf" // default
}
@Test
fun `parse config with only book section`() {
val yaml = """
book:
title: "Test"
subtitle: "Sub"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.book.title shouldBe "Test"
config.book.subtitle shouldBe "Sub"
config.book.edition shouldBe null
}
@Test
fun `parse config with reference books`() {
val yaml = """
book:
title: "Test"
reference_books:
- id: mo
name: "Mundorgel"
abbreviation: "MO"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.referenceBooks shouldHaveSize 1
config.referenceBooks[0].id shouldBe "mo"
}
@Test
fun `parse config with custom layout margins`() {
val yaml = """
book:
title: "Test"
layout:
margins:
top: 25
bottom: 20
inner: 30
outer: 15
chord_line_spacing: 5
verse_spacing: 6
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.layout.margins.top shouldBe 25f
config.layout.margins.bottom shouldBe 20f
config.layout.margins.inner shouldBe 30f
config.layout.margins.outer shouldBe 15f
config.layout.chordLineSpacing shouldBe 5f
config.layout.verseSpacing shouldBe 6f
}
@Test
fun `parse config with foreword section`() {
val yaml = """
book:
title: "Test"
foreword:
file: "./vorwort.txt"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.foreword.shouldNotBeNull()
config.foreword!!.file shouldBe "./vorwort.txt"
}
@Test
fun `parse config without foreword section has null foreword`() {
val yaml = """
book:
title: "Test"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.foreword.shouldBeNull()
}
@Test
fun `parse config with toc highlight column`() {
val yaml = """
book:
title: "Test"
toc:
highlight_column: "CL"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.toc.highlightColumn shouldBe "CL"
}
@Test
fun `parse config without toc section uses defaults`() {
val yaml = """
book:
title: "Test"
""".trimIndent()
val config = ConfigParser.parse(yaml)
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
fun `parse config ignores unknown properties`() {
val yaml = """
book:
title: "Test"
unknown_field: "value"
some_extra_section:
key: value
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.book.title shouldBe "Test"
}
@Test
fun `parse config with custom title font file only`() {
val yaml = """
book:
title: "Fraktur Test"
fonts:
title: { file: "./fonts/Fraktur.ttf", size: 16 }
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.fonts.title.file shouldBe "./fonts/Fraktur.ttf"
config.fonts.title.size shouldBe 16f
config.fonts.title.family shouldBe "Helvetica" // default family as fallback
// Other fonts should still use defaults
config.fonts.lyrics.file.shouldBeNull()
config.fonts.lyrics.family shouldBe "Helvetica"
}
}

View File

@@ -1,150 +0,0 @@
package de.pfadfinder.songbook.parser
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class ForewordParserTest {
@Test
fun `parse foreword with quote, paragraphs and signatures`() {
val input = """
> This is a quote line one
> and quote line two
---
This is the first paragraph of the foreword body.
It continues on the next line.
This is the second paragraph.
-- Max Mustermann
-- Erika Mustermann
""".trimIndent()
val foreword = ForewordParser.parse(input)
foreword.quote.shouldNotBeNull()
foreword.quote shouldBe "This is a quote line one and quote line two"
foreword.paragraphs shouldHaveSize 2
foreword.paragraphs[0] shouldBe "This is the first paragraph of the foreword body. It continues on the next line."
foreword.paragraphs[1] shouldBe "This is the second paragraph."
foreword.signatures shouldHaveSize 2
foreword.signatures[0] shouldBe "Max Mustermann"
foreword.signatures[1] shouldBe "Erika Mustermann"
}
@Test
fun `parse foreword without quote`() {
val input = """
This is just a paragraph.
And another one.
-- Author Name
""".trimIndent()
val foreword = ForewordParser.parse(input)
foreword.quote.shouldBeNull()
foreword.paragraphs shouldHaveSize 2
foreword.paragraphs[0] shouldBe "This is just a paragraph."
foreword.paragraphs[1] shouldBe "And another one."
foreword.signatures shouldHaveSize 1
foreword.signatures[0] shouldBe "Author Name"
}
@Test
fun `parse foreword without signatures`() {
val input = """
> A beautiful quote
---
The foreword body text goes here.
""".trimIndent()
val foreword = ForewordParser.parse(input)
foreword.quote shouldBe "A beautiful quote"
foreword.paragraphs shouldHaveSize 1
foreword.paragraphs[0] shouldBe "The foreword body text goes here."
foreword.signatures.shouldBeEmpty()
}
@Test
fun `parse empty foreword`() {
val foreword = ForewordParser.parse("")
foreword.quote.shouldBeNull()
foreword.paragraphs.shouldBeEmpty()
foreword.signatures.shouldBeEmpty()
}
@Test
fun `parse foreword with only paragraphs`() {
val input = """
First paragraph.
Second paragraph.
Third paragraph.
""".trimIndent()
val foreword = ForewordParser.parse(input)
foreword.quote.shouldBeNull()
foreword.paragraphs shouldHaveSize 3
foreword.paragraphs[0] shouldBe "First paragraph."
foreword.paragraphs[1] shouldBe "Second paragraph."
foreword.paragraphs[2] shouldBe "Third paragraph."
foreword.signatures.shouldBeEmpty()
}
@Test
fun `parse foreword with multi-line paragraph`() {
val input = """
This is a long paragraph that
spans multiple lines and should
be joined into a single paragraph.
""".trimIndent()
val foreword = ForewordParser.parse(input)
foreword.paragraphs shouldHaveSize 1
foreword.paragraphs[0] shouldBe "This is a long paragraph that spans multiple lines and should be joined into a single paragraph."
}
@Test
fun `parse foreword with only a quote`() {
val input = """
> Just a quote
---
""".trimIndent()
val foreword = ForewordParser.parse(input)
foreword.quote shouldBe "Just a quote"
foreword.paragraphs.shouldBeEmpty()
foreword.signatures.shouldBeEmpty()
}
@Test
fun `parse foreword with multiple signatures`() {
val input = """
Some text here.
-- Person One
-- Person Two
-- Person Three
""".trimIndent()
val foreword = ForewordParser.parse(input)
foreword.paragraphs shouldHaveSize 1
foreword.signatures shouldHaveSize 3
foreword.signatures[0] shouldBe "Person One"
foreword.signatures[1] shouldBe "Person Two"
foreword.signatures[2] shouldBe "Person Three"
}
}

View File

@@ -1,258 +0,0 @@
package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.string.shouldContain
import kotlin.test.Test
class ValidatorTest {
@Test
fun `valid song produces no errors`() {
val song = Song(
title = "Test Song",
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song)
errors.shouldBeEmpty()
}
@Test
fun `missing title produces error`() {
val song = Song(
title = "",
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song)
errors shouldHaveSize 1
errors[0].message shouldContain "title"
}
@Test
fun `blank title produces error`() {
val song = Song(
title = " ",
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song)
errors shouldHaveSize 1
errors[0].message shouldContain "title"
}
@Test
fun `empty sections produces error`() {
val song = Song(
title = "Test",
sections = emptyList()
)
val errors = Validator.validateSong(song)
errors shouldHaveSize 1
errors[0].message shouldContain "section"
}
@Test
fun `missing title and empty sections produces two errors`() {
val song = Song(title = "", sections = emptyList())
val errors = Validator.validateSong(song)
errors shouldHaveSize 2
}
@Test
fun `fileName is included in error`() {
val song = Song(title = "", sections = emptyList())
val errors = Validator.validateSong(song, "test.chopro")
errors.forEach { it.file shouldContain "test.chopro" }
}
@Test
fun `valid song with known references produces no errors`() {
val config = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO")
)
)
val song = Song(
title = "Test",
references = mapOf("mundorgel" to 42),
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song, config)
errors.shouldBeEmpty()
}
@Test
fun `unknown reference book produces error`() {
val config = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO")
)
)
val song = Song(
title = "Test",
references = mapOf("unknown_book" to 42),
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song, config)
errors shouldHaveSize 1
errors[0].message shouldContain "unknown_book"
}
@Test
fun `multiple unknown references produce multiple errors`() {
val config = BookConfig(referenceBooks = emptyList())
val song = Song(
title = "Test",
references = mapOf("book1" to 1, "book2" to 2),
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song, config)
errors shouldHaveSize 2
}
@Test
fun `valid config produces no errors`() {
val config = BookConfig()
val errors = Validator.validateConfig(config)
errors.shouldBeEmpty()
}
@Test
fun `zero top margin produces error`() {
val config = BookConfig(
layout = LayoutConfig(margins = Margins(top = 0f))
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "Top margin"
}
@Test
fun `negative bottom margin produces error`() {
val config = BookConfig(
layout = LayoutConfig(margins = Margins(bottom = -5f))
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "Bottom margin"
}
@Test
fun `negative inner margin produces error`() {
val config = BookConfig(
layout = LayoutConfig(margins = Margins(inner = -1f))
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "Inner margin"
}
@Test
fun `zero outer margin produces error`() {
val config = BookConfig(
layout = LayoutConfig(margins = Margins(outer = 0f))
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "Outer margin"
}
@Test
fun `all margins zero produces four errors`() {
val config = BookConfig(
layout = LayoutConfig(margins = Margins(top = 0f, bottom = 0f, inner = 0f, outer = 0f))
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 4
}
@Test
fun `unknown reference with fileName in error`() {
val config = BookConfig(referenceBooks = emptyList())
val song = Song(
title = "Test",
references = mapOf("book1" to 1),
sections = listOf(
SongSection(type = SectionType.VERSE, lines = listOf(
SongLine(segments = listOf(LineSegment(text = "Hello")))
))
)
)
val errors = Validator.validateSong(song, config, "myfile.chopro")
errors shouldHaveSize 1
errors[0].file shouldContain "myfile.chopro"
}
@Test
fun `missing font file produces validation error`() {
val config = BookConfig(
fonts = FontsConfig(
title = FontSpec(file = "/nonexistent/path/FrakturFont.ttf", size = 14f)
)
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "title"
errors[0].message shouldContain "not found"
}
@Test
fun `multiple missing font files produce multiple errors`() {
val config = BookConfig(
fonts = FontsConfig(
title = FontSpec(file = "/nonexistent/title.ttf", size = 14f),
lyrics = FontSpec(file = "/nonexistent/lyrics.ttf", size = 10f)
)
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 2
}
@Test
fun `config with no font files produces no font errors`() {
val config = BookConfig() // all default built-in fonts
val errors = Validator.validateConfig(config)
errors.shouldBeEmpty()
}
@Test
fun `config with existing font file produces no error`() {
// Create a temporary file to simulate an existing font file
val tempFile = kotlin.io.path.createTempFile(suffix = ".ttf").toFile()
try {
val config = BookConfig(
fonts = FontsConfig(
title = FontSpec(file = tempFile.absolutePath, size = 14f)
)
)
val errors = Validator.validateConfig(config)
errors.shouldBeEmpty()
} finally {
tempFile.delete()
}
}
}

View File

@@ -1,11 +0,0 @@
plugins {
id("songbook-conventions")
}
dependencies {
implementation(project(":model"))
implementation("com.github.librepdf:openpdf:2.0.3")
testImplementation(kotlin("test"))
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
}

View File

@@ -1,70 +0,0 @@
package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.pdf.PdfContentByte
import de.pfadfinder.songbook.model.*
import java.awt.Color
class ChordLyricRenderer(
private val fontMetrics: PdfFontMetrics,
private val config: BookConfig
) {
// Renders a single SongLine (chord line above + lyric line below)
// Returns the total height consumed in PDF points
fun renderLine(
cb: PdfContentByte,
line: SongLine,
x: Float, // left x position in points
y: Float, // top y position in points (PDF coordinates, y goes up)
maxWidth: Float // available width in points
): Float {
val hasChords = line.segments.any { it.chord != null }
val chordFont = fontMetrics.getBaseFontBold(config.fonts.chords)
val lyricFont = fontMetrics.getBaseFont(config.fonts.lyrics)
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 // mm to points
var totalHeight = lyricLineHeight
if (hasChords) {
totalHeight += chordLineHeight + chordLyricGap
}
val chordColor = parseColor(config.fonts.chords.color)
// Calculate x positions for each segment
var currentX = x
for (segment in line.segments) {
if (hasChords && segment.chord != null) {
// Draw chord above
cb.beginText()
cb.setFontAndSize(chordFont, chordSize)
cb.setColorFill(chordColor)
cb.setTextMatrix(currentX, y - chordLineHeight)
cb.showText(segment.chord)
cb.endText()
}
// Draw lyric text
cb.beginText()
cb.setFontAndSize(lyricFont, lyricSize)
cb.setColorFill(Color.BLACK)
cb.setTextMatrix(currentX, y - totalHeight)
cb.showText(segment.text)
cb.endText()
currentX += lyricFont.getWidthPoint(segment.text, lyricSize)
}
return totalHeight
}
private fun parseColor(hex: String): Color {
val clean = hex.removePrefix("#")
val r = clean.substring(0, 2).toInt(16)
val g = clean.substring(2, 4).toInt(16)
val b = clean.substring(4, 6).toInt(16)
return Color(r, g, b)
}
}

View File

@@ -1,37 +0,0 @@
package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.pdf.PdfContentByte
import de.pfadfinder.songbook.model.BookConfig
import java.awt.Color
class PageDecorator(
private val fontMetrics: PdfFontMetrics,
private val config: BookConfig
) {
fun addPageNumber(cb: PdfContentByte, pageNumber: Int, pageWidth: Float, pageHeight: Float) {
val font = fontMetrics.getBaseFont(config.fonts.metadata)
val fontSize = config.fonts.metadata.size
val text = pageNumber.toString()
val textWidth = font.getWidthPoint(text, fontSize)
val marginBottom = config.layout.margins.bottom / 0.3528f // mm to points
val marginOuter = config.layout.margins.outer / 0.3528f
val y = marginBottom / 2 // center in bottom margin
// Outer position: even pages -> left, odd pages -> right (for book binding)
val isRightPage = pageNumber % 2 == 1
val x = if (isRightPage) {
pageWidth - marginOuter / 2 - textWidth / 2
} else {
marginOuter / 2 - textWidth / 2
}
cb.beginText()
cb.setFontAndSize(font, fontSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(x, y)
cb.showText(text)
cb.endText()
}
}

View File

@@ -1,813 +0,0 @@
package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.*
import com.lowagie.text.pdf.*
import de.pfadfinder.songbook.model.*
import java.awt.Color
import java.io.OutputStream
class PdfBookRenderer : BookRenderer {
override fun render(layout: LayoutResult, config: BookConfig, output: OutputStream) {
val fontMetrics = PdfFontMetrics()
val chordLyricRenderer = ChordLyricRenderer(fontMetrics, config)
val tocRenderer = TocRenderer(fontMetrics, config)
val pageDecorator = PageDecorator(fontMetrics, config)
// A5 page size in points: 148mm x 210mm -> 419.53 x 595.28 points
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
// Start with right-page margins (page 1 is right/odd page)
val document = Document(pageSize, marginInner, marginOuter, marginTop, marginBottom)
val writer = PdfWriter.getInstance(document, output)
document.open()
// Render intro page (title page) if configured
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()) {
tocRenderer.render(document, writer, layout.tocEntries)
// Pad with blank pages to fill the allocated TOC page count.
// The table auto-paginates, so we only add the difference.
val tocPagesUsed = writer.pageNumber - layout.introPages
val paddingNeeded = maxOf(0, layout.tocPages - tocPagesUsed)
repeat(paddingNeeded) {
document.newPage()
// Force new page even if empty
writer.directContent.let { cb ->
cb.beginText()
cb.endText()
}
}
document.newPage()
}
// Render content pages
var currentPageNum = layout.introPages + layout.tocPages + 1
for (pageContent in layout.pages) {
// Swap margins for left/right pages
val isRightPage = currentPageNum % 2 == 1
if (isRightPage) {
document.setMargins(marginInner, marginOuter, marginTop, marginBottom)
} else {
document.setMargins(marginOuter, marginInner, marginTop, marginBottom)
}
document.newPage()
val cb = writer.directContent
val contentWidth = pageSize.width - marginInner - marginOuter
val contentTop = pageSize.height - marginTop
when (pageContent) {
is PageContent.SongPage -> {
val leftMargin = if (isRightPage) marginInner else marginOuter
renderSongPage(
cb, chordLyricRenderer, fontMetrics, config,
pageContent.song, pageContent.pageIndex,
contentTop, leftMargin, contentWidth, marginBottom
)
}
is PageContent.FillerImage -> {
renderFillerImage(document, pageContent.imagePath, pageSize)
}
is PageContent.BlankPage -> {
// Empty page - just add invisible content to force page creation
cb.beginText()
cb.endText()
}
is PageContent.ForewordPage -> {
val leftMargin = if (isRightPage) marginInner else marginOuter
renderForewordPage(
cb, fontMetrics, config,
pageContent.foreword, pageContent.pageIndex,
contentTop, leftMargin, contentWidth
)
}
}
pageDecorator.addPageNumber(cb, currentPageNum, pageSize.width, pageSize.height)
currentPageNum++
}
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(
cb: PdfContentByte,
chordLyricRenderer: ChordLyricRenderer,
fontMetrics: PdfFontMetrics,
config: BookConfig,
song: Song,
pageIndex: Int, // 0 for first page, 1 for second page of 2-page songs
contentTop: Float,
leftMargin: Float,
contentWidth: Float,
bottomMargin: Float
) {
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) {
// Render title
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
val titleSize = config.fonts.title.size
cb.beginText()
cb.setFontAndSize(titleFont, titleSize)
cb.setColorFill(Color.BLACK)
cb.setTextMatrix(leftMargin, y - titleSize)
cb.showText(song.title)
cb.endText()
y -= titleSize * 1.5f
// Render metadata line (composer/lyricist) - at top position only
if (!renderMetaAtBottom) {
val metaParts = buildMetadataLines(song, config)
if (metaParts.isNotEmpty()) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
for (metaLine in metaParts) {
if (y - metaSize * 1.8f < yMin) break
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(metaLine)
cb.endText()
y -= metaSize * 1.8f
}
}
}
// Render key and capo
val infoParts = mutableListOf<String>()
song.key?.let { infoParts.add("Tonart: $it") }
song.capo?.let { infoParts.add("Capo: $it") }
if (infoParts.isNotEmpty()) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
if (y - metaSize * 1.8f >= yMin) {
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(infoParts.joinToString(" | "))
cb.endText()
y -= metaSize * 1.8f
}
}
y -= 4f // gap before sections
}
// Determine which sections to render on this page
val sections = if (pageIndex == 0) {
song.sections.subList(0, splitIndex)
} else {
song.sections.subList(splitIndex, song.sections.size)
}
for (section in sections) {
// Safety check: stop rendering if we've gone below the boundary
if (y < yMin) break
// 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) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
if (y - metaSize * 1.5f < yMin) break
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(labelText)
cb.endText()
y -= metaSize * 1.5f
}
}
// Chorus indication for repeat
if (section.type == SectionType.CHORUS && section.lines.isEmpty()) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
if (y - metaSize * 1.8f < yMin) break
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText("(Refrain)")
cb.endText()
y -= metaSize * 1.8f
continue
}
// Render repeat markers for REPEAT sections
if (section.type == SectionType.REPEAT) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText("\u2502:")
cb.endText()
}
// Render 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)
y -= height + 1f // 1pt gap between lines
}
}
// End repeat marker
if (section.type == SectionType.REPEAT) {
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
if (y - metaSize * 1.5f >= yMin) {
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(":\u2502")
cb.endText()
y -= metaSize * 1.5f
}
}
// Verse spacing
y -= config.layout.verseSpacing / 0.3528f
}
// Render footer elements (notes, metadata, references) anchored to the bottom of the page.
// Instead of flowing from the current y position after song content, we compute a fixed
// starting Y at the top of the footer area (bottomMargin + footerReservation) and render
// 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 metaSize = config.fonts.metadata.size
// 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.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, footerY - metaSize)
cb.showText(wrappedLine)
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()
}
}
}
private fun renderForewordPage(
cb: PdfContentByte,
fontMetrics: PdfFontMetrics,
config: BookConfig,
foreword: Foreword,
pageIndex: Int,
contentTop: Float,
leftMargin: Float,
contentWidth: Float
) {
var y = contentTop
val bodyFontSpec = config.fonts.lyrics
val bodyFont = fontMetrics.getBaseFont(bodyFontSpec)
val bodySize = bodyFontSpec.size
val lineHeight = bodySize * 1.5f
if (pageIndex == 0) {
// Page 1: Quote + separator + first paragraphs
// Render quote in italic (bold)
val quoteText = foreword.quote
if (quoteText != null) {
val quoteFont = fontMetrics.getBaseFontBold(bodyFontSpec)
val quoteSize = bodySize * 1.1f
// Word-wrap the quote text
val quoteLines = wrapText(quoteText, quoteFont, quoteSize, contentWidth)
for (quoteLine in quoteLines) {
cb.beginText()
cb.setFontAndSize(quoteFont, quoteSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(leftMargin, y - quoteSize)
cb.showText(quoteLine)
cb.endText()
y -= quoteSize * 1.5f
}
y -= 6f // gap before separator
// Horizontal rule
cb.setLineWidth(0.5f)
cb.setColorStroke(Color.GRAY)
cb.moveTo(leftMargin, y)
cb.lineTo(leftMargin + contentWidth, y)
cb.stroke()
y -= 10f // gap after separator
}
// Render body paragraphs
for (paragraph in foreword.paragraphs) {
val wrappedLines = wrapText(paragraph, bodyFont, bodySize, contentWidth)
for (wrappedLine in wrappedLines) {
cb.beginText()
cb.setFontAndSize(bodyFont, bodySize)
cb.setColorFill(Color.BLACK)
cb.setTextMatrix(leftMargin, y - bodySize)
cb.showText(wrappedLine)
cb.endText()
y -= lineHeight
}
y -= lineHeight * 0.5f // paragraph spacing
}
// Render signatures (right-aligned)
if (foreword.signatures.isNotEmpty()) {
y -= lineHeight // extra gap before signatures
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
for (signature in foreword.signatures) {
val sigWidth = metaFont.getWidthPoint(signature, metaSize)
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(leftMargin + contentWidth - sigWidth, y - metaSize)
cb.showText(signature)
cb.endText()
y -= metaSize * 1.8f
}
}
} else {
// Page 2: blank (or overflow content if needed in the future)
cb.beginText()
cb.endText()
}
}
/**
* Simple word-wrap: splits text into lines that fit within maxWidth (in points).
*/
private fun wrapText(text: String, font: BaseFont, fontSize: Float, maxWidth: Float): List<String> {
val words = text.split(" ")
val lines = mutableListOf<String>()
var currentLine = StringBuilder()
for (word in words) {
val testLine = if (currentLine.isEmpty()) word else "$currentLine $word"
val testWidth = font.getWidthPoint(testLine, fontSize)
if (testWidth <= maxWidth) {
currentLine = StringBuilder(testLine)
} else {
if (currentLine.isNotEmpty()) {
lines.add(currentLine.toString())
}
currentLine = StringBuilder(word)
}
}
if (currentLine.isNotEmpty()) {
lines.add(currentLine.toString())
}
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) {
try {
val img = Image.getInstance(imagePath)
img.scaleToFit(pageSize.width * 0.7f, pageSize.height * 0.7f)
img.alignment = Image.ALIGN_CENTER or Image.ALIGN_MIDDLE
document.add(img)
} catch (_: Exception) {
// If image can't be loaded, just leave the page blank
}
}
}

View File

@@ -1,60 +0,0 @@
package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.pdf.BaseFont
import de.pfadfinder.songbook.model.FontMetrics
import de.pfadfinder.songbook.model.FontSpec
import java.io.File
class PdfFontMetrics : FontMetrics {
private val fontCache = mutableMapOf<String, BaseFont>()
fun getBaseFont(font: FontSpec): BaseFont {
val fontFile = font.file
val key = if (fontFile != null) File(fontFile).canonicalPath else font.family
return fontCache.getOrPut(key) {
if (fontFile != null) {
val file = File(fontFile)
if (!file.exists()) {
throw IllegalArgumentException("Font file not found: ${file.absolutePath}")
}
BaseFont.createFont(file.absolutePath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
} else {
// Map common family names to built-in PDF fonts
val pdfFontName = when (font.family.lowercase()) {
"helvetica" -> BaseFont.HELVETICA
"courier" -> BaseFont.COURIER
"times", "times new roman" -> BaseFont.TIMES_ROMAN
else -> BaseFont.HELVETICA
}
BaseFont.createFont(pdfFontName, BaseFont.CP1252, BaseFont.NOT_EMBEDDED)
}
}
}
// Also provide bold variants for chord fonts
fun getBaseFontBold(font: FontSpec): BaseFont {
if (font.file != null) return getBaseFont(font)
val key = "${font.family}_bold"
return fontCache.getOrPut(key) {
val pdfFontName = when (font.family.lowercase()) {
"helvetica" -> BaseFont.HELVETICA_BOLD
"courier" -> BaseFont.COURIER_BOLD
"times", "times new roman" -> BaseFont.TIMES_BOLD
else -> BaseFont.HELVETICA_BOLD
}
BaseFont.createFont(pdfFontName, BaseFont.CP1252, BaseFont.NOT_EMBEDDED)
}
}
override fun measureTextWidth(text: String, font: FontSpec, size: Float): Float {
val baseFont = getBaseFont(font)
// BaseFont.getWidthPoint returns width in PDF points
// Convert to mm: 1 point = 0.3528 mm
return baseFont.getWidthPoint(text, size) * 0.3528f
}
override fun measureLineHeight(font: FontSpec, size: Float): Float {
// Approximate line height as 1.2 * font size, converted to mm
return size * 1.2f * 0.3528f
}
}

View File

@@ -1,154 +0,0 @@
package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.*
import com.lowagie.text.pdf.*
import de.pfadfinder.songbook.model.*
import java.awt.Color
class TocRenderer(
private val fontMetrics: PdfFontMetrics,
private val config: BookConfig
) {
// Light gray background for the highlighted column
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>) {
val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
val fontSize = config.fonts.toc.size
// Title "Inhaltsverzeichnis"
val titleFont = Font(fontMetrics.getBaseFont(config.fonts.title), config.fonts.title.size, Font.BOLD)
val title = Paragraph("Inhaltsverzeichnis", titleFont)
title.alignment = Element.ALIGN_CENTER
title.spacingAfter = 12f
document.add(title)
// Determine columns: Title | Page | ref book abbreviations...
val refBooks = config.referenceBooks
val numCols = 2 + refBooks.size
val table = PdfPTable(numCols)
table.widthPercentage = 100f
// Set column widths: title takes most space, ref columns need room for 3-digit numbers
val widths = FloatArray(numCols)
widths[0] = 12f // title
widths[1] = 1.5f // page
for (i in refBooks.indices) {
widths[2 + i] = 1.5f // enough for 3-digit page numbers; headers are rotated 90°
}
table.setWidths(widths)
// Determine which column index should be highlighted
val highlightAbbrev = config.toc.highlightColumn
val highlightColumnIndex: Int? = if (highlightAbbrev != null) {
if (highlightAbbrev == "Seite") {
1
} else {
val refIndex = refBooks.indexOfFirst { it.abbreviation == highlightAbbrev }
if (refIndex >= 0) 2 + refIndex else null
}
} else null
// Header row — reference book columns are rotated 90°
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
table.addCell(headerCell("Titel", headerFont, isHighlighted = false))
table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1))
for ((i, book) in refBooks.withIndex()) {
val isHighlighted = highlightColumnIndex == 2 + i
table.addCell(rotatedHeaderCell(book.abbreviation, headerFont, isHighlighted))
}
table.headerRows = 1
// TOC entries
val entryFont = Font(tocFont, fontSize)
val aliasFont = Font(tocFont, fontSize, Font.ITALIC)
for (entry in tocEntries.sortedBy { it.title.lowercase() }) {
val font = if (entry.isAlias) aliasFont else entryFont
table.addCell(entryCell(entry.title, font, isHighlighted = false))
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT, isHighlighted = highlightColumnIndex == 1))
for ((i, book) in refBooks.withIndex()) {
val ref = entry.references[book.abbreviation]
val isHighlighted = highlightColumnIndex == 2 + i
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_CENTER, isHighlighted = isHighlighted))
}
}
document.add(table)
}
private fun headerCell(text: String, font: Font, isHighlighted: Boolean): PdfPCell {
val cell = PdfPCell(Phrase(text, font))
cell.borderWidth = 0f
cell.borderWidthBottom = 0.5f
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) {
cell.backgroundColor = highlightColor
}
return cell
}
private fun entryCell(
text: String,
font: Font,
alignment: Int = Element.ALIGN_LEFT,
isHighlighted: Boolean = false
): PdfPCell {
val cell = PdfPCell(Phrase(text, font))
cell.borderWidth = 0f
cell.horizontalAlignment = alignment
cell.paddingTop = 1f
cell.paddingBottom = 1f
if (isHighlighted) {
cell.backgroundColor = highlightColor
}
return cell
}
}

View File

@@ -1,103 +0,0 @@
package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.Document
import com.lowagie.text.PageSize
import com.lowagie.text.pdf.PdfWriter
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.floats.shouldBeGreaterThan
import java.io.ByteArrayOutputStream
import kotlin.test.Test
class ChordLyricRendererTest {
private val fontMetrics = PdfFontMetrics()
private val config = BookConfig()
private val renderer = ChordLyricRenderer(fontMetrics, config)
private fun withPdfContentByte(block: (com.lowagie.text.pdf.PdfContentByte) -> Unit) {
val baos = ByteArrayOutputStream()
val document = Document(PageSize.A5)
val writer = PdfWriter.getInstance(document, baos)
document.open()
val cb = writer.directContent
block(cb)
document.close()
}
@Test
fun `renderLine returns positive height for lyric-only line`() {
withPdfContentByte { cb ->
val line = SongLine(listOf(LineSegment(text = "Hello world")))
val height = renderer.renderLine(cb, line, 50f, 500f, 300f)
height shouldBeGreaterThan 0f
}
}
@Test
fun `renderLine returns greater height for chord+lyric line than lyric-only`() {
withPdfContentByte { cb ->
val lyricOnly = SongLine(listOf(LineSegment(text = "Hello world")))
val withChords = SongLine(listOf(LineSegment(chord = "Am", text = "Hello world")))
val lyricHeight = renderer.renderLine(cb, lyricOnly, 50f, 500f, 300f)
val chordHeight = renderer.renderLine(cb, withChords, 50f, 500f, 300f)
chordHeight shouldBeGreaterThan lyricHeight
}
}
@Test
fun `renderLine handles multiple segments`() {
withPdfContentByte { cb ->
val line = SongLine(
listOf(
LineSegment(chord = "C", text = "Amazing "),
LineSegment(chord = "G", text = "Grace, how "),
LineSegment(chord = "Am", text = "sweet the "),
LineSegment(chord = "F", text = "sound")
)
)
val height = renderer.renderLine(cb, line, 50f, 500f, 300f)
height shouldBeGreaterThan 0f
}
}
@Test
fun `renderLine handles segments with mixed chords and no-chords`() {
withPdfContentByte { cb ->
val line = SongLine(
listOf(
LineSegment(chord = "C", text = "Hello "),
LineSegment(text = "world"),
LineSegment(chord = "G", text = " today")
)
)
val height = renderer.renderLine(cb, line, 50f, 500f, 300f)
height shouldBeGreaterThan 0f
}
}
@Test
fun `renderLine handles empty text segments`() {
withPdfContentByte { cb ->
val line = SongLine(listOf(LineSegment(chord = "Am", text = "")))
val height = renderer.renderLine(cb, line, 50f, 500f, 300f)
height shouldBeGreaterThan 0f
}
}
@Test
fun `renderLine handles custom chord color from config`() {
val customConfig = BookConfig(
fonts = FontsConfig(
chords = FontSpec(family = "Helvetica", size = 9f, color = "#FF0000")
)
)
val customRenderer = ChordLyricRenderer(fontMetrics, customConfig)
withPdfContentByte { cb ->
val line = SongLine(listOf(LineSegment(chord = "Am", text = "Hello")))
val height = customRenderer.renderLine(cb, line, 50f, 500f, 300f)
height shouldBeGreaterThan 0f
}
}
}

View File

@@ -1,55 +0,0 @@
package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.Document
import com.lowagie.text.PageSize
import com.lowagie.text.pdf.PdfWriter
import de.pfadfinder.songbook.model.BookConfig
import java.io.ByteArrayOutputStream
import kotlin.test.Test
class PageDecoratorTest {
private val fontMetrics = PdfFontMetrics()
private val config = BookConfig()
private val decorator = PageDecorator(fontMetrics, config)
private fun withPdfContentByte(block: (com.lowagie.text.pdf.PdfContentByte) -> Unit) {
val baos = ByteArrayOutputStream()
val document = Document(PageSize.A5)
val writer = PdfWriter.getInstance(document, baos)
document.open()
val cb = writer.directContent
block(cb)
document.close()
}
@Test
fun `addPageNumber renders odd page number on right side`() {
// Odd page = right side of book spread
withPdfContentByte { cb ->
decorator.addPageNumber(cb, 1, PageSize.A5.width, PageSize.A5.height)
}
}
@Test
fun `addPageNumber renders even page number on left side`() {
// Even page = left side of book spread
withPdfContentByte { cb ->
decorator.addPageNumber(cb, 2, PageSize.A5.width, PageSize.A5.height)
}
}
@Test
fun `addPageNumber handles large page numbers`() {
withPdfContentByte { cb ->
decorator.addPageNumber(cb, 999, PageSize.A5.width, PageSize.A5.height)
}
}
@Test
fun `addPageNumber works with A4 page size`() {
withPdfContentByte { cb ->
decorator.addPageNumber(cb, 5, PageSize.A4.width, PageSize.A4.height)
}
}
}

View File

@@ -1,629 +0,0 @@
package de.pfadfinder.songbook.renderer.pdf
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.shouldBe
import java.io.ByteArrayOutputStream
import kotlin.test.Test
import kotlin.test.assertFails
class PdfBookRendererTest {
private val renderer = PdfBookRenderer()
private fun createSimpleSong(title: String = "Test Song"): Song {
return Song(
title = title,
composer = "Test Composer",
lyricist = "Test Lyricist",
key = "Am",
capo = 2,
notes = listOf("Play gently"),
sections = listOf(
SongSection(
type = SectionType.VERSE,
label = "Verse 1",
lines = listOf(
SongLine(
listOf(
LineSegment(chord = "Am", text = "Hello "),
LineSegment(chord = "C", text = "World")
)
),
SongLine(
listOf(
LineSegment(text = "This is a test line")
)
)
)
),
SongSection(
type = SectionType.CHORUS,
lines = listOf(
SongLine(
listOf(
LineSegment(chord = "F", text = "Chorus "),
LineSegment(chord = "G", text = "line")
)
)
)
)
)
)
}
@Test
fun `render produces valid PDF with single song`() {
val song = createSimpleSong()
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
// Check PDF header
val bytes = baos.toByteArray()
val header = String(bytes.sliceArray(0..4))
header shouldBe "%PDF-"
}
@Test
fun `render produces valid PDF with TOC`() {
val song = createSimpleSong()
val layout = LayoutResult(
tocPages = 2,
pages = listOf(PageContent.SongPage(song, 0)),
tocEntries = listOf(
TocEntry(title = "Test Song", pageNumber = 3)
)
)
val baos = ByteArrayOutputStream()
renderer.render(layout, BookConfig(), baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render handles blank pages`() {
val layout = LayoutResult(
tocPages = 0,
pages = listOf(PageContent.BlankPage),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, BookConfig(), baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render handles mixed page types`() {
val song = createSimpleSong()
val layout = LayoutResult(
tocPages = 0,
pages = listOf(
PageContent.SongPage(song, 0),
PageContent.BlankPage,
PageContent.SongPage(song, 0)
),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, BookConfig(), baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render handles A4 format`() {
val song = createSimpleSong()
val config = BookConfig(book = BookMeta(format = "A4"))
val layout = LayoutResult(
tocPages = 0,
pages = listOf(PageContent.SongPage(song, 0)),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, config, baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render handles song with all section types`() {
val song = Song(
title = "Full Song",
sections = listOf(
SongSection(
type = SectionType.VERSE,
label = "Verse 1",
lines = listOf(
SongLine(listOf(LineSegment(chord = "C", text = "Verse line")))
)
),
SongSection(
type = SectionType.CHORUS,
lines = listOf(
SongLine(listOf(LineSegment(chord = "G", text = "Chorus line")))
)
),
SongSection(
type = SectionType.BRIDGE,
label = "Bridge",
lines = listOf(
SongLine(listOf(LineSegment(text = "Bridge line")))
)
),
SongSection(
type = SectionType.REPEAT,
lines = listOf(
SongLine(listOf(LineSegment(chord = "Am", text = "Repeat 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 handles empty chorus section (chorus reference)`() {
val song = Song(
title = "Song with chorus ref",
sections = listOf(
SongSection(
type = SectionType.CHORUS,
lines = emptyList() // empty = just a reference
)
)
)
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 handles song without metadata`() {
val song = Song(
title = "Minimal Song",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(
SongLine(listOf(LineSegment(text = "Just lyrics")))
)
)
)
)
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 handles second page of two-page song`() {
val song = createSimpleSong()
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 handles filler image with nonexistent path gracefully`() {
val layout = LayoutResult(
tocPages = 0,
pages = listOf(PageContent.FillerImage("/nonexistent/image.png")),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, BookConfig(), baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render handles TOC with reference books`() {
val config = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO")
)
)
val song = createSimpleSong()
val layout = LayoutResult(
tocPages = 2,
pages = listOf(PageContent.SongPage(song, 0)),
tocEntries = listOf(
TocEntry(title = "Test Song", pageNumber = 3, references = mapOf("MO" to 42))
)
)
val baos = ByteArrayOutputStream()
renderer.render(layout, config, baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render handles multiple songs with proper page numbering`() {
val song1 = createSimpleSong("Song One")
val song2 = createSimpleSong("Song Two")
val layout = LayoutResult(
tocPages = 0,
pages = listOf(
PageContent.SongPage(song1, 0),
PageContent.SongPage(song2, 0)
),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, BookConfig(), baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render handles song with multiple notes`() {
val song = Song(
title = "Song with Notes",
notes = listOf("Note 1: Play slowly", "Note 2: Repeat chorus twice"),
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(
SongLine(listOf(LineSegment(text = "A simple 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 with custom margins`() {
val config = BookConfig(
layout = LayoutConfig(
margins = Margins(top = 20f, bottom = 20f, inner = 25f, outer = 15f)
)
)
val song = createSimpleSong()
val layout = LayoutResult(
tocPages = 0,
pages = listOf(PageContent.SongPage(song, 0)),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
renderer.render(layout, config, baos)
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render throws on empty layout with no content`() {
val layout = LayoutResult(
tocPages = 0,
pages = emptyList(),
tocEntries = emptyList()
)
val baos = ByteArrayOutputStream()
// OpenPDF requires at least one page of content
assertFails {
renderer.render(layout, BookConfig(), baos)
}
}
@Test
fun `render handles song with only key no capo`() {
val song = Song(
title = "Key Only Song",
key = "G",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "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 handles song with only capo no key`() {
val song = Song(
title = "Capo Only Song",
capo = 3,
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(SongLine(listOf(LineSegment(text = "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
}
// --- 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

@@ -1,231 +0,0 @@
package de.pfadfinder.songbook.renderer.pdf
import de.pfadfinder.songbook.model.FontSpec
import io.kotest.matchers.floats.shouldBeGreaterThan
import io.kotest.matchers.floats.shouldBeLessThan
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeSameInstanceAs
import kotlin.test.Test
import kotlin.test.assertFailsWith
class PdfFontMetricsTest {
private val metrics = PdfFontMetrics()
@Test
fun `getBaseFont returns Helvetica for default font spec`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val baseFont = metrics.getBaseFont(font)
// Helvetica built-in returns a non-null BaseFont
baseFont.postscriptFontName shouldBe "Helvetica"
}
@Test
fun `getBaseFont returns Courier for courier family`() {
val font = FontSpec(family = "Courier", size = 10f)
val baseFont = metrics.getBaseFont(font)
baseFont.postscriptFontName shouldBe "Courier"
}
@Test
fun `getBaseFont returns Times-Roman for times family`() {
val font = FontSpec(family = "Times", size = 10f)
val baseFont = metrics.getBaseFont(font)
baseFont.postscriptFontName shouldBe "Times-Roman"
}
@Test
fun `getBaseFont returns Times-Roman for times new roman family`() {
val font = FontSpec(family = "Times New Roman", size = 10f)
val baseFont = metrics.getBaseFont(font)
baseFont.postscriptFontName shouldBe "Times-Roman"
}
@Test
fun `getBaseFont falls back to Helvetica for unknown family`() {
val font = FontSpec(family = "UnknownFont", size = 10f)
val baseFont = metrics.getBaseFont(font)
baseFont.postscriptFontName shouldBe "Helvetica"
}
@Test
fun `getBaseFont caches fonts by family name`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val first = metrics.getBaseFont(font)
val second = metrics.getBaseFont(font)
first shouldBeSameInstanceAs second
}
@Test
fun `getBaseFontBold returns Helvetica-Bold for Helvetica`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val boldFont = metrics.getBaseFontBold(font)
boldFont.postscriptFontName shouldBe "Helvetica-Bold"
}
@Test
fun `getBaseFontBold returns Courier-Bold for Courier`() {
val font = FontSpec(family = "Courier", size = 10f)
val boldFont = metrics.getBaseFontBold(font)
boldFont.postscriptFontName shouldBe "Courier-Bold"
}
@Test
fun `getBaseFontBold returns Times-Bold for Times`() {
val font = FontSpec(family = "Times", size = 10f)
val boldFont = metrics.getBaseFontBold(font)
boldFont.postscriptFontName shouldBe "Times-Bold"
}
@Test
fun `getBaseFontBold falls back to Helvetica-Bold for unknown family`() {
val font = FontSpec(family = "UnknownFont", size = 10f)
val boldFont = metrics.getBaseFontBold(font)
boldFont.postscriptFontName shouldBe "Helvetica-Bold"
}
@Test
fun `getBaseFontBold returns regular font when file is specified`() {
// When a file is specified, bold should return the same as regular
// (custom fonts don't have bold variants auto-resolved)
// We can't test with a real file here, but verify the logic path:
// file != null -> delegates to getBaseFont
// Since we don't have a real font file, we test with family-based fonts
val font = FontSpec(family = "Helvetica", size = 10f)
val bold1 = metrics.getBaseFontBold(font)
val bold2 = metrics.getBaseFontBold(font)
bold1 shouldBeSameInstanceAs bold2
}
@Test
fun `measureTextWidth returns positive value for non-empty text`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val width = metrics.measureTextWidth("Hello World", font, 10f)
width shouldBeGreaterThan 0f
}
@Test
fun `measureTextWidth returns zero for empty text`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val width = metrics.measureTextWidth("", font, 10f)
width shouldBe 0f
}
@Test
fun `measureTextWidth wider text returns larger width`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val shortWidth = metrics.measureTextWidth("Hi", font, 10f)
val longWidth = metrics.measureTextWidth("Hello World, this is longer", font, 10f)
longWidth shouldBeGreaterThan shortWidth
}
@Test
fun `measureTextWidth scales with font size`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val smallWidth = metrics.measureTextWidth("Test", font, 10f)
val largeWidth = metrics.measureTextWidth("Test", font, 20f)
largeWidth shouldBeGreaterThan smallWidth
}
@Test
fun `measureTextWidth returns value in mm`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val width = metrics.measureTextWidth("M", font, 10f)
// A single 'M' at 10pt should be roughly 2-4mm
width shouldBeGreaterThan 1f
width shouldBeLessThan 10f
}
@Test
fun `measureLineHeight returns positive value`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val height = metrics.measureLineHeight(font, 10f)
height shouldBeGreaterThan 0f
}
@Test
fun `measureLineHeight scales with font size`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val smallHeight = metrics.measureLineHeight(font, 10f)
val largeHeight = metrics.measureLineHeight(font, 20f)
largeHeight shouldBeGreaterThan smallHeight
}
@Test
fun `measureLineHeight returns value in mm`() {
val font = FontSpec(family = "Helvetica", size = 10f)
val height = metrics.measureLineHeight(font, 10f)
// 10pt * 1.2 * 0.3528 = ~4.23mm
height shouldBeGreaterThan 3f
height shouldBeLessThan 6f
}
// --- Custom font file tests ---
private val testFontPath: String
get() = this::class.java.getResource("/TestFont.ttf")!!.file
@Test
fun `getBaseFont loads custom font from file path`() {
val font = FontSpec(file = testFontPath, size = 12f)
val baseFont = metrics.getBaseFont(font)
// Custom font should load successfully and have a non-null PostScript name
baseFont.postscriptFontName.isNotEmpty() shouldBe true
}
@Test
fun `getBaseFont caches custom font by canonical path`() {
val font1 = FontSpec(file = testFontPath, size = 12f)
val font2 = FontSpec(file = testFontPath, size = 14f) // different size, same file
val first = metrics.getBaseFont(font1)
val second = metrics.getBaseFont(font2)
first shouldBeSameInstanceAs second
}
@Test
fun `getBaseFont throws for missing font file`() {
val font = FontSpec(file = "/nonexistent/path/MissingFont.ttf", size = 12f)
assertFailsWith<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

@@ -1,124 +0,0 @@
package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.Document
import com.lowagie.text.PageSize
import com.lowagie.text.pdf.PdfWriter
import de.pfadfinder.songbook.model.*
import io.kotest.matchers.ints.shouldBeGreaterThan
import java.io.ByteArrayOutputStream
import kotlin.test.Test
class TocRendererTest {
private val fontMetrics = PdfFontMetrics()
private val config = BookConfig()
private val renderer = TocRenderer(fontMetrics, config)
@Test
fun `render creates TOC with entries`() {
val baos = ByteArrayOutputStream()
val document = Document(PageSize.A5)
val writer = PdfWriter.getInstance(document, baos)
document.open()
val entries = listOf(
TocEntry(title = "Amazing Grace", pageNumber = 3),
TocEntry(title = "Blowin' in the Wind", pageNumber = 5),
TocEntry(title = "Country Roads", pageNumber = 7)
)
renderer.render(document, writer, entries)
document.close()
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render handles alias entries in italics`() {
val baos = ByteArrayOutputStream()
val document = Document(PageSize.A5)
val writer = PdfWriter.getInstance(document, baos)
document.open()
val entries = listOf(
TocEntry(title = "Amazing Grace", pageNumber = 3),
TocEntry(title = "Grace (Amazing)", pageNumber = 3, isAlias = true)
)
renderer.render(document, writer, entries)
document.close()
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render includes reference book columns`() {
val configWithRefs = BookConfig(
referenceBooks = listOf(
ReferenceBook(id = "mundorgel", name = "Mundorgel", abbreviation = "MO"),
ReferenceBook(id = "pfadfinder", name = "Pfadfinderliederbuch", abbreviation = "PL")
)
)
val rendererWithRefs = TocRenderer(fontMetrics, configWithRefs)
val baos = ByteArrayOutputStream()
val document = Document(PageSize.A5)
val writer = PdfWriter.getInstance(document, baos)
document.open()
val entries = listOf(
TocEntry(
title = "Amazing Grace",
pageNumber = 3,
references = mapOf("MO" to 42, "PL" to 15)
),
TocEntry(
title = "Country Roads",
pageNumber = 7,
references = mapOf("MO" to 88)
)
)
rendererWithRefs.render(document, writer, entries)
document.close()
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render sorts entries alphabetically`() {
val baos = ByteArrayOutputStream()
val document = Document(PageSize.A5)
val writer = PdfWriter.getInstance(document, baos)
document.open()
// Entries given out of order
val entries = listOf(
TocEntry(title = "Zzz Last", pageNumber = 10),
TocEntry(title = "Aaa First", pageNumber = 1),
TocEntry(title = "Mmm Middle", pageNumber = 5)
)
renderer.render(document, writer, entries)
document.close()
baos.size() shouldBeGreaterThan 0
}
@Test
fun `render handles empty reference books list`() {
val baos = ByteArrayOutputStream()
val document = Document(PageSize.A5)
val writer = PdfWriter.getInstance(document, baos)
document.open()
val entries = listOf(
TocEntry(title = "Test Song", pageNumber = 1)
)
renderer.render(document, writer, entries)
document.close()
baos.size() shouldBeGreaterThan 0
}
}

View File

@@ -1,25 +0,0 @@
rootProject.name = "songbook"
pluginManagement {
repositories {
gradlePluginPortal()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
google()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
google()
}
}
include("model")
include("parser")
include("layout")
include("renderer-pdf")
include("app")
include("cli")
include("gui")

242
songbook-style.sty Normal file
View File

@@ -0,0 +1,242 @@
\NeedsTeXFormat{LaTeX2e}
\ProvidesPackage{songbook-style}[2026/04/01 Pfadfinder Liederbuch Style]
% --- Core packages ---
\RequirePackage{fontspec}
\RequirePackage[ngerman]{babel}
\RequirePackage[
a5paper,
top=15mm,
bottom=20mm,
inner=20mm,
outer=12mm
]{geometry}
\RequirePackage[hidelinks]{hyperref}
\RequirePackage{fancyhdr}
\RequirePackage{xcolor}
\RequirePackage{longtable}
\RequirePackage{array}
\RequirePackage{colortbl}
\RequirePackage{rotating}
\RequirePackage{graphicx}
\RequirePackage{csquotes}
\RequirePackage[minimal]{leadsheets}
\ExplSyntaxOn
\cs_new:cpn {leadsheets-library-musicsymbols-loaded} {}
\ExplSyntaxOff
\useleadsheetslibraries{chordnames,chords,shorthands,properties,templates,translations,songs}
% --- Font setup ---
\setmainfont{TeX Gyre Heros}
\newfontfamily\frakfont{UnifrakturMaguntia-Book}[Path=fonts/,Extension=.ttf]
% --- Colors ---
\definecolor{tocrowgray}{gray}{0.92}
\definecolor{tocheadgray}{gray}{0.75}
% --- Page style ---
\pagestyle{fancy}
\fancyhf{}
\fancyfoot[LE]{\large\bfseries\thepage}
\fancyfoot[RO]{\large\bfseries\thepage}
\renewcommand{\headrulewidth}{0pt}
\renewcommand{\footrulewidth}{0pt}
% --- Custom song properties ---
\definesongproperty{alias}
\definesongproperty{note}
\definesongproperty{mundorgel}
\definesongproperty{pfadfinderliederbuch}
% --- leadsheets settings ---
\setleadsheets{
title-template = songbook,
verse/numbered = false,
verse/named = false,
chorus/named = false,
chorus/numbered = false,
after-song = \songendsection,
}
\setchords{
format = \small,
}
% ==========================================================================
% Song TOC matrix
% ==========================================================================
\newcounter{songnumber}
\newcounter{tocrowcount}
\ExplSyntaxOn
\iow_new:N \g__sb_toc_iow
\tl_new:N \l__sb_title_tl
\tl_new:N \l__sb_mo_tl
\tl_new:N \l__sb_pflb_tl
\tl_new:N \l__sb_num_tl
\tl_new:N \l__sb_songid_tl
\bool_new:N \g__sb_toc_opened_bool
\seq_new:N \g__sb_written_seq
% Lazy-open: only truncate the file when first song writes to it
% This ensures the TOC reads the PREVIOUS run's data before truncation
\cs_new_protected:Npn \__sb_ensure_toc_open:
{
\bool_if:NF \g__sb_toc_opened_bool
{
\iow_open:Nn \g__sb_toc_iow { \c_sys_jobname_str .songtoc }
\bool_gset_true:N \g__sb_toc_opened_bool
}
}
\AtEndDocument{
\bool_if:NT \g__sb_toc_opened_bool
{ \iow_close:N \g__sb_toc_iow }
}
\cs_new_protected:Npn \writesongtoc
{
% Use leadsheets song ID to skip duplicate calls (measurement pass)
\tl_set:NV \l__sb_songid_tl \l_leadsheets_current_song_id_tl
\seq_if_in:NVF \g__sb_written_seq \l__sb_songid_tl
{
\seq_gput_right:NV \g__sb_written_seq \l__sb_songid_tl
\__sb_ensure_toc_open:
\stepcounter{songnumber}
\tl_set:Nx \l__sb_num_tl { \int_use:N \c@songnumber }
\tl_set:Nx \l__sb_title_tl { \songproperty{title} }
\tl_set:Nx \l__sb_mo_tl { \songproperty{mundorgel} }
\tl_set:Nx \l__sb_pflb_tl { \songproperty{pfadfinderliederbuch} }
\iow_now:Nx \g__sb_toc_iow
{
\exp_not:N \songtocrow
{ \l__sb_title_tl }
{ \l__sb_mo_tl }
{ \l__sb_pflb_tl }
{ \exp_not:N \pageref { song: \l__sb_num_tl } }
}
}
% Label MUST be outside guard: measurement pass label is discarded (inside vbox),
% but the real pass label survives and gets written to .aux
\tl_set:Nx \l__sb_num_tl { \int_use:N \c@songnumber }
\label{song:\tl_use:N \l__sb_num_tl}
}
\ExplSyntaxOff
% --- Render one TOC row ---
\newcommand{\songtocrow}[4]{%
#1 & #2 & #3 & \cellcolor{tocheadgray}\textbf{#4} \\
\hline
}
% --- Rotated column header ---
\newcommand{\rotheader}[1]{%
\begin{turn}{70}\footnotesize\textbf{#1}\end{turn}%
}
% --- Print the song TOC table ---
\newcommand{\printsongtoc}{%
\thispagestyle{fancy}%
{\Large\bfseries Inhaltsverzeichnis\par}%
\vspace{5mm}%
\footnotesize
\rowcolors{2}{tocrowgray}{white}%
\begin{longtable}{%
>{\raggedright\arraybackslash}p{0.52\textwidth}|%
>{\centering\arraybackslash}p{0.10\textwidth}|%
>{\centering\arraybackslash}p{0.10\textwidth}|%
>{\centering\arraybackslash\columncolor{tocheadgray}}p{0.12\textwidth}%
}
& \rotheader{MO} & \rotheader{PfLB}
& \rotheader{\normalsize Lieder-\newline\normalsize buch} \\
\hline
\endfirsthead
& \rotheader{MO} & \rotheader{PfLB}
& \rotheader{\normalsize Lieder-\newline\normalsize buch} \\
\hline
\endhead
\InputIfFileExists{\jobname.songtoc}{}{}%
\end{longtable}%
}
% ==========================================================================
% Song end section
% ==========================================================================
\newcommand{\songendsection}{%
\vfill
\ifsongproperty{note}{%
{\footnotesize\songproperty{note}\par\smallskip}%
}{}%
\begingroup\footnotesize
\ifsongproperty{lyrics}{%
\ifsongproperty{composer}{%
Worte: \songproperty{lyrics}\par
Weise: \songproperty{composer}\par
}{%
Worte und Weise: \songproperty{lyrics}\par
}%
}{%
\ifsongproperty{composer}{%
Weise: \songproperty{composer}\par
}{}%
}%
\endgroup
\vspace{3mm}%
\begingroup\footnotesize\centering
\begin{tabular}{ccc}
MO & PfLB & Liederbuch \\
\ifsongproperty{mundorgel}{\songproperty{mundorgel}}{} &
\ifsongproperty{pfadfinderliederbuch}{\songproperty{pfadfinderliederbuch}}{} &
\thepage
\end{tabular}\par
\endgroup
\newpage
}
% ==========================================================================
% Song title template
% ==========================================================================
\definesongtitletemplate{songbook}{%
{\LARGE\frakfont\songproperty{title}\par}%
\writesongtoc
\vspace{4mm}%
}
% ==========================================================================
% Image placement
% ==========================================================================
% Full-page filler image (centered, scaled to fit, own page)
% Usage: \fillerpage{images/drawing.png}
\newcommand{\fillerpage}[1]{%
\clearpage
\thispagestyle{empty}%
\vspace*{\fill}%
\begin{center}%
\includegraphics[width=0.85\textwidth,height=0.85\textheight,keepaspectratio]{#1}%
\end{center}%
\vspace*{\fill}%
\clearpage
}
% Inline image within a page (e.g., at end of a song with remaining space)
% Usage: \songimage{images/landscape.png}
\newcommand{\songimage}[1]{%
\begin{center}%
\includegraphics[width=0.8\textwidth,keepaspectratio]{#1}%
\end{center}%
}
% Full-page image with no margins (bleeds to edges)
% Usage: \fullpageimage{images/cover.png}
\newcommand{\fullpageimage}[1]{%
\clearpage
\thispagestyle{empty}%
\newgeometry{margin=0pt}%
\noindent\includegraphics[width=\paperwidth,height=\paperheight]{#1}%
\restoregeometry
\clearpage
}

61
songbook.tex Normal file
View File

@@ -0,0 +1,61 @@
% songbook.tex - Pfadfinder Liederbuch
\documentclass[a5paper, 10pt, twoside]{article}
\usepackage{songbook-style}
\begin{document}
% --- Title page ---
\begin{titlepage}
\centering
\vspace*{3cm}
{\Huge\bfseries Pfadfinder Liederbuch\par}
\vspace{1cm}
{\Large Beispiel-Ausgabe\par}
\vspace{2cm}
{\large 1. Auflage, 2026\par}
\vfill
\end{titlepage}
% --- Foreword / Introductory page ---
\thispagestyle{empty}
{\large\bfseries\itshape
\enquote{Das Volkslied ist nun einmal da --, daran k\"onnen wir nicht
vorbei -- es ergreift uns stark und tief, und die Antwort auf
das Warum? bleiben wir schuldig.}
\par}
\vspace{2mm}
\noindent\rule{\textwidth}{0.4pt}
\vspace{4mm}
\small
So hei\ss t es im Vorwort des wohl bekanntesten Liederbuchs
in der Jugendbewegung, dem \textit{Zupfgeigenhansl}, aus dem Jahr 1913.
Und auch wir erleben auf Fahrt und Lager immer wieder die Kraft des
gemeinsamen Singens. Mit diesem Liederbuch haben wir eine Auswahl
an Liedern aus unterschiedlichen Quellen zusammengetragen.
Singen verbindet uns, macht Freude und ist ein entscheidendes Element
unserer Lager und Fahrt.
\vspace{5mm}
Herzlichst Gut Pfad
\clearpage
% --- Table of Contents ---
\printsongtoc
\clearpage
% --- Filler images can be placed between songs ---
% Example: \fillerpage{images/drawing.png}
% --- Songs (alphabetical) ---
\input{songs/abend-wird-es-wieder}
\input{songs/auf-auf-zum-froehlichen-jagen}
\input{songs/die-gedanken-sind-frei}
\input{songs/hejo-spann-den-wagen-an}
\input{songs/kein-schoener-land}
\end{document}

View File

@@ -1,45 +0,0 @@
book:
title: "Pfadfinder Liederbuch"
subtitle: "Beispiel-Ausgabe"
edition: "1. Auflage, 2026"
format: A5
songs:
directory: "./songs"
order: alphabetical
fonts:
lyrics: { family: "Helvetica", size: 10 }
chords: { family: "Helvetica", size: 9, color: "#333333" }
title: { family: "Helvetica", size: 14 }
metadata: { family: "Helvetica", size: 8 }
toc: { family: "Helvetica", size: 9 }
# To use a custom font file (e.g. Fraktur/Blackletter for titles):
# title: { file: "./fonts/FrakturFont.ttf", size: 16 }
# The file path is relative to the project directory.
# Supported formats: .ttf, .otf
# Custom fonts are embedded in the PDF and support Unicode (including umlauts).
layout:
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
chord_line_spacing: 1
verse_spacing: 6
page_number_position: bottom-outer
images:
directory: "./images"
reference_books:
- id: mundorgel
name: "Mundorgel"
abbreviation: "MO"
- id: pfadfinderliederbuch
name: "Pfadfinderliederbuch"
abbreviation: "PfLB"
toc:
highlight_column: "Seite"
output:
directory: "./output"
filename: "liederbuch.pdf"

View File

@@ -1,27 +0,0 @@
{title: Abend wird es wieder}
{lyricist: Christian Gottlob Barth, 1836}
{composer: Volksweise}
{key: C}
{tags: Abendlied}
{ref: pfadfinderliederbuch 12}
{start_of_verse: Strophe 1}
[C]Abend wird es [G]wieder,
[G]über Wald und [C]Feld
säuselt [F]Frieden [C]nieder,
und es [G]ruht die [C]Welt.
{end_of_verse}
{start_of_verse: Strophe 2}
[C]Nur der Bach er[G]gießet
[G]sich am Felsen [C]dort,
und er [F]braust und [C]fließet
immer, [G]immer [C]fort.
{end_of_verse}
{start_of_verse: Strophe 3}
[C]Und kein Abend [G]bringet
[G]Frieden ihm und [C]Ruh,
keine [F]Glocke [C]klinget
ihm ein [G]Rastlied [C]zu.
{end_of_verse}

View File

@@ -0,0 +1,31 @@
\begin{song}{
title = Abend wird es wieder,
lyrics = {Christian Gottlob Barth, 1836},
composer = Volksweise,
key = C,
tags = Abendlied,
pfadfinderliederbuch = 12,
}
\begin{verse}
\chord{C}Abend wird es \chord{G}wieder, \\
\chord{G}über Wald und \chord{C}Feld \\
säuselt \chord{F}Frieden \chord{C}nieder, \\
und es \chord{G}ruht die \chord{C}Welt.
\end{verse}
\begin{verse}
\chord{C}Nur der Bach er\chord{G}gießet \\
\chord{G}sich am Felsen \chord{C}dort, \\
und er \chord{F}braust und \chord{C}fließet \\
immer, \chord{G}immer \chord{C}fort.
\end{verse}
\begin{verse}
\chord{C}Und kein Abend \chord{G}bringet \\
\chord{G}Frieden ihm und \chord{C}Ruh, \\
keine \chord{F}Glocke \chord{C}klinget \\
ihm ein \chord{G}Rastlied \chord{C}zu.
\end{verse}
\end{song}

View File

@@ -1,26 +0,0 @@
{title: Auf, auf zum fröhlichen Jagen}
{lyricist: Traditionell, 18. Jahrhundert}
{composer: Volksweise}
{key: F}
{tags: Volkslied, Jagd}
{start_of_verse: Strophe 1}
[F]Auf, auf zum fröhlichen [C]Jagen,
auf [C]in die grüne [F]Heid'!
Es [F]gibt nichts Schönres [Bb]auf Erden,
als [C]jetzt zur Herbstes[F]zeit.
{end_of_verse}
{start_of_chorus}
Halli, hallo, halli, hallo,
auf [C]in die grüne [F]Heid'!
{end_of_chorus}
{start_of_verse: Strophe 2}
[F]Der Hirsch, der springt im [C]Walde,
das [C]Reh steht auf der [F]Flur,
die [F]Vöglein singen [Bb]alle
zur [C]schönen Jägerei[F]natur.
{end_of_verse}
{chorus}

View File

@@ -0,0 +1,29 @@
\begin{song}{
title = {Auf, auf zum fröhlichen Jagen},
lyrics = {Traditionell, 18. Jahrhundert},
composer = Volksweise,
key = F,
tags = {Volkslied, Jagd},
}
\begin{verse}
\chord{F}Auf, auf zum fröhlichen \chord{C}Jagen, \\
auf \chord{C}in die grüne \chord{F}Heid'! \\
Es \chord{F}gibt nichts Schönres \chord{Bb}auf Erden, \\
als \chord{C}jetzt zur Herbstes\chord{F}zeit.
\end{verse}
\begin{verse*}
Ref.: \\
Halli, hallo, halli, hallo, \\
auf \chord{C}in die grüne \chord{F}Heid'!
\end{verse*}
\begin{verse}
\chord{F}Der Hirsch, der springt im \chord{C}Walde, \\
das \chord{C}Reh steht auf der \chord{F}Flur, \\
die \chord{F}Vöglein singen \chord{Bb}alle \\
zur \chord{C}schönen Jägerei\chord{F}natur.
\end{verse}
\end{song}

View File

@@ -1,42 +0,0 @@
{title: Die Gedanken sind frei}
{alias: Gedankenfreiheit}
{lyricist: Deutsches Volkslied}
{composer: Deutsches Volkslied, ca. 1810}
{key: G}
{tags: Volkslied, Freiheit}
{note: Eines der bekanntesten deutschen Volkslieder. Text erstmals 1780.}
{ref: mundorgel 42}
{ref: pfadfinderliederbuch 118}
{start_of_verse: Strophe 1}
Die Ge[G]danken sind [D]frei,
wer [D]kann sie er[G]raten?
Sie [G]fliehen vor[C]bei
wie [D]nächtliche [G]Schatten.
Kein [C]Mensch kann sie [G]wissen,
kein [Am]Jäger er[D]schießen.
Es [G]bleibet da[C]bei:
Die Ge[D]danken sind [G]frei!
{end_of_verse}
{start_of_verse: Strophe 2}
Ich [G]denke, was ich [D]will
und [D]was mich be[G]glücket,
doch [G]alles in der [C]Still',
und [D]wie es sich [G]schicket.
Mein [C]Wunsch und Be[G]gehren
kann [Am]niemand ver[D]wehren,
es [G]bleibet da[C]bei:
Die Ge[D]danken sind [G]frei!
{end_of_verse}
{start_of_verse: Strophe 3}
Und [G]sperrt man mich [D]ein
im [D]finsteren [G]Kerker,
das [G]alles sind rein [C]
ver[D]gebliche [G]Werke.
Denn [C]meine Ge[G]danken
zer[Am]reißen die [D]Schranken
und [G]Mauern ent[C]zwei:
Die Ge[D]danken sind [G]frei!
{end_of_verse}

View File

@@ -0,0 +1,46 @@
\begin{song}{
title = Die Gedanken sind frei,
alias = Gedankenfreiheit,
lyrics = Deutsches Volkslied,
composer = {Deutsches Volkslied, ca. 1810},
key = G,
tags = {Volkslied, Freiheit},
note = {Eines der bekanntesten deutschen Volkslieder. Text erstmals 1780.},
mundorgel = 42,
pfadfinderliederbuch = 118,
}
\begin{verse}
Die Ge\chord{G}danken sind \chord{D}frei, \\
wer \chord{D}kann sie er\chord{G}raten? \\
Sie \chord{G}fliehen vor\chord{C}bei \\
wie \chord{D}nächtliche \chord{G}Schatten. \\
Kein \chord{C}Mensch kann sie \chord{G}wissen, \\
kein \chord{Am}Jäger er\chord{D}schießen. \\
Es \chord{G}bleibet da\chord{C}bei: \\
Die Ge\chord{D}danken sind \chord{G}frei!
\end{verse}
\begin{verse}
Ich \chord{G}denke, was ich \chord{D}will \\
und \chord{D}was mich be\chord{G}glücket, \\
doch \chord{G}alles in der \chord{C}Still', \\
und \chord{D}wie es sich \chord{G}schicket. \\
Mein \chord{C}Wunsch und Be\chord{G}gehren \\
kann \chord{Am}niemand ver\chord{D}wehren, \\
es \chord{G}bleibet da\chord{C}bei: \\
Die Ge\chord{D}danken sind \chord{G}frei!
\end{verse}
\begin{verse}
Und \chord{G}sperrt man mich \chord{D}ein \\
im \chord{D}finsteren \chord{G}Kerker, \\
das \chord{G}alles sind rein \\
\chord{C}ver- \chord{D}gebliche \chord{G}Werke. \\
Denn \chord{C}meine Ge\chord{G}danken \\
zer\chord{Am}reißen die \chord{D}Schranken \\
und \chord{G}Mauern ent\chord{C}zwei: \\
Die Ge\chord{D}danken sind \chord{G}frei!
\end{verse}
\end{song}

View File

@@ -1,18 +0,0 @@
{title: Hejo, spann den Wagen an}
{lyricist: Traditionell}
{composer: Traditionell}
{key: Am}
{tags: Kanon, Fahrt}
{ref: mundorgel 15}
{start_of_verse: Kanon}
[Am]Hejo, spann den Wagen an,
denn der [G]Wind treibt [Am]Regen übers Land.
[Am]Hol die goldnen Garben rein,
denn der [G]Wind treibt [Am]Regen übers Land.
{end_of_verse}
{start_of_verse: 2. Stimme}
[Am]Hejo, spann den Wagen an,
denn der [G]Wind treibt [Am]Regen übers Land.
{end_of_verse}

View File

@@ -0,0 +1,24 @@
\begin{song}{
title = {Hejo, spann den Wagen an},
lyrics = Traditionell,
composer = Traditionell,
key = Am,
tags = {Kanon, Fahrt},
mundorgel = 15,
}
\begin{verse*}
Kanon: \\
\chord{Am}Hejo, spann den Wagen an, \\
denn der \chord{G}Wind treibt \chord{Am}Regen übers Land. \\
\chord{Am}Hol die goldnen Garben rein, \\
denn der \chord{G}Wind treibt \chord{Am}Regen übers Land.
\end{verse*}
\begin{verse*}
2. Stimme: \\
\chord{Am}Hejo, spann den Wagen an, \\
denn der \chord{G}Wind treibt \chord{Am}Regen übers Land.
\end{verse*}
\end{song}

View File

@@ -1,33 +0,0 @@
{title: Kein schöner Land}
{alias: Kein schöner Land in dieser Zeit}
{lyricist: Anton Wilhelm von Zuccalmaglio}
{composer: Volksweise, 1840}
{key: D}
{tags: Volkslied, Abendlied}
{note: Veröffentlicht 1840 in "Deutsche Volkslieder mit ihren Original-Weisen".}
{ref: mundorgel 88}
{ref: pfadfinderliederbuch 65}
{start_of_verse: Strophe 1}
Kein [D]schöner Land in [A]dieser Zeit,
als [A]hier das unsre [D]weit und breit,
wo [D]wir uns [G]finden
wohl [D]unter [A]Linden
zur [D]Abend[A]zeit.
{end_of_verse}
{start_of_verse: Strophe 2}
Da [D]haben wir so [A]manche Stund'
ge[A]sessen da in [D]froher Rund'
und [D]taten [G]singen,
die [D]Lieder [A]klingen
im [D]Eichen[A]grund.
{end_of_verse}
{start_of_verse: Strophe 3}
Dass [D]wir uns hier in [A]diesem Tal
noch [A]treffen so viel [D]hundertmal,
Gott [D]mag es [G]schenken,
Gott [D]mag es [A]lenken,
er [D]hat die [A]Gnad'.
{end_of_verse}

View File

@@ -0,0 +1,37 @@
\begin{song}{
title = Kein schöner Land,
alias = {Kein schöner Land in dieser Zeit},
lyrics = {Anton Wilhelm von Zuccalmaglio},
composer = {Volksweise, 1840},
key = D,
tags = {Volkslied, Abendlied},
note = {Veröffentlicht 1840 in ``Deutsche Volkslieder mit ihren Original-Weisen''.},
mundorgel = 88,
pfadfinderliederbuch = 65,
}
\begin{verse}
Kein \chord{D}schöner Land in \chord{A}dieser Zeit, \\
als \chord{A}hier das unsre \chord{D}weit und breit, \\
wo \chord{D}wir uns \chord{G}finden \\
wohl \chord{D}unter \chord{A}Linden \\
zur \chord{D}Abend- \chord{A}zeit.
\end{verse}
\begin{verse}
Da \chord{D}haben wir so \chord{A}manche Stund' \\
ge\chord{A}sessen da in \chord{D}froher Rund' \\
und \chord{D}taten \chord{G}singen, \\
die \chord{D}Lieder \chord{A}klingen \\
im \chord{D}Eichen- \chord{A}grund.
\end{verse}
\begin{verse}
Dass \chord{D}wir uns hier in \chord{A}diesem Tal \\
noch \chord{A}treffen so viel \chord{D}hundertmal, \\
Gott \chord{D}mag es \chord{G}schenken, \\
Gott \chord{D}mag es \chord{A}lenken, \\
er \chord{D}hat die \chord{A}Gnad'.
\end{verse}
\end{song}