Compare commits
6 Commits
b339c10ca0
...
v0.3.0-lat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93f451eef9 | ||
|
|
ab00b710b1 | ||
|
|
5a63067b93 | ||
|
|
cae0c52b67 | ||
|
|
692be693e9 | ||
|
|
4024d0e421 |
35
.gitignore
vendored
35
.gitignore
vendored
@@ -1,22 +1,29 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
buildSrc/build/
|
||||
# LaTeX build artifacts
|
||||
*.aux
|
||||
*.log
|
||||
*.out
|
||||
*.toc
|
||||
*.fls
|
||||
*.fdb_latexmk
|
||||
*.synctex.gz
|
||||
*.synctex(busy)
|
||||
*.sxd
|
||||
*.sxc
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
# Output directory
|
||||
output/
|
||||
|
||||
# OS
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Output
|
||||
output/
|
||||
|
||||
# Kotlin
|
||||
*.class
|
||||
# Editor files
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
*~
|
||||
*.swp
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
__pycache__/
|
||||
|
||||
118
CLAUDE.md
118
CLAUDE.md
@@ -2,79 +2,83 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Test Commands
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Build everything
|
||||
gradle build
|
||||
# Build the songbook PDF (two-pass for TOC)
|
||||
make
|
||||
|
||||
# Run all tests
|
||||
gradle test
|
||||
# Remove auxiliary files
|
||||
make clean
|
||||
|
||||
# Run tests for a specific module
|
||||
gradle :parser:test
|
||||
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
|
||||
# Remove everything including PDF
|
||||
make distclean
|
||||
```
|
||||
|
||||
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 → Measure → Paginate → Render
|
||||
|
||||
`SongbookPipeline` (in `app`) orchestrates the full flow:
|
||||
1. `ConfigParser` reads `songbook.yaml` → `BookConfig`
|
||||
2. `ChordProParser` reads `.chopro` files → `Song` objects
|
||||
3. `Validator` checks config and songs
|
||||
4. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
|
||||
5. `TocGenerator` estimates TOC page count and creates entries
|
||||
6. `PaginationEngine` arranges songs into pages (greedy spread packing)
|
||||
7. `PdfBookRenderer` generates the PDF via OpenPDF
|
||||
|
||||
**Module dependency graph:**
|
||||
```
|
||||
model ← parser
|
||||
model ← layout
|
||||
model ← renderer-pdf
|
||||
parser, layout, renderer-pdf ← app
|
||||
app ← cli (Clikt)
|
||||
app, parser ← gui (Compose Desktop)
|
||||
songbook.tex # Main document (title page, TOC, song inputs)
|
||||
songbook-style.sty # Style package (geometry, fonts, leadsheets config)
|
||||
songs/ # One .tex file per song
|
||||
fonts/ # Font files (UnifrakturMaguntia for titles)
|
||||
images/ # Filler images (empty for now)
|
||||
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.
|
||||
|
||||
## Key Types
|
||||
|
||||
- `Song` → sections → `SongLine` → `LineSegment(chord?, text)` — chord is placed above the text segment
|
||||
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`
|
||||
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
|
||||
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
|
||||
Pure LaTeX songbook using the `leadsheets` package with LuaLaTeX. The style matches the Carmina Leonis songbook format:
|
||||
- Song titles in Fraktur/blackletter font (UnifrakturMaguntia)
|
||||
- Chords above lyrics in regular weight, black
|
||||
- No verse labels (verses separated by blank lines)
|
||||
- Metadata (Worte/Weise) at bottom of each song page
|
||||
- Reference book cross-references (MO, PfLB) in footer
|
||||
- Each song starts on a new page
|
||||
- A5 twoside format with page numbers at bottom-outer
|
||||
|
||||
## Song Format
|
||||
|
||||
ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
|
||||
Each song uses the `leadsheets` `song` environment:
|
||||
|
||||
## Test Patterns
|
||||
```latex
|
||||
\begin{song}{
|
||||
title = Song Title,
|
||||
lyrics = Lyricist,
|
||||
composer = Composer,
|
||||
key = G,
|
||||
mundorgel = 42,
|
||||
pfadfinderliederbuch = 118,
|
||||
note = {Optional note text.},
|
||||
}
|
||||
|
||||
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.
|
||||
\begin{verse}
|
||||
\chord{G}Lyrics with \chord{D}chords above. \\
|
||||
Next \chord{C}line here.
|
||||
\end{verse}
|
||||
|
||||
## Package
|
||||
\begin{verse}
|
||||
Second verse without chords (or with).
|
||||
\end{verse}
|
||||
|
||||
All code under `de.pfadfinder.songbook.*` — subpackages match module names (`.model`, `.parser`, `.layout`, `.renderer.pdf`, `.app`, `.cli`, `.gui`).
|
||||
\end{song}
|
||||
```
|
||||
|
||||
**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)
|
||||
|
||||
## Style Details (songbook-style.sty)
|
||||
|
||||
- Page geometry: A5, margins (top 15mm, bottom 20mm, inner 20mm, outer 12mm)
|
||||
- Body font: TeX Gyre Heros (Helvetica clone)
|
||||
- Title font: UnifrakturMaguntia (Fraktur/blackletter, from `fonts/` directory)
|
||||
- Chord format: small, regular weight, black
|
||||
- Song title template: Fraktur title only (metadata rendered at bottom via `after-song` hook)
|
||||
- Reference style based on Carmina Leonis (Pfadfinder scout songbook)
|
||||
|
||||
23
Makefile
Normal file
23
Makefile
Normal 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
|
||||
298
all-songs.tex
Normal file
298
all-songs.tex
Normal file
@@ -0,0 +1,298 @@
|
||||
% Auto-generated list of all songs (alphabetical order)
|
||||
% Generated by import-songs.py
|
||||
|
||||
\input{songs/abends-gehn-die-liebespaare}
|
||||
\input{songs/abends-treten-elche}
|
||||
\input{songs/abends-wenn-das-tageslicht}
|
||||
\input{songs/ade-nun-zur-guten-nacht}
|
||||
\input{songs/alle-strassen}
|
||||
\input{songs/alle-die-mit-uns-auf-kaperfahrt}
|
||||
\input{songs/allzeit-bereit-bundeslied-der-cpd}
|
||||
\input{songs/almost-heaven}
|
||||
\input{songs/als-wir-nach-frankreich}
|
||||
\input{songs/als-wir-noch-knaben-waren}
|
||||
\input{songs/am-alten-hafen-piratenhafen}
|
||||
\input{songs/am-brunnen-vor-dem-tore}
|
||||
\input{songs/am-ural}
|
||||
\input{songs/am-westermanns-loenstief}
|
||||
\input{songs/an-de-eck}
|
||||
\input{songs/an-land}
|
||||
\input{songs/andre-die-das-land}
|
||||
\input{songs/auf-vielen-strassen}
|
||||
\input{songs/balkanlied}
|
||||
\input{songs/ballade-von-bergen}
|
||||
\input{songs/ballade-von-der-gemeinsamen-zeit-vorspiel-d-a-d-g}
|
||||
\input{songs/banner}
|
||||
\input{songs/bella-ciao}
|
||||
\input{songs/big-bomb-dolly-aus-dover}
|
||||
\input{songs/bin-ja-nur-ein-armer-zigeuner}
|
||||
\input{songs/birkenring}
|
||||
\input{songs/bis-in-die-roten-morgenstunden}
|
||||
\input{songs/brennt-die-sonne}
|
||||
\input{songs/bruder-nun-wird-es-abend}
|
||||
\input{songs/burschen-burschen}
|
||||
\input{songs/buendische-vaganten}
|
||||
\input{songs/buergerlied}
|
||||
\input{songs/come-by-the-hills}
|
||||
\input{songs/das-gotenlied}
|
||||
\input{songs/das-leben}
|
||||
\input{songs/das-lilienbanner}
|
||||
\input{songs/das-schiff-im-nebel}
|
||||
\input{songs/dat-du-min-leewsten-buest}
|
||||
\input{songs/dein-ist-dein-leben-vorspiel-e-c-g-d}
|
||||
\input{songs/der-da-vorn-so-laut}
|
||||
\input{songs/der-geist-der-fahrt}
|
||||
\input{songs/der-geist-ist-mued}
|
||||
\input{songs/der-holzschuhmann}
|
||||
\input{songs/der-kirchenmausrock}
|
||||
\input{songs/der-kleine-troll}
|
||||
\input{songs/der-lang-genug}
|
||||
\input{songs/der-mond-ist-aufgegangen}
|
||||
\input{songs/der-papagei-ein-vogel-ist}
|
||||
\input{songs/der-pfahl}
|
||||
\input{songs/der-rabe}
|
||||
\input{songs/der-tag-begann}
|
||||
\input{songs/der-tod-reit-auf}
|
||||
\input{songs/der-wagen}
|
||||
\input{songs/der-zug-faehrt-auf}
|
||||
\input{songs/die-affen-rasen}
|
||||
\input{songs/die-ballade-vom-roten-haar}
|
||||
\input{songs/die-blauen-dragoner}
|
||||
\input{songs/die-daemmerung-faellt}
|
||||
\input{songs/die-eisenfaust}
|
||||
\input{songs/die-freie-republik}
|
||||
\input{songs/die-gedanken}
|
||||
\input{songs/die-glocken}
|
||||
\input{songs/die-grauen-nebel}
|
||||
\input{songs/die-herren-waren-bei-laune}
|
||||
\input{songs/die-klampfen-erklingen}
|
||||
\input{songs/die-lappen-hoch}
|
||||
\input{songs/die-mazurka}
|
||||
\input{songs/die-nacht-ist-nicht-allein-zum-schlafen-da}
|
||||
\input{songs/die-roten-fahnen}
|
||||
\input{songs/die-sandbank}
|
||||
\input{songs/die-schluchten-des-balkan}
|
||||
\input{songs/die-sonne-geht}
|
||||
\input{songs/die-strasse-gleitet}
|
||||
\input{songs/die-trommel-her}
|
||||
\input{songs/die-weber}
|
||||
\input{songs/die-zunft-der-strassenbrueder}
|
||||
\input{songs/dort-an-dem-ueferchen}
|
||||
\input{songs/drei-rote-pfiffe}
|
||||
\input{songs/drei-tropfen-blut-chume-geselle}
|
||||
\input{songs/du-machst-kleinholz}
|
||||
\input{songs/durch-die-morgenroten-scheiben}
|
||||
\input{songs/daemmert-von-fern}
|
||||
\input{songs/edelweisspiraten}
|
||||
\input{songs/eh-die-sonne}
|
||||
\input{songs/ein-hase-sass-im-tiefen-tal}
|
||||
\input{songs/ein-hotdog-unten-am-hafen}
|
||||
\input{songs/ein-kleiner-matrose}
|
||||
\input{songs/ein-landsknecht}
|
||||
\input{songs/ein-neuer-tag-beginnt}
|
||||
\input{songs/ein-stolzes-schiff}
|
||||
\input{songs/eines-morgens-partisanenlied}
|
||||
\input{songs/eines-morgens-ging}
|
||||
\input{songs/einst-macht-ich}
|
||||
\input{songs/endlich-trocknet-der-landstrasse}
|
||||
\input{songs/endlos-lang}
|
||||
\input{songs/endlos-sind-jene-strassen}
|
||||
\input{songs/ensemble-on-est-mieux-intro-e-a-c-h7}
|
||||
\input{songs/erklingen-leise-lieder}
|
||||
\input{songs/es-dunkelt-schon}
|
||||
\input{songs/es-gibt-nur-wasser}
|
||||
\input{songs/es-ist-an-der-zeit}
|
||||
\input{songs/es-ist-ein-schnitter}
|
||||
\input{songs/es-liegen-drei-glaenzende-kugeln}
|
||||
\input{songs/es-liegt-etwas-auf-den-strassen}
|
||||
\input{songs/es-soll-sich-der-mensch}
|
||||
\input{songs/es-tropft-von-helm-und-saebel}
|
||||
\input{songs/es-war-an-einem-sommertag}
|
||||
\input{songs/es-war-ein-koenig}
|
||||
\input{songs/es-war-in-einer-regennacht}
|
||||
\input{songs/es-wollt-ein-maegdlein}
|
||||
\input{songs/falado}
|
||||
\input{songs/fields-of-athenry}
|
||||
\input{songs/finnlandlied}
|
||||
\input{songs/fordre-niemand}
|
||||
\input{songs/fresenhof}
|
||||
\input{songs/freunde-das-leben-seid-ihr}
|
||||
\input{songs/frueher-da-war-ich}
|
||||
\input{songs/fruehling-dringt-in-den-norden}
|
||||
\input{songs/geburtstagslied}
|
||||
\input{songs/gehe-nicht-o-gregor}
|
||||
\input{songs/gelbe-blaetter-fallen-im-wind}
|
||||
\input{songs/gestern-brueder}
|
||||
\input{songs/gospodar-dein-grossgut}
|
||||
\input{songs/griechischer-fruehling}
|
||||
\input{songs/grosser-gott-wir-loben-dich}
|
||||
\input{songs/graefin-anne}
|
||||
\input{songs/gut-wieder-hier-zu-sein}
|
||||
\input{songs/gute-nacht-kameraden}
|
||||
\input{songs/hans-spielmann}
|
||||
\input{songs/hell-strahlt-die-sonne}
|
||||
\input{songs/heulender-motor}
|
||||
\input{songs/heute-hier}
|
||||
\input{songs/hier-waechst-kein-ahorn}
|
||||
\input{songs/hoch-lebe-der-mann-mit-dem-hut}
|
||||
\input{songs/hochzeit}
|
||||
\input{songs/hohe-tannen}
|
||||
\input{songs/how-many-roads-blowin-in-the-wind}
|
||||
\input{songs/hymn-intro-e-esus4-e-esus4-e}
|
||||
\input{songs/hoerst-du-den-wind-bundeslied-der-esm}
|
||||
\input{songs/ich-kann-dich-sehen}
|
||||
\input{songs/ich-komme-schon}
|
||||
\input{songs/ich-komme-dir-zu-sagen-versprechenslied}
|
||||
\input{songs/ich-moecht-mit-einem-zirkus-ziehn}
|
||||
\input{songs/ich-reise-uebers-gruene-land}
|
||||
\input{songs/ich-und-ein-fass-voller-wein}
|
||||
\input{songs/ich-war-noch-so-jung-bettelvogt}
|
||||
\input{songs/ihr-huebschen-jungen-reiter}
|
||||
\input{songs/ihr-woelfe-kommt-und-schliesst-den-kreis}
|
||||
\input{songs/im-morgennebel}
|
||||
\input{songs/in-dem-dunklem-wald-von-paganovo}
|
||||
\input{songs/in-des-waldes-lola}
|
||||
\input{songs/in-die-sonne}
|
||||
\input{songs/in-junkers-kneipe}
|
||||
\input{songs/islandlied}
|
||||
\input{songs/jalava-lied}
|
||||
\input{songs/jasmin}
|
||||
\input{songs/jauchzende-jungen}
|
||||
\input{songs/jeden-abend-jerchenkow}
|
||||
\input{songs/jenseits-des-tales}
|
||||
\input{songs/jubilaeumslied-der-esm}
|
||||
\input{songs/joerg-von-frundsberg}
|
||||
\input{songs/kaffee-und-karin}
|
||||
\input{songs/kameraden-jagt-die-pferde}
|
||||
\input{songs/kameraden-wann-sehen-wir-uns-wieder}
|
||||
\input{songs/karl-der-kaefer}
|
||||
\input{songs/kein-schoener-land}
|
||||
\input{songs/klingt-ein-lied-durch-die-nacht-piratenlied}
|
||||
\input{songs/kommt-ihr-menschen}
|
||||
\input{songs/komodowaran-intro-ad7g-ad7g}
|
||||
\input{songs/land-der-dunklen-waelder}
|
||||
\input{songs/lasst-die-banner}
|
||||
\input{songs/leave-her-johnny}
|
||||
\input{songs/leut-die-leut}
|
||||
\input{songs/lord-of-the-dance}
|
||||
\input{songs/laender-fahrten-abenteuer}
|
||||
\input{songs/loewen-sind-jetzt-los}
|
||||
\input{songs/man-sagt}
|
||||
\input{songs/meersalz-seht}
|
||||
\input{songs/mein-ganzes-leben}
|
||||
\input{songs/mein-kleines-boot}
|
||||
\input{songs/meine-sonne-will-ich-fragen}
|
||||
\input{songs/michel-warum-weinest-du}
|
||||
\input{songs/miners-song}
|
||||
\input{songs/molly-malone}
|
||||
\input{songs/moorsoldaten}
|
||||
\input{songs/maedchen-maenner-meister}
|
||||
\input{songs/nacht-in-portugal}
|
||||
\input{songs/nachts-auf-dem-dorfplatz}
|
||||
\input{songs/nachts-steht-hunger}
|
||||
\input{songs/nehmt-abschied-brueder}
|
||||
\input{songs/nicht-nur-nebenbei}
|
||||
\input{songs/nichts-fuer-suesse-ziehharmonika}
|
||||
\input{songs/noch-lange-sassen-wir}
|
||||
\input{songs/nordwaerts}
|
||||
\input{songs/nun-greift-in-die-saiten}
|
||||
\input{songs/nun-lustig-lustig}
|
||||
\input{songs/oh-fischer}
|
||||
\input{songs/oh-bootsmann}
|
||||
\input{songs/originale-3-strophe}
|
||||
\input{songs/panama}
|
||||
\input{songs/papst-sultan}
|
||||
\input{songs/platoff}
|
||||
\input{songs/refrain-2x}
|
||||
\input{songs/rote-ritterscharen}
|
||||
\input{songs/roter-mond}
|
||||
\input{songs/roter-wein-im-becher}
|
||||
\input{songs/santiano}
|
||||
\input{songs/santiano-2}
|
||||
\input{songs/scarborough-fair}
|
||||
\input{songs/schilf-bleicht}
|
||||
\input{songs/schlaf-mein-bub}
|
||||
\input{songs/schliess-aug-und-ohr}
|
||||
\input{songs/sei-der-abend}
|
||||
\input{songs/she-hangs-her-head-sad-lisa}
|
||||
\input{songs/siehst-du-die-feuer}
|
||||
\input{songs/singt-freunde-lasst-die-klampfen}
|
||||
\input{songs/so-trolln-wir-uns}
|
||||
\input{songs/sonnenschein-und-wilde-feste}
|
||||
\input{songs/star-of-county-down}
|
||||
\input{songs/stiebt-vom-kasbek}
|
||||
\input{songs/stille-tage}
|
||||
\input{songs/strassen-auf-und-strassen-ab}
|
||||
\input{songs/sturm-bricht-los}
|
||||
\input{songs/sturm-und-drang}
|
||||
\input{songs/tief-im-busch}
|
||||
\input{songs/trinklied-vor-dem-abgang}
|
||||
\input{songs/trommeln-und-pfeifen}
|
||||
\input{songs/turm-um-uns}
|
||||
\input{songs/ty-morjak-deutscher-text}
|
||||
\input{songs/und-am-abend}
|
||||
\input{songs/und-der-herbst}
|
||||
\input{songs/und-die-morgenfruehe}
|
||||
\input{songs/unglueck-vor-mir}
|
||||
\input{songs/unser-stammesbus}
|
||||
\input{songs/unter-den-toren}
|
||||
\input{songs/vagabundenlied}
|
||||
\input{songs/verliebt-in-du-intro-c-g-a-a-2x}
|
||||
\input{songs/viva-la-feria}
|
||||
\input{songs/vom-barette}
|
||||
\input{songs/von-allen-blauen-huegeln}
|
||||
\input{songs/von-der-festung-droehnt}
|
||||
\input{songs/von-ueberall}
|
||||
\input{songs/wach-nun-auf}
|
||||
\input{songs/was-gehn-euch-meine}
|
||||
\input{songs/was-helfen-mir-tausend-dukaten}
|
||||
\input{songs/was-kann-ich-denn-dafuer}
|
||||
\input{songs/was-keiner-wagt}
|
||||
\input{songs/was-sollen-wir-trinken}
|
||||
\input{songs/weisser-sand}
|
||||
\input{songs/welle-wogte}
|
||||
\input{songs/wem-gott-will-rechte-gunst-erweisen}
|
||||
\input{songs/wenn-alle-bruennlein}
|
||||
\input{songs/wenn-der-abend-naht}
|
||||
\input{songs/wenn-der-fruehling-kommt}
|
||||
\input{songs/wenn-die-bunten-fahnen}
|
||||
\input{songs/wenn-die-zeit}
|
||||
\input{songs/wenn-hell-die-goldne-sonne}
|
||||
\input{songs/wenn-ich-des-morgens}
|
||||
\input{songs/weronika-mit-w}
|
||||
\input{songs/what-shall-we-do-drunken-sailor}
|
||||
\input{songs/whats-right-ye-jacobites}
|
||||
\input{songs/whiskey-in-the-jar}
|
||||
\input{songs/wie-ein-fest-nach-langer-trauer}
|
||||
\input{songs/wie-kommts-dass-du}
|
||||
\input{songs/wie-schoen-blueht}
|
||||
\input{songs/wild-rover}
|
||||
\input{songs/wilde-gesellen}
|
||||
\input{songs/wilde-reiter}
|
||||
\input{songs/wildgaense}
|
||||
\input{songs/wir-drei-wir-gehn-jetzt}
|
||||
\input{songs/wir-fahren-uebers-weite-meer}
|
||||
\input{songs/wir-haben-das-sehen}
|
||||
\input{songs/wir-kamen-einst}
|
||||
\input{songs/wir-lagen-vor-madagaskar}
|
||||
\input{songs/wir-lieben-die-stuerme}
|
||||
\input{songs/wir-rufen-zu-dir-michaelslied}
|
||||
\input{songs/wir-sassen-in-johnnys-spelunke}
|
||||
\input{songs/wir-sind-des-geyers}
|
||||
\input{songs/wir-sind-durch-deutschland-gefahren}
|
||||
\input{songs/wir-sind-eine-kleine}
|
||||
\input{songs/wir-sind-kameraden}
|
||||
\input{songs/wir-sind-wieder-da-st-goar-hymne}
|
||||
\input{songs/wir-sitzen-im-rostigen-haifsch}
|
||||
\input{songs/wir-sitzen-zu-pferde}
|
||||
\input{songs/wir-wollen-zu-land-ausfahren}
|
||||
\input{songs/wir-wolln-im-gruenen-wald}
|
||||
\input{songs/wo-schorle-der-apfelschorlen-blues}
|
||||
\input{songs/wohl-ueber-erde}
|
||||
\input{songs/wohlauf-die-luft}
|
||||
\input{songs/wollt-ihr-hoeren}
|
||||
\input{songs/wos-nur-felsen}
|
||||
\input{songs/zieh-meiner-strasse}
|
||||
\input{songs/zogen-einst}
|
||||
\input{songs/zogen-viele-strassen}
|
||||
\input{songs/ueber-meiner-heimat}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package de.pfadfinder.songbook.app
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import de.pfadfinder.songbook.parser.*
|
||||
import de.pfadfinder.songbook.layout.*
|
||||
import de.pfadfinder.songbook.renderer.pdf.*
|
||||
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) {
|
||||
|
||||
fun build(): BuildResult {
|
||||
// 1. Parse config
|
||||
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 config = ConfigParser.parse(configFile)
|
||||
|
||||
// Validate config
|
||||
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")))
|
||||
}
|
||||
|
||||
logger.info { "Found ${songFiles.size} song files" }
|
||||
|
||||
val songs = mutableListOf<Song>()
|
||||
val allErrors = mutableListOf<ValidationError>()
|
||||
|
||||
for (file in songFiles) {
|
||||
try {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
val songErrors = Validator.validateSong(song, file.name)
|
||||
if (songErrors.isNotEmpty()) {
|
||||
allErrors.addAll(songErrors)
|
||||
} else {
|
||||
songs.add(song)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
if (allErrors.isNotEmpty()) {
|
||||
return BuildResult(false, errors = allErrors)
|
||||
}
|
||||
|
||||
// Sort songs
|
||||
val sortedSongs = when (config.songs.order) {
|
||||
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
|
||||
else -> songs // manual order = file order
|
||||
}
|
||||
|
||||
logger.info { "Parsed ${sortedSongs.size} songs" }
|
||||
|
||||
// 3. Measure songs
|
||||
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 tocPages = tocGenerator.estimateTocPages(sortedSongs)
|
||||
|
||||
val paginationEngine = PaginationEngine(config)
|
||||
val pages = paginationEngine.paginate(measuredSongs, tocPages)
|
||||
|
||||
val tocEntries = tocGenerator.generate(pages, tocPages)
|
||||
|
||||
val layoutResult = LayoutResult(
|
||||
tocPages = tocPages,
|
||||
pages = pages,
|
||||
tocEntries = tocEntries
|
||||
)
|
||||
|
||||
logger.info { "Layout: ${tocPages} TOC pages, ${pages.size} content pages" }
|
||||
|
||||
// 5. Render PDF
|
||||
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, ${pages.size + tocPages} pages" }
|
||||
|
||||
return BuildResult(
|
||||
success = true,
|
||||
outputFile = outputFile,
|
||||
songCount = sortedSongs.size,
|
||||
pageCount = pages.size + tocPages
|
||||
)
|
||||
}
|
||||
|
||||
fun validate(): List<ValidationError> {
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
if (!configFile.exists()) {
|
||||
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
||||
}
|
||||
|
||||
val config = ConfigParser.parse(configFile)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10")
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
fonts/UnifrakturMaguntia-Book.ttf
Normal file
BIN
fonts/UnifrakturMaguntia-Book.ttf
Normal file
Binary file not shown.
@@ -1 +0,0 @@
|
||||
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
|
||||
@@ -1,20 +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")
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "de.pfadfinder.songbook.gui.AppKt"
|
||||
}
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
package de.pfadfinder.songbook.gui
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
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.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 statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
||||
var isRunning by remember { mutableStateOf(false) }
|
||||
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun loadSongs(path: String) {
|
||||
val projectDir = File(path)
|
||||
songs = emptyList()
|
||||
if (!projectDir.isDirectory) return
|
||||
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
val songsDir = if (configFile.exists()) {
|
||||
try {
|
||||
val config = de.pfadfinder.songbook.parser.ConfigParser.parse(configFile)
|
||||
File(projectDir, config.songs.directory)
|
||||
} catch (_: Exception) {
|
||||
File(projectDir, "songs")
|
||||
}
|
||||
} else {
|
||||
File(projectDir, "songs")
|
||||
}
|
||||
|
||||
if (!songsDir.isDirectory) return
|
||||
|
||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
songs = 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
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))
|
||||
|
||||
// Song list
|
||||
Text(
|
||||
text = "Lieder (${songs.size}):",
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
||||
) {
|
||||
if (songs.isEmpty() && projectPath.isNotBlank()) {
|
||||
item {
|
||||
Text(
|
||||
"Keine Lieder gefunden. Bitte Projektverzeichnis prüfen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else if (projectPath.isBlank()) {
|
||||
item {
|
||||
Text(
|
||||
"Bitte ein Projektverzeichnis auswählen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(songs) { song ->
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp, horizontal = 8.dp)) {
|
||||
Text(song.title, modifier = Modifier.weight(1f))
|
||||
Text(song.fileName, color = Color.Gray, fontSize = 12.sp)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
adapter = rememberScrollbarAdapter(listState)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 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 {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
SongbookPipeline(File(projectPath)).build()
|
||||
} 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
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && projectPath.isNotBlank()
|
||||
) {
|
||||
Text("Buch erstellen")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (projectPath.isBlank()) return@Button
|
||||
isRunning = true
|
||||
lastBuildResult = null
|
||||
statusMessages = listOf(StatusMessage("Validierung läuft...", 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 geöffnet werden: ${e.message}",
|
||||
MessageType.ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text("PDF öffnen")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
1044
import-songs.py
Normal file
1044
import-songs.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
plugins {
|
||||
id("songbook-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":model"))
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,72 +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 line (composer/lyricist)
|
||||
if (song.composer != null || song.lyricist != null) {
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
if (song.notes.isNotEmpty()) {
|
||||
heightMm += 1.5f // gap
|
||||
for (note in song.notes) {
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
|
||||
}
|
||||
}
|
||||
|
||||
val pageCount = if (heightMm <= contentHeightMm) 1 else 2
|
||||
return MeasuredSong(song, heightMm, pageCount)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,261 +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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
plugins {
|
||||
id("songbook-conventions")
|
||||
}
|
||||
@@ -1,67 +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()
|
||||
)
|
||||
|
||||
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 = 3f, // mm
|
||||
val verseSpacing: Float = 4f, // mm
|
||||
val pageNumberPosition: String = "bottom-outer"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
package de.pfadfinder.songbook.model
|
||||
|
||||
import java.io.OutputStream
|
||||
|
||||
interface BookRenderer {
|
||||
fun render(layout: LayoutResult, config: BookConfig, output: OutputStream)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,26 +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 LayoutResult(
|
||||
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
|
||||
)
|
||||
@@ -1,31 +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>)
|
||||
|
||||
data class LineSegment(
|
||||
val chord: String? = null, // null = no chord above this segment
|
||||
val text: String
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,199 +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>()
|
||||
|
||||
fun flushSection() {
|
||||
if (currentType != null) {
|
||||
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
|
||||
currentType = null
|
||||
currentLabel = null
|
||||
currentLines = mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
for (rawLine in lines) {
|
||||
val line = rawLine.trimEnd()
|
||||
|
||||
// Skip comments
|
||||
if (line.trimStart().startsWith("#")) continue
|
||||
|
||||
// Skip empty lines
|
||||
if (line.isBlank()) 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
|
||||
}
|
||||
"end_of_verse", "eov" -> {
|
||||
flushSection()
|
||||
}
|
||||
"start_of_chorus", "soc" -> {
|
||||
flushSection()
|
||||
currentType = SectionType.CHORUS
|
||||
currentLabel = value
|
||||
}
|
||||
"end_of_chorus", "eoc" -> {
|
||||
flushSection()
|
||||
}
|
||||
"start_of_repeat", "sor" -> {
|
||||
flushSection()
|
||||
currentType = SectionType.REPEAT
|
||||
currentLabel = value
|
||||
}
|
||||
"end_of_repeat", "eor" -> {
|
||||
flushSection()
|
||||
}
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package de.pfadfinder.songbook.parser
|
||||
|
||||
import de.pfadfinder.songbook.model.BookConfig
|
||||
import de.pfadfinder.songbook.model.Song
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
}
|
||||
@@ -1,488 +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"
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package de.pfadfinder.songbook.parser
|
||||
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
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 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"
|
||||
}
|
||||
}
|
||||
@@ -1,209 +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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,248 +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 TOC first
|
||||
if (layout.tocEntries.isNotEmpty()) {
|
||||
tocRenderer.render(document, writer, layout.tocEntries)
|
||||
// Add blank pages to fill TOC allocation
|
||||
repeat(layout.tocPages - 1) {
|
||||
document.newPage()
|
||||
// Force new page even if empty
|
||||
writer.directContent.let { cb ->
|
||||
cb.beginText()
|
||||
cb.endText()
|
||||
}
|
||||
}
|
||||
document.newPage()
|
||||
}
|
||||
|
||||
// Render content pages
|
||||
var currentPageNum = 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
|
||||
)
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
pageDecorator.addPageNumber(cb, currentPageNum, pageSize.width, pageSize.height)
|
||||
currentPageNum++
|
||||
}
|
||||
|
||||
document.close()
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
var y = contentTop
|
||||
|
||||
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)
|
||||
val metaParts = mutableListOf<String>()
|
||||
song.composer?.let { metaParts.add("M: $it") }
|
||||
song.lyricist?.let { metaParts.add("T: $it") }
|
||||
if (metaParts.isNotEmpty()) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(metaParts.joinToString(" / "))
|
||||
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
|
||||
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
|
||||
// For simplicity in this implementation, render all sections on pageIndex 0
|
||||
// A more sophisticated implementation would split sections across pages
|
||||
val sections = if (pageIndex == 0) song.sections else emptyList()
|
||||
|
||||
for (section in sections) {
|
||||
// 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
|
||||
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
|
||||
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) {
|
||||
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
|
||||
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 notes at the bottom
|
||||
if (pageIndex == 0 && song.notes.isNotEmpty()) {
|
||||
y -= 4f
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
for (note in song.notes) {
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(note)
|
||||
cb.endText()
|
||||
y -= metaSize * 1.5f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +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
|
||||
|
||||
class PdfFontMetrics : FontMetrics {
|
||||
private val fontCache = mutableMapOf<String, BaseFont>()
|
||||
|
||||
fun getBaseFont(font: FontSpec): BaseFont {
|
||||
val key = font.file ?: font.family
|
||||
return fontCache.getOrPut(key) {
|
||||
if (font.file != null) {
|
||||
BaseFont.createFont(font.file, 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
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package de.pfadfinder.songbook.renderer.pdf
|
||||
|
||||
import com.lowagie.text.*
|
||||
import com.lowagie.text.pdf.*
|
||||
import de.pfadfinder.songbook.model.*
|
||||
|
||||
class TocRenderer(
|
||||
private val fontMetrics: PdfFontMetrics,
|
||||
private val config: BookConfig
|
||||
) {
|
||||
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
|
||||
val widths = FloatArray(numCols)
|
||||
widths[0] = 10f // title
|
||||
widths[1] = 1.5f // page
|
||||
for (i in refBooks.indices) {
|
||||
widths[2 + i] = 1.5f
|
||||
}
|
||||
table.setWidths(widths)
|
||||
|
||||
// Header row
|
||||
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
||||
table.addCell(headerCell("Titel", headerFont))
|
||||
table.addCell(headerCell("Seite", headerFont))
|
||||
for (book in refBooks) {
|
||||
table.addCell(headerCell(book.abbreviation, headerFont))
|
||||
}
|
||||
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))
|
||||
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT))
|
||||
for (book in refBooks) {
|
||||
val ref = entry.references[book.abbreviation]
|
||||
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT))
|
||||
}
|
||||
}
|
||||
|
||||
document.add(table)
|
||||
}
|
||||
|
||||
private fun headerCell(text: String, font: Font): PdfPCell {
|
||||
val cell = PdfPCell(Phrase(text, font))
|
||||
cell.borderWidth = 0f
|
||||
cell.borderWidthBottom = 0.5f
|
||||
cell.paddingBottom = 4f
|
||||
return cell
|
||||
}
|
||||
|
||||
private fun entryCell(text: String, font: Font, alignment: Int = Element.ALIGN_LEFT): PdfPCell {
|
||||
val cell = PdfPCell(Phrase(text, font))
|
||||
cell.borderWidth = 0f
|
||||
cell.horizontalAlignment = alignment
|
||||
cell.paddingTop = 1f
|
||||
cell.paddingBottom = 1f
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,420 +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
|
||||
}
|
||||
}
|
||||
@@ -1,161 +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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
256
songbook-style.sty
Normal file
256
songbook-style.sty
Normal file
@@ -0,0 +1,256 @@
|
||||
\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}
|
||||
|
||||
% --- Repeat markers (used by shorthands library for |: and :| ) ---
|
||||
\providecommand{\leftrepeat}{|:\,}
|
||||
\providecommand{\rightrepeat}{\,:|}
|
||||
\providecommand{\leftrightrepeat}{|:\,:|}
|
||||
|
||||
|
||||
% --- 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}
|
||||
\definesongproperty{bulibu}
|
||||
\definesongproperty{bulibull}
|
||||
\definesongproperty{cl}
|
||||
\definesongproperty{swa}
|
||||
\definesongproperty{barde}
|
||||
\definesongproperty{libock}
|
||||
|
||||
% --- leadsheets settings ---
|
||||
\setleadsheets{
|
||||
title-template = songbook,
|
||||
verse/numbered = false,
|
||||
verse/named = false,
|
||||
chorus/named = false,
|
||||
chorus/numbered = false,
|
||||
after-song = \songendsection,
|
||||
bar-shortcuts = false,
|
||||
}
|
||||
|
||||
\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}%
|
||||
\rowcolors{1}{}{}%
|
||||
}
|
||||
|
||||
% ==========================================================================
|
||||
% 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
|
||||
}
|
||||
54
songbook.tex
Normal file
54
songbook.tex
Normal file
@@ -0,0 +1,54 @@
|
||||
% 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
|
||||
|
||||
% --- Songs (alphabetical, auto-generated from import) ---
|
||||
\input{all-songs}
|
||||
|
||||
\end{document}
|
||||
@@ -1,37 +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 }
|
||||
|
||||
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: pfadfinderliederbuch
|
||||
name: "Pfadfinderliederbuch"
|
||||
abbreviation: "PfLB"
|
||||
|
||||
output:
|
||||
directory: "./output"
|
||||
filename: "liederbuch.pdf"
|
||||
@@ -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}
|
||||
31
songs/abend-wird-es-wieder.tex
Normal file
31
songs/abend-wird-es-wieder.tex
Normal 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}
|
||||
30
songs/abends-gehn-die-liebespaare.tex
Normal file
30
songs/abends-gehn-die-liebespaare.tex
Normal file
@@ -0,0 +1,30 @@
|
||||
\begin{song}{
|
||||
title = {Abends gehn die Liebespaare},
|
||||
lyrics = {Hermann Hesse},
|
||||
composer = {Flo (Florian Schön), Stamm Raugrafen, BdP},
|
||||
cl = 12,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
\chord{d}Abends gehn die Liebespaar \chord{C}e langsam durc \chord{d}h das Feld, \\
|
||||
F \chord{d}rauen lösen ihre \chord{C} Haare, Händle \chord{d}r zählen G \chord{C}eld, \\
|
||||
B \chord{F}ürger lesen ban \chord{C}g das Neuste in dem Ab \chord{d}endblatt \chord{C}, \\
|
||||
K \chord{F}inder ballen kle \chord{C}ine Fäuste, schlaf \chord{d}en tief un \chord{C}d satt. \\
|
||||
Je \chord{B}der tut das ei \chord{F}nzig Wahre, folgt \chord{C}erhabner Pflicht, \\
|
||||
S \chord{B}äugling, Bürger, Lieb \chord{F}espaare - und ich \chord{C}selber nic \chord{d}ht \chord{C}? \\
|
||||
|: L \chord{B}eider, l \chord{F}eider, \chord{C}la \chord{d}-la leider; \chord{B}l \chord{F}a- \chord{d}l \chord{C}a \chord{d} leider. :| \\
|
||||
Doch! Auch meiner Abendtaten, deren Sklav ich bin, \\
|
||||
kann der Weltgeist nicht entraten, sie auch haben Sinn. \\
|
||||
Und so geh ich auf und nieder, tanze innerlich, summe \\
|
||||
dumme Gassenlieder, lobe Gott und mich, trinke Wein \\
|
||||
und fantasiere, dass ich Pascha wär, fühle Sorgen an \\
|
||||
der Niere, lächle, trinke mehr, \\
|
||||
|: Weiter, weiter, immer weiter; wa-wa weiter. :| \\
|
||||
Sage ja zu meinem Herzen, morgens geht es nicht, \\
|
||||
spinne aus vergangenen Schmerzen spielend ein Ge- \\
|
||||
dicht, sehe Mond und Sterne kreisen, ahne ihren Sinn, \\
|
||||
fühle mich mit ihnen reisen einerlei wohin. \\
|
||||
|: Leider, leider, la-la leider; la-la leider. :|
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
37
songs/abends-treten-elche.tex
Normal file
37
songs/abends-treten-elche.tex
Normal file
@@ -0,0 +1,37 @@
|
||||
\begin{song}{
|
||||
title = {Abends treten Elche},
|
||||
lyrics = {Heinrich Eichen},
|
||||
composer = {Gerd Lascheit},
|
||||
bulibu = 359,
|
||||
cl = 13,
|
||||
swa = 6,
|
||||
barde = 4,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
\chord{a}Abends treten Elc \chord{d}he aus den Düne \chord{a}n, \\
|
||||
ziehen von der Palv \chord{E}e an den Stran \chord{a}d. \\
|
||||
/: W \chord{d}enn die Nac \chord{a}ht, wie e \chord{d}ine gute Mu \chord{a}tter, \\
|
||||
leise deckt ihr Tu \chord{E}ch auf Haff und L \chord{a}and. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ruhig trinken sie vom großen Wasser, \\
|
||||
darin Sterne wie am Himmel steh‘n. \\
|
||||
/: Und sie heben ihre starken Köpfe \\
|
||||
lautlos in des Sommerwindes Weh‘n. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Langsam schreiten wieder sie von dannen, \\
|
||||
Tiere einer längst versunk‘nen Zeit. \\
|
||||
/: Und sie schwinden in der Ferne Nebel, \\
|
||||
wie im hohen Tor der Ewigkeit. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Heinrich Eichen schrieb den Text in Erinnerung an eine mehrmalige Begeg- \\
|
||||
nung mit Elchen.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
36
songs/abends-wenn-das-tageslicht.tex
Normal file
36
songs/abends-wenn-das-tageslicht.tex
Normal file
@@ -0,0 +1,36 @@
|
||||
\begin{song}{
|
||||
title = {Abends wenn das Tageslicht},
|
||||
bulibu = 360,
|
||||
cl = 14,
|
||||
swa = 7,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
A \chord{A}bends wenn das Tageslich \chord{D}t verweht \\
|
||||
u \chord{E}nd der Mond am Himmel steht, \\
|
||||
|: s \chord{D}itzen wir am Lagerfeuer, r \chord{E}ingsum schweigt die W \chord{A}elt, \\
|
||||
n \chord{D}ur der Wind sein A \chord{E}bendlied erzäh \chord{A}lt :|
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Flammen steigen hoch wie ein Fanal, \\
|
||||
Lieder klingen durch das Tal. \\
|
||||
|: Lieder aus der Heimat und von allem, was uns lieb, \\
|
||||
leise der Gesang zum Himmel zieht. :|
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Wo auch unser Lagerfeuer brennt, \\
|
||||
unterm nächtigen Firmament, \\
|
||||
|: sind vergessen alle Trübsal, aller Herzen Not, \\
|
||||
in uns Glaube, Treue, Freundschaft lohnt. :|
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und es öffnet sich die weite Welt, \\
|
||||
bis hinauf zum Sternenzelt. \\
|
||||
|: Uns ́re Lieder klingen durch die Lande weit und breit, \\
|
||||
schlagen Brücken über Raum und Zeit. :|
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
41
songs/ade-nun-zur-guten-nacht.tex
Normal file
41
songs/ade-nun-zur-guten-nacht.tex
Normal file
@@ -0,0 +1,41 @@
|
||||
\begin{song}{
|
||||
title = {Ade nun zur guten Nacht},
|
||||
cl = 15,
|
||||
swa = 10,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Ad \chord{D}e \chord{A7}nun zur gu \chord{D}ten Nacht! \\
|
||||
J \chord{A7}etz \chord{D}t \chord{G}wird der Sch \chord{D}luss gema \chord{h}cht, / dass i \chord{e}ch muss \\
|
||||
sch \chord{A7}eid \chord{D}en. /: \chord{A7}Im \chord{D}Sommer, da wä \chord{G}chst der Klee \chord{e}, \\
|
||||
im W \chord{A7}inter, da schne \chord{D}it's den Schn \chord{h}ee, \\
|
||||
da k \chord{e}omme ich wie \chord{A7}der \chord{D}.:/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
So trauern nun Berg und Tal, \\
|
||||
wo ich viel tausendmal / bin drüber gegangen; \\
|
||||
l: das hat deine Schönheit gemacht, \\
|
||||
sie hat mich zum Lieben gebracht / \\
|
||||
mit großem Verlangen. :l
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Das Brünnlein rinnt und rauscht \\
|
||||
wohl unterm Holderstrauch, / wo wir gesessen. \\
|
||||
l: Wie manchen Glockenschlag, \\
|
||||
da Herz bei Herzen lag, / das hast du vergessen. :l
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Die Mädchen in der Welt \\
|
||||
sind falscher als das Geld / mit ihrem Lieben. \\
|
||||
l: Ade nun zur guten Nacht, \\
|
||||
jetzt wird der Schluss gemacht, / \\
|
||||
dass ich muss scheiden. :l \\
|
||||
Das deutsche Volkslied ist seit der Mitte des 19. Jahrhunderts in Deutschland, \\
|
||||
Österreich und der Schweiz bekannt. Diese Fassung mit vier Strophen ent- \\
|
||||
stammt dem in der Jugendbewegung weit verbreiteten „Zupfgeigenhansl“.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
43
songs/alle-die-mit-uns-auf-kaperfahrt.tex
Normal file
43
songs/alle-die-mit-uns-auf-kaperfahrt.tex
Normal file
@@ -0,0 +1,43 @@
|
||||
\begin{song}{
|
||||
title = {Alle, die mit uns auf Kaperfahrt},
|
||||
lyrics = {Gottfried Wolters},
|
||||
composer = {Lieselotte Holzmeister},
|
||||
bulibu = 238,
|
||||
cl = 17,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Al \chord{e}le, die mit uns auf K \chord{H7}aperfahrt \chord{e}fahren, \\
|
||||
müssen Männer mit Bärte \chord{H7}n sein \chord{e}. \\
|
||||
Ref.: J \chord{G}an und Hein und Klaas und Pitt, \\
|
||||
di \chord{e}e haben B \chord{H7}ärte, die haben Bä \chord{e}rte. \\
|
||||
J \chord{G}an und Hein und Klaas und Pitt, \\
|
||||
d \chord{e}ie haben B \chord{H7}ärte, die fahren \chord{e}mit.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Alle, die Tod und Teufel nicht fürchten, \\
|
||||
müssen Männer mit Bärten sein.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Alle, die Weiber und Branntwein lieben, \\
|
||||
müssen Männer mit Bärten sein.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Alle, die mit uns das Walross killen, \\
|
||||
müssen Männer mit Bärten sein.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Alle, die öligen Zwieback lieben, \\
|
||||
müssen Männer mit Bärten sein.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Alle, die endlich zur Hölle mitfahren, \\
|
||||
müssen Männer mit Bärten sein.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
42
songs/alle-strassen.tex
Normal file
42
songs/alle-strassen.tex
Normal file
@@ -0,0 +1,42 @@
|
||||
\begin{song}{
|
||||
title = {Alle Straßen},
|
||||
lyrics = {axi (Alexej Stachowitsch)},
|
||||
composer = {axi (Alexej Stachowitsch)},
|
||||
cl = 18,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Alle St \chord{G}raßen dies \chord{D}er \chord{G} Erde führen j \chord{e}eden nur im Kr \chord{D}eis, \\
|
||||
der dem \chord{a}ew‘gen Stirb \chord{G}und W \chord{C}erde, \chord{D}nicht den Hafen \chord{G}weiß.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ref: \\
|
||||
/: S \chord{G}onne \chord{C}jagt die W \chord{G}olken, über Zellhof streicht d \chord{e}er \chord{D} Wind. \\
|
||||
Was der T \chord{D}ag gebracht, \chord{a}deckt die Nacht, \\
|
||||
dass sich Tr \chord{D}aum und W \chord{D7}achen f \chord{G}ind :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Alle Wasser, alle Zeiten, fließen einem Ziele zu, \\
|
||||
geht ein Strömen und ein Gleiten in die große Ruh!
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Alle Lieder sind ein Fragen, \\
|
||||
nach woher? und nach wohin? \\
|
||||
Alle jagen und verzagen an des Lebens Sinn.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Wo verstummt der Alten Singen, \\
|
||||
vor dem Streit, um was und wie, \\
|
||||
soll aus Österreich erklingen, junge Melodie!
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Alternativ zur letzten Zeile in der 4. Strophe: \\
|
||||
...wird aus jungen Herzen dringen neue Melodie.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
30
songs/allzeit-bereit-bundeslied-der-cpd.tex
Normal file
30
songs/allzeit-bereit-bundeslied-der-cpd.tex
Normal file
@@ -0,0 +1,30 @@
|
||||
\begin{song}{
|
||||
title = {Allzeit bereit (Bundeslied der CPD)},
|
||||
lyrics = {Hermann Mettel (1879-1956), aus dem Bibelkreis-Liederbuch, Barmen 1927},
|
||||
composer = {(Johann) Jakob Heinrich Lützel (1823-1899)},
|
||||
cl = 19,
|
||||
libock = 8,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Al \chord{F}lzeit be \chord{C}reit! Den k \chord{a}urzen S \chord{d}pruch als L \chord{F}osung \chord{D}ic \chord{C}h erkor; \\
|
||||
ihn sc \chord{F}hreib ich \chord{d}in mein \chord{B}Lebensb \chord{C}uch, ihn halt ich stets \chord{G}mi \chord{C}r vor. \\
|
||||
Das gibt dem L \chord{F}eben Z \chord{B}weck und Ziel, gibt \chord{D}Mut und H \chord{d}ei \chord{C}te \chord{F}rkeit; \\
|
||||
zu heilgem E \chord{B}rnst, zu frohem Spiel a \chord{F}llzeit, \chord{C}allzeit b \chord{F}ereit!
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Allzeit bereit, dem zu entflieh'n, was mir das Herz befleckt. \\
|
||||
Nichts Schlechtes soll mich abwärts zieh'n, hoch ist mein Ziel \\
|
||||
gesteckt Gott zum lebend'gen Eigentum sei Leib und Seel ge- \\
|
||||
weiht, zu seines Namens Ehr' und Ruhm, allzeit, allzeit bereit!
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Allzeit bereit! Wahr sei der Mund, unwandelbar die Treu. \\
|
||||
Rein sei das Herz, fest sei der Bund, der Wandel ohne Scheu! \\
|
||||
O hilf mir Gott, du starker Hort, dass ich kann jederzeit \\
|
||||
erfüllen treu das Losungswort: allzeit, allzeit bereit!
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
38
songs/almost-heaven.tex
Normal file
38
songs/almost-heaven.tex
Normal file
@@ -0,0 +1,38 @@
|
||||
\begin{song}{
|
||||
title = {Almost heaven},
|
||||
lyrics = {John Denver},
|
||||
composer = {John Denver},
|
||||
bulibu = 140,
|
||||
cl = 20,
|
||||
barde = 7,
|
||||
libock = 10,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
\chord{D}Almost heaven, We \chord{h}st Virginia, \\
|
||||
blue ridge Mountains, Shena \chord{G}ndoah River. \\
|
||||
Life is old there, \chord{h}older than the trees, \\
|
||||
\chord{A}younger than the mountains growin \chord{G}g like a b \chord{D}reeze. \\
|
||||
Ref.: \\
|
||||
Country \chord{D}roads, take me ho \chord{A}me to the pl \chord{h}ace I bel \chord{G}ong: \\
|
||||
West V \chord{D}irginia, mountain m \chord{A}omma, \\
|
||||
take me h \chord{G}ome, Country ro \chord{D}ads.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
All my mem’ries gather round her, \\
|
||||
miner´s lady, stranger to blue water. \\
|
||||
Dark and dusty, painted on the sky, \\
|
||||
misty taste of moonshine, teardrops in my eye. \\
|
||||
Country roads....
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
I hear the v \chord{A}oice in the mor \chord{D}nin´ hour she calls me, \\
|
||||
the r \chord{G}adio remind \chord{D}s me of my home \chord{A} far away, \\
|
||||
and dr \chord{h}iving down the roa \chord{C}ds \\
|
||||
I get a f \chord{G}eeling that I sh \chord{D}ould have been home \\
|
||||
ye \chord{A}sterday, yesterda \chord{A7}y, Country road \chord{D}s...
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
69
songs/als-wir-nach-frankreich.tex
Normal file
69
songs/als-wir-nach-frankreich.tex
Normal file
@@ -0,0 +1,69 @@
|
||||
\begin{song}{
|
||||
title = {Als wir nach Frankreich},
|
||||
lyrics = {Josef von Lauff},
|
||||
composer = {Aus dem 1. Weltkrieg},
|
||||
cl = 22,
|
||||
libock = 12,
|
||||
}
|
||||
|
||||
% Der Schriftsteller und Dramaturg Josef von Lauff war so kaisertreu, dass die-
|
||||
% ser ihn 1913 in den Adelsstand erhob - kein Wunder, dass er dann als Kriegs-
|
||||
% berichterstatter mit Gedichten wie diesem dem Kaiser entsprechend dankte.
|
||||
|
||||
\begin{verse}
|
||||
Als wi \chord{G}r nach Fr \chord{D}ankreich zo \chord{G}g \chord{e}en, \\
|
||||
wir wa \chord{a}ren un \chord{D}s‘rer d \chord{G}rei, \\
|
||||
/: ein Sch \chord{G}ütze und ein J \chord{e}äger, \\
|
||||
und ic \chord{a}h, der Fahnenträ \chord{D}ger \\
|
||||
der schwe \chord{G}ren Re \chord{D}it \chord{G}erei. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Drei Brüder und drei Herzen, \\
|
||||
der Fahnen folgten sie, \\
|
||||
/: zu Lüttich auf dem Plane, \\
|
||||
da flüsterte die Fahne, \\
|
||||
Herr Jesus und Marie. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und als wir weiterzogen, \\
|
||||
wir waren uns‘rer zwei, \\
|
||||
/: ein Bückeburger Jäger \\
|
||||
und ich, der Fahnenträger \\
|
||||
der schweren Reiterei. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Zwei Brüder und zwei Herzen \\
|
||||
begrüßten Tau und Tag. \\
|
||||
/: Am Abend purpurfarben, \\
|
||||
zu Longwy in den Garben, \\
|
||||
die Fahne Amen sprach. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und als sie Amen sagte, \\
|
||||
brach noch ein Herz entzwei. \\
|
||||
/: Ade, mein lieber Jäger, \\
|
||||
dich grüßt der Fahnenträger \\
|
||||
der schweren Reiterei. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ach Mutter, liebste Mutter, \\
|
||||
nur fest auf Gott gebaut! \\
|
||||
/: Noch tut die Fahne schweben, \\
|
||||
die mir auf Tod und Leben \\
|
||||
mein Kaiser anvertraut. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und flüstert sie einst leise: \\
|
||||
Nun gilt es dir Gesell! \\
|
||||
/: dann folgt der Fahnenträger \\
|
||||
dem großen Trommelschläger \\
|
||||
zum himmlischen Appell. :/
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
64
songs/als-wir-noch-knaben-waren.tex
Normal file
64
songs/als-wir-noch-knaben-waren.tex
Normal file
@@ -0,0 +1,64 @@
|
||||
\begin{song}{
|
||||
title = {Als wir noch Knaben waren},
|
||||
lyrics = {Bärenrotte, Zugvogel},
|
||||
composer = {nach einem irischen Lied},
|
||||
cl = 24,
|
||||
libock = 14,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Als w \chord{C}ir noch Knaben waren, die Z \chord{a}eit ist lang vorbei, \\
|
||||
ein je \chord{F}des Jahr im Frühling beg \chord{C}ann die Tippelei. \\
|
||||
Wir tr \chord{C}äumten von Kosaken, Sold \chord{a}aten und Piraten, \\
|
||||
von D \chord{F}schingis‘ Goldner Horde und \chord{C}and‘ren Heldentaten.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ref.: \\
|
||||
/: H \chord{C}e, \chord{G} heute geht‘s uns gut! \chord{C}Es ist noch Suppe da, \\
|
||||
e \chord{F}s ist noch Suppe da, he, z \chord{C}ünd´ das F \chord{G}euer a \chord{C}n! :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Wir waren stolze Burschen, was galten uns die Weiber? \\
|
||||
Tramptour´n in fremden Ländern, wir pfiffen auf die Neider. \\
|
||||
Doch unsre wilden Fahrten, die sind heut nur noch nett, \\
|
||||
erst bauen wir die Kohte auf und dann das Lotterbett.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Wir sind die Fähnchen Rotte, der Jojo führt uns an, \\
|
||||
wir sind ein kleiner Haufen, ein halbes Dutzend Mann. \\
|
||||
Von uns, da kennt ein jeder den Wert der Eisenpfanne, \\
|
||||
des Morgens ein paar Eier und Kaffee in der Kanne.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Am Mittag Fleisch im Potte, wir können‘s ja berappen, \\
|
||||
und wer nicht satt zu essen hat, das sind die armen Knap- \\
|
||||
pen. Des Abends Wein im Becher, gefüllt bis an den Rand, \\
|
||||
wir freu´n uns unseres Lebens, wir tippeln durch das Land.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Jetzt Arbeit und ´ne Frau, wer hätte das gedacht? \\
|
||||
Und nebenbei ein Haus gebaut, wir haben‘s weit gebracht. \\
|
||||
Doch schon nach ein paar Jahren, da dachten wir zurück, \\
|
||||
an unsre wilden Feste und an das Jugendglück.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Die besten Freunde lachen, das ist ein starkes Stück. \\
|
||||
Was macht ihr denn für Sachen, seid ihr im zweiten Glück? \\
|
||||
Drum Männer rückt zusammen, es ist noch Tschai im Becher, \\
|
||||
obwohl wir alte Säcke sind, sind wir gewalt‘ge Zecher.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und ruft Jan Hein zur Reise, was ist denn schon dabei? \\
|
||||
Wir kommen in den Himmel und spielen die Schalmei. \\
|
||||
Wenn wir auf Wolken schweben und Hosianna singen, \\
|
||||
sehn wir auf Erden Jüngere die Eisenpfanne schwingen.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
38
songs/am-alten-hafen-piratenhafen.tex
Normal file
38
songs/am-alten-hafen-piratenhafen.tex
Normal file
@@ -0,0 +1,38 @@
|
||||
\begin{song}{
|
||||
title = {Am alten Hafen (Piratenhafen)},
|
||||
lyrics = {cux (Jan Franke), Orden der Freibeuter, Wandervogel BfJ},
|
||||
composer = {cux (Jan Franke), Orden der Freibeuter, Wandervogel BfJ},
|
||||
cl = 26,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Am \chord{a}alten Hafen ein \chord{d}Lichtlein noch brennt, \\
|
||||
die ge \chord{E}sunk'nen Gestalten mit N \chord{a}amen man nicht kennt. \\
|
||||
Raue Kehle letztes \chord{d}Lied noch nicht sang, \\
|
||||
am H \chord{E}olztisch, der letzte \chord{a}Heller noch nicht sprang. \\
|
||||
|: Unser Ruf eilt uns vora \chord{d}us: \\
|
||||
Der T \chord{E}od kann warten auf uns Pi \chord{a}raten! :|
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Der ranzige Wirt 'ne Pistole erzählt, \\
|
||||
die geschundene Dirne der Schnaps am Leben hält. \\
|
||||
Keine Pfaff diesen Ort jemals sah, \\
|
||||
dafür war'n so ziemlich alle andern Gauner da.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Der alte Schipper, der schläft langsam ein, \\
|
||||
auf der Schulter 'ne Krähe mit noch einem Bein. \\
|
||||
Eine Wespe summt ganz müde herum, \\
|
||||
durch das Wettergetose hört man ihr Gebrumm.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Plötzlich wird es totenstill im Raum, \\
|
||||
man hört der Fremden Schritte erst noch kaum. \\
|
||||
Ein jeder schenkt sein Glas nochmal ein, \\
|
||||
er weiß, es werden seine letzten Schlücke sein.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
42
songs/am-brunnen-vor-dem-tore.tex
Normal file
42
songs/am-brunnen-vor-dem-tore.tex
Normal file
@@ -0,0 +1,42 @@
|
||||
\begin{song}{
|
||||
title = {Am Brunnen vor dem Tore},
|
||||
lyrics = {Wilhelm Müller},
|
||||
composer = {Franz Schubert},
|
||||
cl = 27,
|
||||
libock = 16,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Am Br \chord{D}unnen vor dem Tore, da s \chord{A}teht ein Lindenb \chord{D}aum. \\
|
||||
Ich t \chord{D}räumt in seinem Schatten so m \chord{A}anchen süßen Tr \chord{D}aum. \\
|
||||
Ich schn \chord{A}itt in seine R \chord{D}inde so m \chord{G}anches liebe W \chord{A}ort. \\
|
||||
Es \chord{A7}zog in Freud und Le \chord{D}iden /: zu ihm mich i \chord{A}mmer f \chord{D}ort. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ich musst auch heute wandern, vorbei in tiefer Nacht. \\
|
||||
Da hab ich noch im Dunkeln, die Augen zugemacht. \\
|
||||
Und seine Zweige rauschten, als riefen sie mir zu: \\
|
||||
Komm her zu mir, Geselle, /: hier find‘st du deine Ruh. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Die kalten Winde bliesen, mir grad ins Angesicht. \\
|
||||
Der Hut flog mir vom Kopfe, ich wendete mich nicht. \\
|
||||
Nun bin ich manche Stunde entfernt von jenem Ort, \\
|
||||
und immer hör ich‘s rauschen: /: Du fändest Ruhe dort.:/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Der ursprüngliche Titel lautet „Der Lindenbaum“. \\
|
||||
Der Text gehört zu einem Gedichtzyklus, den Mül- \\
|
||||
ler Die Winterreise überschrieb. Franz Schubert \\
|
||||
vertonte den gesamten Gedichtzyklus unter dem \\
|
||||
Titel Winterreise und in diesem Rahmen auch den \\
|
||||
Lindenbaum als Kunstlied. In der bekanntesten \\
|
||||
und populärsten Bearbeitung der Schubertschen \\
|
||||
Vertonung von Friedrich Silcher ist das Werk zum \\
|
||||
Volkslied geworden.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
41
songs/am-ural.tex
Normal file
41
songs/am-ural.tex
Normal file
@@ -0,0 +1,41 @@
|
||||
\begin{song}{
|
||||
title = {Am Ural},
|
||||
lyrics = {axi (Alexej Stachowitsch)},
|
||||
composer = {axi (Alexej Stachowitsch)},
|
||||
bulibu = 2,
|
||||
cl = 28,
|
||||
swa = 14,
|
||||
barde = 12,
|
||||
libock = 18,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
\chord{e}Am Ura \chord{D}l \chord{e}, fern von der Heimat, \\
|
||||
sitzen Kos \chord{D}aken beim Feuersch \chord{e}ei \chord{D}n \chord{e}. \\
|
||||
Der e \chord{e}in \chord{D}e \chord{e} spielt Balaleika, \\
|
||||
die a \chord{D}nderen, sie stimmen mit \chord{e}ei \chord{D}n \chord{e}.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ref.: \\
|
||||
Hey, \chord{e}Ossa, Ossa, schöne Stadt am Karmar, \\
|
||||
\chord{D}Ossa, Ossa, schöne Stadt am Karmar, \\
|
||||
O \chord{e}ssa, Ossa, schöne Stadt am Karmar, \\
|
||||
j \chord{D}ohei, j \chord{e}oh \chord{D}ei \chord{e} j \chord{D}o, \chord{e} j \chord{D}oh \chord{e}ei jo, hei jo,
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Den Pferden gellt es in den Ohren, \\
|
||||
wenn die Kosaken jauchzen und schrei‘n. \\
|
||||
Sie geben den Tieren die Sporen, \\
|
||||
drüben liegt Ossa im Feuerschein.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Am Himmel, da leuchten die Sterne, \\
|
||||
der Wolf heult im finsteren Tann. \\
|
||||
Die Heimat, so grüßt sie von Ferne, \\
|
||||
vergessen ist alle Qual.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
43
songs/am-westermanns-loenstief.tex
Normal file
43
songs/am-westermanns-loenstief.tex
Normal file
@@ -0,0 +1,43 @@
|
||||
\begin{song}{
|
||||
title = {Am Westermanns Lönstief},
|
||||
lyrics = {trenk (Alo Hamm).},
|
||||
composer = {trenk (Alo Hamm).},
|
||||
bulibu = 236,
|
||||
cl = 29,
|
||||
swa = 15,
|
||||
barde = 14,
|
||||
libock = 18,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
\chord{d}Am Westermanns Lönstief pfeift e \chord{g}isiger W \chord{d}ind. \\
|
||||
Uns s \chord{g}chaukelt die S \chord{d}ee wie die M \chord{A}utter ihr \chord{d}Kind. \\
|
||||
\chord{C}Am \chord{F} Westermanns L \chord{C}önstief ist a \chord{F}lles so gr \chord{C}au, \\
|
||||
wir f \chord{g}angen den H \chord{d}ering, den K \chord{A}ab \chord{d}eljau. \\
|
||||
Ref: \\
|
||||
Tschire \chord{g}e macht die S \chord{d}ee, tschi \chord{A}ra, tschi \chord{d}ree, \\
|
||||
tschir \chord{g}ee macht die \chord{d}See, tsch \chord{A}ira-ha-ha-ha tschi \chord{d}re \chord{F}e \chord{C}.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Durch Tage und Nächte wir kurren im Nord \\
|
||||
und hieven die zappelnde Beute an Bord. \\
|
||||
Wir kehlen den Hering und salzen ihn ein, \\
|
||||
sind voll unsere Katjes, wir fahren heim.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Südwester, das Ölzeug und Isländer Wams, \\
|
||||
was nützen die Plünnen im Schneeflockentanz. \\
|
||||
Ein daumenbreit Schluck aus der Buttel mit Rum, \\
|
||||
das krempelt uns wieder ne Weile um.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Spring über die Reling Jan Rasmus, tschiree, \\
|
||||
fass Taue, halt fest dich, sonst fährst du zur See. \\
|
||||
So mancher fuhr tief in den Meerkeller weg, \\
|
||||
der Teufel soll holen den Höllenfreck.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
52
songs/an-de-eck.tex
Normal file
52
songs/an-de-eck.tex
Normal file
@@ -0,0 +1,52 @@
|
||||
\begin{song}{
|
||||
title = {An de Eck},
|
||||
lyrics = {Leopold und James Wolf Ludwig, 1911},
|
||||
composer = {Leopold und James Wolf Ludwig, 1911},
|
||||
cl = 30,
|
||||
libock = 20,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
An de E \chord{A}ck steiht ´n Jung mit´n Tüddelband \\
|
||||
in de anner Hand ´n Bodderbrood mit Ke \chord{E7}es,
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
wenn he blots nich mit de Been in´n Tüddel kümmt \\
|
||||
un dor liggt he ok all lang op de \chord{A}Nees
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
un he rasselt mit´n Dassel op´n Kantsteen \\
|
||||
un he bitt sick ganz geheurig op de Tu \chord{D}ng, \\
|
||||
as he o \chord{E}psteiht, seggt he: hett nich w \chord{A}eeh doon, \\
|
||||
ischa ´n K \chord{E}lacks för ´n H \chord{E7}amborger Jun \chord{A}g.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Refrain
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
J \chord{A}o, \chord{D} j \chord{E}o, \chord{A} jo, klaun, klaun, Äppel wüllt wi klaun, \\
|
||||
ruck zuck övern Zaun \chord{E7}, \\
|
||||
Ein j \chord{E}eder aber ka \chord{A}nn dat nich, denn he mutt \chord{D} ut \\
|
||||
Ha \chord{E}mborg sien \chord{A}.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
An de Eck steiht ´n Deern mit´n Eierkorf \\
|
||||
in de anner Hand ´n groote Buddel Rum \\
|
||||
Wenn se blots nich mit de Eier op dat Plaaster sleit \\
|
||||
un dor seggt dat ok al lang "bum bum". \\
|
||||
Un se smitt de Eiers un den Rum tosomen \\
|
||||
un se seggt "so'n Eiergrog den hebb ik geern" \\
|
||||
as se opsteiht, seggt se: "hett nich weeh doon, \\
|
||||
ischa´n Klacks för´n Hamborger Deern.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Refrain
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
55
songs/an-land.tex
Normal file
55
songs/an-land.tex
Normal file
@@ -0,0 +1,55 @@
|
||||
\begin{song}{
|
||||
title = {An Land},
|
||||
lyrics = {Element of Crime},
|
||||
composer = {Element of Crime},
|
||||
bulibull = 1,
|
||||
cl = 82,
|
||||
barde = 140,
|
||||
libock = 180,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
H \chord{C}eute wird wohl kein \chord{a}Schiff mehr gehn \\
|
||||
und k \chord{d}einer geht mehr vor die T \chord{G}ür. \\
|
||||
\chord{C}Alle sind heute versch \chord{a}üchtert; \\
|
||||
nur i \chord{d}ch bin es nicht und \chord{G}das liegt an dir. \\
|
||||
Am F \chord{E}enster fliegt eine \chord{a}Kuh vorbei; \\
|
||||
da kommt j \chord{F}ede Hilfe zu \chord{C}s \chord{G}pät. \\
|
||||
\chord{C}Ein Glas auf die K \chord{G}uh und eins auf die \chord{C}See.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ich liebe die See und sie liebt mich auch. \\
|
||||
Hörst du, wie sie nach mir brüllt? \\
|
||||
Ich hätte sie niemals verlassen sollen \\
|
||||
und das ist, was sie mir klarmachen will. \\
|
||||
Wenn hinter uns nicht der Deich wär', \\
|
||||
käme jede Hilfe zu spät. \\
|
||||
Ein Glas auf den Deich und eins auf die See.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Hier wurd' ich an Land gespült, \\
|
||||
hier setz' ich mich fest. \\
|
||||
Von dir weht mich kein Sturm mehr fort. \\
|
||||
Bei dir werd' ich bleiben so lang du mich lässt. \\
|
||||
Deine Hand kommt in meine \\
|
||||
und jede Hilfe zu spät. \\
|
||||
Ein Glas auf uns und eins auf die See.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Heute wird wohl kein Schiff mehr gehn \\
|
||||
und keiner geht mehr vor die Tür. \\
|
||||
Alle sind heute verschüchtert; \\
|
||||
nur ich bin es nicht und das liegt an dir. \\
|
||||
Am Fenster fliegt eine Kuh vorbei; \\
|
||||
da kommt jede Hilfe zu spät. \\
|
||||
Ein Glas auf die Kuh und eins auf die See.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
\chord{C}Ein Glas auf \chord{G}uns und eins auf die S \chord{C}ee.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
64
songs/andre-die-das-land.tex
Normal file
64
songs/andre-die-das-land.tex
Normal file
@@ -0,0 +1,64 @@
|
||||
\begin{song}{
|
||||
title = {Andre, die das Land},
|
||||
lyrics = {Theodor Kramer},
|
||||
composer = {Erich Schmeckenbecher},
|
||||
bulibu = 280,
|
||||
cl = 36,
|
||||
barde = 20,
|
||||
}
|
||||
|
||||
% Der Lyriker Theodor Kramer stammte aus einer jüdischen Familie und war
|
||||
% Sozialdemokrat – eine denkbar schlechte Kombination in Österreich zur NS-
|
||||
% Zeit. Alle seine Schriften landeten auf der „Liste des schädlichen und uner-
|
||||
% wünschten Schrifttums“ der Nazis, womit Kramer, der insgesamt über 12.000
|
||||
% Gedichte verfasst hat, seine Lebensgrundlage verlor. Seine Heimat wollte er
|
||||
% zunächst dennoch nicht verlassen. Die Verzweiflung über seine Situation
|
||||
% brachte er im Juli 1938 in diesem Gedicht zum Ausdruck. Nur einen Monat
|
||||
% später versuchte er erfolglos, sich das Leben zu nehmen. 1939 entschied er
|
||||
% sich doch noch zur Emigration und gelangte nach England. 1957 kehrte er
|
||||
% nach Österreich zurück und starb nur ein Jahr später. Seine Gedichte gerieten
|
||||
% zunächst in Vergessenheit, doch die Vertonung einiger seiner Lieder durch
|
||||
% das Folk-Duo Zupfgeigenhansel trug zur Wiederentdeckung seines Werkes
|
||||
% bei.
|
||||
|
||||
\begin{verse}
|
||||
A \chord{G}nd‘re, die das Land so sehr nicht l \chord{D}iebten, \\
|
||||
wa \chord{e}r‘n von Anfang \chord{C}an gewillt zu g \chord{G}eh‘n. \\
|
||||
I \chord{e}hnen - manche s \chord{C}ind schon fort - ist‘s \chord{G}besser, \\
|
||||
ich doch müsste mit dem eig‘nen Mess \chord{D}er, \\
|
||||
me \chord{e}ine Wurzeln au \chord{C}s der Er \chord{D}de dre \chord{G}h‘n.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Keine Nacht hab‘ ich seither geschlafen, \\
|
||||
und es ist mir mehr als weh zumut; \\
|
||||
viele Wochen sind seither verstrichen, \\
|
||||
alle Kraft ist längst aus mir gewichen, \\
|
||||
und ich fühl, dass ich daran verblut.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und doch müsst ich mich von hinnen heben, \\
|
||||
sei‘s auch nur zu bleiben, was ich war. \\
|
||||
Nimmer kann ich, wo ich bin, gedeihen; \\
|
||||
draußen braucht ich wahrlich nicht zu schreien, \\
|
||||
denn mein leises Wort war immer wahr.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Seiner wär‘ ich wie in alten Tagen, \\
|
||||
sicher schluchzend wider mich gewandt, \\
|
||||
hätt´ ich Tag und Nacht mich nur zu heißen, \\
|
||||
mich samt meinen Wurzeln auszureißen \\
|
||||
und zu setzen in ein and‘res Land.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
And‘re, die das Land so sehr nicht liebten, \\
|
||||
war‘n von Anfang an gewillt zu geh‘n. \\
|
||||
Ihnen - manche sind schon fort - ist besser, \\
|
||||
ich doch müsste mit dem eig‘nen Messer, \\
|
||||
meine Wurzeln aus der Erde dreh‘n.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
@@ -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}
|
||||
29
songs/auf-auf-zum-froehlichen-jagen.tex
Normal file
29
songs/auf-auf-zum-froehlichen-jagen.tex
Normal 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}
|
||||
38
songs/auf-vielen-strassen.tex
Normal file
38
songs/auf-vielen-strassen.tex
Normal file
@@ -0,0 +1,38 @@
|
||||
\begin{song}{
|
||||
title = {Auf vielen Straßen},
|
||||
lyrics = {Björn Behnke},
|
||||
composer = {Alfred Zschiesche},
|
||||
bulibull = 32,
|
||||
cl = 42,
|
||||
swa = 21,
|
||||
barde = 34,
|
||||
}
|
||||
|
||||
% Alfred Zschiesche (*22.02.1908 in Wiesbaden † 1992) Fahrtenname alf, war
|
||||
% ein bekannter Liedschöpfer, Komponist, Dichter, Zeichner und Fotograf. Er
|
||||
% war Nerother Wandervogel und im bündischen Widerstand aktiv. Ihm verdan-
|
||||
% ken wir Lieder wie: "Wenn die bunten Fahnen wehen", "Wir sind eine kleine
|
||||
% verlorene Schar" oder "Wo wollt ihr hin, ihr tollen Jungen "
|
||||
|
||||
\begin{verse}
|
||||
Auf vielen St \chord{d}raßen dieser Wel \chord{A}t, \\
|
||||
habt ihr euch sorglos rum getri \chord{d}eben, \\
|
||||
/: so ohne Z \chord{g}elt und ohne Ge \chord{d}ld \\
|
||||
der Tippel \chord{A}ei verschr \chord{d}ieben. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Was galt euch Armut, was Gefahr? \\
|
||||
Ihr habt, verachtet und zerschunden, \\
|
||||
/: da draußen treibend Jahr für Jahr \\
|
||||
doch euer Glück gefunden. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Habt manches Lied der Einsamkeit \\
|
||||
wohl in die Nacht hinaus gesungen. \\
|
||||
/: Auf fremden Meeren fern der Zeit \\
|
||||
ist euer Sang verklungen. :/
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
37
songs/balkanlied.tex
Normal file
37
songs/balkanlied.tex
Normal file
@@ -0,0 +1,37 @@
|
||||
\begin{song}{
|
||||
title = {Balkanlied},
|
||||
lyrics = {Fredl Mayr, aus Lieder der Eisbrechermannschaft, 1932 Ein Lied aus der späten Phase der Jugendbewegung. In der Verbotszeit gehörte es zu den nicht-opportunen Erkennungsliedern der Bündischen. Als Willi Graf, später Mitglied der Weißen Rose, Als er 1938 wegen bündischer Umtriebe (er gehörte seit 1934 dem Grauen Orden an) angeklagt wurde, brachte die Anklage unter anderem das Singen dieses Liedes als Beweis vor. Das Lied wurde erstmals abgedruckt in „Lagerfeuer“, Heft 3/1932, und dann in den „Liedern der Eisbrechermannschaft“.},
|
||||
composer = {Fredl Mayr, aus Lieder der Eisbrechermannschaft, 1932 Ein Lied aus der späten Phase der Jugendbewegung. In der Verbotszeit gehörte es zu den nicht-opportunen Erkennungsliedern der Bündischen. Als Willi Graf, später Mitglied der Weißen Rose, Als er 1938 wegen bündischer Umtriebe (er gehörte seit 1934 dem Grauen Orden an) angeklagt wurde, brachte die Anklage unter anderem das Singen dieses Liedes als Beweis vor. Das Lied wurde erstmals abgedruckt in „Lagerfeuer“, Heft 3/1932, und dann in den „Liedern der Eisbrechermannschaft“.},
|
||||
cl = 312,
|
||||
swa = 224,
|
||||
barde = 309,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Und wir k \chord{e}auern wied \chord{H7}er \chord{e}um die heiße Glut \\
|
||||
und erz \chord{a}ählen vom A \chord{D}bent \chord{e}euer. \\
|
||||
und der wilde Bal \chord{H7}kan \chord{e} ist gerad‘ noch gut \\
|
||||
und wir s \chord{a}chwören am L \chord{D}agerf \chord{e}euer. \\
|
||||
/: Dass die N \chord{G}eider verda \chord{D}mmt und die Sp \chord{C}ießer verflu \chord{H7}cht, \\
|
||||
d \chord{e}ie uns gehemmt viel t \chord{H7}ausend M \chord{e}ale. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Doch sehr bald wird es wahr, dass wir stehen am Meer und \\
|
||||
gedenken der fernen Heimat. \\
|
||||
Und der kleine Trupp, der rüstet gar sehr \\
|
||||
zu verlassen die grauen Mauern. \\
|
||||
l: Und es hält uns nichts mehr und wir freuen uns sehr, \\
|
||||
bald flattern Segel nach Osten. :l
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und der schwarze Adler flattert uns voran \\
|
||||
auf der hellgelben Fahne am Maste. \\
|
||||
Und ein wildes Lied schwingt sich von Kahn zu Kahn, \\
|
||||
das verwegne, das vielfach verhasste. \\
|
||||
l: Dass die Neider verdammt und die Spießer verflucht, \\
|
||||
die uns gehemmt viel tausend Male.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
28
songs/ballade-von-bergen.tex
Normal file
28
songs/ballade-von-bergen.tex
Normal file
@@ -0,0 +1,28 @@
|
||||
\begin{song}{
|
||||
title = {Ballade von Bergen},
|
||||
lyrics = {Lina Sobral Bastos, Frederick Oelbaum},
|
||||
composer = {Lina Sobral Bastos, Sophie Manstorfer},
|
||||
libock = 214,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Im N \chord{a}orden hängen die W \chord{d}olken tief, die \chord{G}Nässe durch die \\
|
||||
P \chord{C}onchos trieft. \\
|
||||
Das \chord{a}Wasser in der K \chord{e}ohte s \chord{G}teht und der R \chord{F}egen nicht verg \chord{a}eht. \\
|
||||
Der Magen bis zum \chord{d}Boden hing, die L \chord{G}ust auf Pampf uns \\
|
||||
\chord{C}bald verging. \\
|
||||
Weil \chord{a}uns das nur der N \chord{e}orden \chord{G}gibt: haben wir uns tr \chord{F}otzdem in \\
|
||||
das Land verli \chord{a}ebt. \\
|
||||
Ref.: J \chord{a}aa, so ist F \chord{G}ahrt, manchmal \chord{F}hart, sie macht uns st \chord{e}ark \\
|
||||
J \chord{a}aa, so ist F \chord{G}ahrt, macht uns S \chord{F}paß, ja, ja, \chord{e}ja \chord{E/e}, jaa
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Wir leuchten hell am Horizont, die Sonne aus den Wolken \\
|
||||
kommt. / Sie strahlt uns auf die nasse Haut, doch sie hält es \\
|
||||
lang nicht aus. / Von Bergen aus die Fjorde sehen, wenn wir \\
|
||||
auf der Fähre stehen. / Weil uns das nur der Norden gibt: \\
|
||||
haben wir uns trotzdem in das Land verliebt.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
71
songs/ballade-von-der-gemeinsamen-zeit-vorspiel-d-a-d-g.tex
Normal file
71
songs/ballade-von-der-gemeinsamen-zeit-vorspiel-d-a-d-g.tex
Normal file
@@ -0,0 +1,71 @@
|
||||
\begin{song}{
|
||||
title = {Ballade von der gemeinsamen Zeit Vorspiel: /: d A :/ d g C F d g A /: d A :/},
|
||||
lyrics = {Konny Kleinkunstpunk},
|
||||
composer = {Konny Kleinkunstpunk},
|
||||
cl = 414,
|
||||
libock = 446,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
\chord{d}Zähle doch nicht unsere \chord{g}Stunden und weine doch nicht, \\
|
||||
wenn du \chord{d}gehst. Du vergießt doch \chord{C}auch keine \chord{B}Tr \chord{d}änen, \\
|
||||
B F Gis \\
|
||||
wenn der Wind mal nicht weht. So frier ich auch nicht in \\
|
||||
der \chord{g}Nacht, wenn der Mond am Himmel ve \chord{F}rrät, dass die \\
|
||||
F Dis Cis C \\
|
||||
Sonne ihr Licht jetzt woanders austrägt.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Halte mich in deinen Armen und lass uns gehen ein Stück. Ande- \\
|
||||
re machen es anders. Was wissen denn die schon vom Glück? \\
|
||||
Was wissen denn die schon von Abschied? Und ist’s nur ein Ab- \\
|
||||
schied auf Zeit, so hab ich doch einen Zeitvertreib: \\
|
||||
\chord{d}Heute säh ich, morgen mäh ich, \chord{g}übermorgen back ich \\
|
||||
Brot, p \chord{C}ress den Saft aus Südhangreben – \chord{F} dieser Wein \\
|
||||
wird \chord{g}süß und \chord{A}ro \chord{d}t! Bau ein Haus aus Wegrandsteinen, \\
|
||||
p \chord{g}flanze Rosen, roten Mohn, l \chord{C}ern das schöne Spiel der \\
|
||||
Geige, \chord{F}kauf mir ein Ban \chord{g}done \chord{A}om. \chord{d}Hack das Holz und \\
|
||||
heiz die Stube, n \chord{g}ehm ein Bad mit Elixier, \chord{C}reiß die Blätter \\
|
||||
vom Kalender u \chord{F}nd dann bist du w \chord{g}ieder hi \chord{A}er...
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
So kamst du zurück eines Tages, der Koffer verschwand unterm \\
|
||||
Bett. Jetzt liegst du in meinen Armen, doch weiß ich, du gehst \\
|
||||
wieder weg. Noch halten wir unsere Hände, noch lächelt dein \\
|
||||
Gesicht, noch drückt dein Koffer unter uns nicht.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Dann sagst du, du hast noch zwei Stunden, dann ruft dich wieder \\
|
||||
die Pflicht. Wir haben ne Art gefunden, dass uns das Herz nicht \\
|
||||
zerbricht. Unser Gang endet wieder am Bahnsteig. Ich seh zu, \\
|
||||
wie der Zug sich entfernt. Hör zu, ich hab ein Lied gelernt:
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Heute säh ich, morgen mäh ich, …
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Hat man uns denn so erzogen? Oder was hat uns soweit ge- \\
|
||||
bracht, dass dieses dumme Leben uns hindern kann an unsrer \\
|
||||
Pracht, uns hindert an unserer Nähe? Denn die Liebe verhin- \\
|
||||
dert’s ja nicht wie die Traurigkeit, wenn der Morgen anbricht.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Was bringt uns das viele Gerenne? Was sagt mir dies klagende \\
|
||||
Lied? Es sagt mir, dass sich nichts ändert, wenn keine Änderung \\
|
||||
geschieht. Wir haben nur dies kurze Leben, dann sind wir wieder \\
|
||||
allein. So könnte es jetzt doch mal andersrum sein. \\
|
||||
J \chord{d}a, dann säen wir gemeinsam, \chord{g}backen unser eigen’ Brot, \chord{C} trinken \\
|
||||
Wein aus vollen Schläuchen, t \chord{F}anzen bis ins \chord{g}Morgenr \chord{A}ot \chord{d}. Bauen \\
|
||||
noch ein Haus aus Kieselsteinen, \chord{g}pflanzen auch noch Majoran, \\
|
||||
u \chord{C}nd du singst zu den Akkorden, i \chord{F}ch spiel Geige, w \chord{g}as ich \chord{A}kann. \\
|
||||
\chord{d}Und das Holz im Ofen knistert, \chord{g}wenn du aus der Wanne steigst. \\
|
||||
\chord{C}Der Kalender liegt im Feuer, \chord{F}wenn du mir den \chord{g}Nordstern \chord{A}zeigst.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
69
songs/banner.tex
Normal file
69
songs/banner.tex
Normal file
@@ -0,0 +1,69 @@
|
||||
\begin{song}{
|
||||
title = {Banner},
|
||||
lyrics = {Wolfgang Hartmann, VCP Stamm Franken (Wiesbaden-Erbenheim), Sommer 1984},
|
||||
composer = {Wolfgang Hartmann, VCP Stamm Franken (Wiesbaden-Erbenheim), Sommer 1984},
|
||||
cl = 44,
|
||||
libock = 36,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
\chord{a}Banner, Zelte, "Wer da?" \chord{F}Rufe, Stille um das L \chord{G}ager her, \\
|
||||
Feuer scheinen \chord{a}in der Nacht. \chord{E} Im \chord{C}Mantel schläft die Wache \chord{G}ein, \\
|
||||
ein Leutnant schritt vor \chord{a}bei, das Würfelspiel ist f \chord{E}alsch. \\
|
||||
Aus \chord{C}einem Schatten tritt her \chord{G}bei ein Spielmann in den K \chord{a}reis, \\
|
||||
der Posten lässt vor \chord{E}bei und flüstert l \chord{a}eis: \\
|
||||
\chord{a}Vagabund so hör` mich an: \\
|
||||
Die N \chord{F}acht ist kurz und i \chord{F}rgendwann der R \chord{G}uf vom Kornett laut \\
|
||||
ertönt, drum s \chord{a}piele mir das a \chord{E}ltgesung'ne Lied. \\
|
||||
Und der S \chord{C}pielmann \chord{G}singt ein L \chord{a}ied: \\
|
||||
Fünf Sc \chord{a}hwäne durch die Ödmark ziehn, ein K \chord{F}önig mit vier \\
|
||||
Recken hin, im \chord{G}Morgenrot ihr Banner fliegt, und \chord{a}weiter geht \\
|
||||
es f \chord{E}ür das gute Ziel.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
In Feindes Lager hört man's auch; \\
|
||||
durch Stille säuselt Melodie, \\
|
||||
und Herzen horchen wie noch nie. \\
|
||||
Die Klampfe in der Hand \\
|
||||
der Spielmann singt allein, \\
|
||||
und alles lauscht in dieser Nacht. \\
|
||||
Doch plötzlich hinter sich hört er \\
|
||||
wie vereint der Chor, \\
|
||||
und alle stimmen ein in diese Melodie:
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Fünf Schwäne durch die Ödmark ziehn...
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und als der Morgen hell erstrahlt, \\
|
||||
die Schlacht beginnt, die Trommel warnt. \\
|
||||
Vorne steht ein Grenadier, \\
|
||||
der denkt zurück und legt nieder das Schwert.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und als drei Jahr' vergangen war'n \\
|
||||
das Feld liegt öd und leer vornweg, \\
|
||||
und nichts erinnert mehr daran; \\
|
||||
an eine nicht gewes'ne Schlacht, \\
|
||||
doch plötzlich hört man dann der Nachtigallen Schlag. \\
|
||||
Als ob eine fremde Melodie zog über das Land; \\
|
||||
von Ferne weht ein Wind und trägt sie fort.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Fünf Schwäne durch die Ödmark ziehn...
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Lalalalala...
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und der Spielmann singt ein Lied.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
45
songs/bella-ciao.tex
Normal file
45
songs/bella-ciao.tex
Normal file
@@ -0,0 +1,45 @@
|
||||
\begin{song}{
|
||||
title = {Bella Ciao},
|
||||
lyrics = {italienisches Volkslied},
|
||||
composer = {italienisches Volkslied},
|
||||
libock = 328,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Una matti \chord{e}na mi sono alzato , o bella ciao …, \\
|
||||
Una matti \chord{a}na mi sono alza \chord{e}to, \\
|
||||
e ho tro \chord{H7}vato l’invas \chord{e}or.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
O partigiano, portami via, o bella ciao …, \\
|
||||
O partigiano, portami via \\
|
||||
Ché mi sento di morir
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
E se io muoio da partigiano, o bella ciao …, \\
|
||||
E se io muoio da partigiano, \\
|
||||
tu mi devi seppellir.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
E seppellire lassù in montagna, o bella ciao …, \\
|
||||
E seppellire lassù in montagna, \\
|
||||
Sotto l’ombra di un bel fior.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Tutte le genti che passeranno, o bella ciao …, \\
|
||||
Tutte le genti che passeranno \\
|
||||
Mi diranno: "Che bel fior"
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
E questo è il fiore del partigiano, \\
|
||||
o bella ciao …, \\
|
||||
/: E Questo è il fiore del partigiano \\
|
||||
Morto per la libertà :/
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
44
songs/big-bomb-dolly-aus-dover.tex
Normal file
44
songs/big-bomb-dolly-aus-dover.tex
Normal file
@@ -0,0 +1,44 @@
|
||||
\begin{song}{
|
||||
title = {Big Bomb Dolly aus Dover},
|
||||
lyrics = {Fritz Graßhoff},
|
||||
composer = {klappe (Klaus Eckhardt)},
|
||||
cl = 384,
|
||||
libock = 416,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Wir ko \chord{e}mmen mit dem W \chord{H7}alfischkahn zwei \chord{C}mal im J \chord{H7}ahr nach \\
|
||||
Ha \chord{e}us. Ne Wolke Dunst von \chord{H7}Lebertran, die \chord{C}weht dem \chord{H7}Pott \\
|
||||
vor \chord{e}aus. \\
|
||||
\chord{D}Wi \chord{G}r mähen unser \chord{D}Sauerkraut und \chord{G}wetzen in die \chord{D}Stadt, \\
|
||||
weil d \chord{e}ie perfekte S \chord{H7}eemannsbraut uns lä \chord{C}ngst ge \chord{H7}rochen h \chord{e}at.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Refrain:
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
D \chord{D}as ist die \chord{G}Big Bomb \chord{D}Dolly aus D \chord{G}over, hehohe! \\
|
||||
Die hat Sprengstoff u \chord{D}nterm Pullo \chord{G}ver, hehohe! \\
|
||||
Die macht \chord{D}Feuer aus der Heuer in der \chord{G} Pinte an der See, \\
|
||||
dass die F \chord{D}lusen nur so brennen und die L \chord{G}eute in Calais \\
|
||||
nachts die Z \chord{e}eitung l \chord{H7}ese \chord{e}n \chord{H7}kön \chord{e}nen!
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Sitzt mal der Käptn abgebrannt in Schulden und in Gin, \\
|
||||
dann gehen wir mit Fisch an Hand und ohne Piepen hin. Die \\
|
||||
Dame ist nicht lasterhaft, drum rechnet sie genau. Sie nimmt \\
|
||||
für ihre Einsatzkraft zwei Zentner Kabeljau.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Es bleibt nicht aus, dass unser Pott den letzten Kai bezieht. \\
|
||||
Im Seemannsheim „Zum lieben Gott“ gibt ’s nichts auf dem \\
|
||||
Gebiet. Doch findet unser scharfer Blick das ferne Leuchtsig- \\
|
||||
nal. Denn unten brennt ja noch zum Glück das Feuer am Ka- \\
|
||||
nal!
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
36
songs/bin-ja-nur-ein-armer-zigeuner.tex
Normal file
36
songs/bin-ja-nur-ein-armer-zigeuner.tex
Normal file
@@ -0,0 +1,36 @@
|
||||
\begin{song}{
|
||||
title = {Bin ja nur ein armer Zigeuner},
|
||||
lyrics = {Andreas Hönisch},
|
||||
composer = {Andreas Hönisch},
|
||||
cl = 46,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Ref.: \\
|
||||
/: \chord{D}Bin ja nur ein \chord{A}armer Zig \chord{D}euner, \\
|
||||
habe nichts als Wage \chord{A}n und Pfer \chord{D}d. :/ \\
|
||||
No \chord{D}rmandie \chord{G}und Pyren \chord{D}äe \chord{A}n, \\
|
||||
h \chord{D}ab die ha \chord{G}lbe Wel \chord{D}t geseh \chord{A}n.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Keiner fragt, woher ich komm‘, \\
|
||||
keiner fragt, wohin ich geh‘.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Kommt mein Wagen vor ein Dorf, \\
|
||||
laufen schon die Leute fort.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Nur die Kinder bleiben steh‘n, \\
|
||||
wollen den Zigeuner seh‘n.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Führt der Weg wer weiß wohin, \\
|
||||
der da droben kennt mein Ziel.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
53
songs/birkenring.tex
Normal file
53
songs/birkenring.tex
Normal file
@@ -0,0 +1,53 @@
|
||||
\begin{song}{
|
||||
title = {Birkenring},
|
||||
lyrics = {Margarete Jehn},
|
||||
composer = {Wolfgang Jehn},
|
||||
cl = 330,
|
||||
barde = 327,
|
||||
libock = 356,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Warum zöge \chord{d}rst du noch und bleibst ste \chord{A}hn in der Nacht? \\
|
||||
Horch, im Wald hinterm Dorf ist der S \chord{d}ommer erwacht! \\
|
||||
Tritt doch näher, mein Freund, und reich \chord{A}mir deine Hand, \\
|
||||
komm herein in den fröhlichen Bir \chord{d}kenring.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Refrain: \\
|
||||
Sieh das Glüc \chord{g}k wird vergehn, denn die Ze \chord{d}it bleibt nicht \\
|
||||
stehn, mit den W \chord{A}inden wird der S \chord{d}ommer vergeh \chord{d7}n. \\
|
||||
Drum drück fes \chord{g}t an dein Herz, was die Fr \chord{d}eude dir gibt, \\
|
||||
komm herei \chord{A}n in den fröhlichen Bi \chord{d}rkenring.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Was die Kantele sagt, darfst du glauben, mein Freund. \\
|
||||
Heut wird wahr, was du einsam im Winter geträumt! \\
|
||||
Wenn die Liebe dir lacht, wend’ nicht ab deinen Blick, \\
|
||||
komm herein in den fröhlichen Birkenring.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Einmal kam ich auch schon als Wanderbursch’ her. Ich \\
|
||||
wollt gern was erleben, ich wünschte es so sehr. Und der \\
|
||||
Frühling war mir eine zweite Geburt, komm herein in den \\
|
||||
fröhlichen Birkenring.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und nun wohne ich hier, hier im Wald bei dem Weib. \\
|
||||
Und im Winter, da sagt mir die Kantele bald. Wirst Du \\
|
||||
staunen und schaun, was du einsam geträumt. Komm \\
|
||||
herein in den fröhlichen Birkenring.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Wenn du mitsummst das Lied und ein Blick tut dir kund, \\
|
||||
in den Augen der anderen spiegelt sich‘s bunt, die Liebe \\
|
||||
zu dir in den Farben der Welt, dann bleib hier in dem \\
|
||||
fröhlichen Birkenring.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
58
songs/bis-in-die-roten-morgenstunden.tex
Normal file
58
songs/bis-in-die-roten-morgenstunden.tex
Normal file
@@ -0,0 +1,58 @@
|
||||
\begin{song}{
|
||||
title = {Bis in die roten Morgenstunden},
|
||||
lyrics = {Lina Sobral Bastos, Frederick Oelbaum},
|
||||
composer = {Lina Sobral Bastos, Frederick Oelbaum},
|
||||
libock = 92,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Die \chord{C}Sonne ist schon unter \chord{a}gegangen \\
|
||||
\chord{F}in der Jurte die D \chord{C}unkelheit b \chord{G}richt. \\
|
||||
Ein F \chord{C}euer, das uns \chord{a}alle verbindet, \\
|
||||
die F \chord{F}lammen flackern w \chord{C}arm und \chord{G}dicht
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Rein in die Jurte auf den Teppich, \\
|
||||
Doch „Schuhe aus!“, sonst wird er dreckig. \\
|
||||
Gitarren werden schnell gestimmt, \\
|
||||
der Abend erst beginnt.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ref.: Der C \chord{F}hai geht durch die Runde und wir s \chord{C}ingen: \\
|
||||
\chord{E}Dai da da da d \chord{a}ai \\
|
||||
Bis in die r \chord{F}oten Morgenstunden wird es k \chord{C}lingen: \\
|
||||
\chord{E}Dai da da da \chord{a}da \chord{E}i
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Die letzte Runde schläft gleich ein, \\
|
||||
so soll es heute nicht sein. \\
|
||||
Die Kekse gehen rum „Hurra!“ \\
|
||||
Nach den Wölflingen ist keiner mehr da.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Das Leben als Wölfling ist echt hart, \\
|
||||
wenn man nicht ins Bett gehen mag. \\
|
||||
Das Leben als Pfadi ist auch nicht leicht, \\
|
||||
wenn drei Stunden schlafen nicht reicht.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Refrain
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Das F \chord{E}euer ist fast erl \chord{a}oschen. \\
|
||||
Um das l \chord{F}etzte Lied wurde oft best \chord{E}ochen. \\
|
||||
In \chord{F}ein paar Stunden ist A \chord{a}ufsteh-Zeit \\
|
||||
„ \chord{E}Allezeit be \chord{E7}reit!“
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Refrain 2x
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
32
songs/brennt-die-sonne.tex
Normal file
32
songs/brennt-die-sonne.tex
Normal file
@@ -0,0 +1,32 @@
|
||||
\begin{song}{
|
||||
title = {Brennt die Sonne},
|
||||
lyrics = {Andreas Hönisch},
|
||||
composer = {Andreas Hönisch},
|
||||
cl = 48,
|
||||
libock = 38,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Br \chord{D}ennt die Sonne, tropft der Regen, scheint \\
|
||||
verloren \chord{A}jeder Pfad, / ei \chord{e}ne Spur auf neuen Wegen, \\
|
||||
f \chord{A}ührt zum Ziel und \chord{E}führt zur T \chord{A}at. \\
|
||||
Ref.: \\
|
||||
/: Nie \chord{D}mals liegen bleiben, \chord{G}bis zum G \chord{A7}ipfel steigen \\
|
||||
Pfa \chord{D}dfinder in Europa: „ \chord{G}Al \chord{A}lezeit Be \chord{D}reit!“ :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Sind die Sohlen durchgelaufen, \\
|
||||
hängt der Magen bis zum Kinn, \\
|
||||
wollen Teufel mit uns raufen, \\
|
||||
weiter geht´s durch dick und dünn.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Unserm Herrn in Demut dienen \\
|
||||
mehr als Gold und Silber wiegt. \\
|
||||
Darum auch den Nächsten lieben, \\
|
||||
wenn er arm am Wege liegt.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
42
songs/bruder-nun-wird-es-abend.tex
Normal file
42
songs/bruder-nun-wird-es-abend.tex
Normal file
@@ -0,0 +1,42 @@
|
||||
\begin{song}{
|
||||
title = {Bruder, nun wird es Abend},
|
||||
lyrics = {olka (Erich Scholz)},
|
||||
composer = {olka (Erich Scholz)},
|
||||
bulibu = 386,
|
||||
cl = 49,
|
||||
swa = 26,
|
||||
barde = 39,
|
||||
libock = 42,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Bru \chord{a}der, nun wird es \chord{E}Abend, \\
|
||||
ni \chord{a}mm dir ein Glas zum Wei \chord{E}n. \\
|
||||
/: Sc \chord{G}he \chord{G7}nke \chord{C} Triodimali, T \chord{a}riodimali, T \chord{F}riodimali \chord{a}ein :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Stopf dir die lange Pfeife, \\
|
||||
denke nicht viel dabei. \\
|
||||
/: Singe Triodimali, Triodimali, Triodimali zwei. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Nichts will das Lied bedeuten, \\
|
||||
als etwas glücklich sein. \\
|
||||
/: Dreimal Triodimali, Triodimali, Triodimali drei. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Mondlampe lacht am Fenster, \\
|
||||
Schlaf klopft an die Tür. \\
|
||||
/: Leise Triodimali, Triodimali, Triodimali vier. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Traumschwere Worte fallen, \\
|
||||
Stille besiegt das Haus. \\
|
||||
/: Trinke Triodimali, Triodimali, Triodimali aus. :/
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
51
songs/buendische-vaganten.tex
Normal file
51
songs/buendische-vaganten.tex
Normal file
@@ -0,0 +1,51 @@
|
||||
\begin{song}{
|
||||
title = {Bündische Vaganten},
|
||||
lyrics = {Alfons „Alo“ Hamm (Trenk)},
|
||||
composer = {Alfons „Alo“ Hamm (Trenk)},
|
||||
bulibu = 50,
|
||||
cl = 176,
|
||||
swa = 117,
|
||||
barde = 137,
|
||||
libock = 174,
|
||||
}
|
||||
|
||||
% „Ayen“ ist der traditionelle Zugvogel-Gruß. Die Bundesordnung sagt hierüber
|
||||
% nur: „Unser Bundesgruß in Ruf und Brief ‘Ayen‘ ist eine Verstümmelung des
|
||||
% Wortes ‘Mayenne‘ und hat eine historische Bedeutung.“
|
||||
% Angeblich war „Mayenne“ das Codewort das die Posten der „Ritter von Ma-
|
||||
% yenne“ an der Westfront verbindenden Kradmelders (Motorrad-Kurier). Und
|
||||
% über das Knattern des Motors blieb von dem Codewort eben nur „...ayenne“
|
||||
% übrig.
|
||||
|
||||
\begin{verse}
|
||||
H \chord{e}ei, wie vorn der F \chord{H7}etzen fliegt, \\
|
||||
hei, w \chord{e}ie er sich im W \chord{H7}inde wiegt \\
|
||||
\chord{a}ohne Ra \chord{e}st und o \chord{H7}hne \chord{e} Ruh. \\
|
||||
So wiegen wir mit fr \chord{H7}eiem Sinn \\
|
||||
uns ü \chord{e}ber tausend St \chord{H7}raßen hin, \\
|
||||
\chord{a}ohne E \chord{e}nde, i \chord{H7}mmerz \chord{e}u.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ref: \\
|
||||
B \chord{C}ündische Va \chord{G}ganten \\
|
||||
ti \chord{D}ppeln in die Welt. Hey - t \chord{G}ippeln in die Welt. \\
|
||||
B \chord{C}ündische Vaga \chord{G}nten \\
|
||||
tip \chord{D}peln in die Welt - Ho o Ay \chord{G}en!
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Treiben wir dem Süden zu, lässt uns der Norden keine Ruh, \\
|
||||
überall zu Haus sind wir. \\
|
||||
Mal rüber nach Amerika, mal runter bis nach Afrika - \\
|
||||
Hoija - hoija - das sind wir.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Hast du noch ein jung Gesicht, so zage nicht und fackle \\
|
||||
nicht, frage niemals nach dem Wie! \\
|
||||
Wer nur am Rand der Straße klebt, für seinen dummen \\
|
||||
Bauche lebt, misst der Ferne Zauber nie!
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
73
songs/buergerlied.tex
Normal file
73
songs/buergerlied.tex
Normal file
@@ -0,0 +1,73 @@
|
||||
\begin{song}{
|
||||
title = {Bürgerlied},
|
||||
lyrics = {Adalbert Harnisch, im Mai 1845 für den Elbinger Bürgerverein geschrieben},
|
||||
composer = {auf die Melodie von “Als Chursachsen das vernommen, dass der Turk vor Wien was kommen” aus dem Jahr 1683. Der Verfasser der Melodie ist nicht überliefert.},
|
||||
bulibu = 302,
|
||||
cl = 270,
|
||||
barde = 235,
|
||||
libock = 278,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Ob wir ro \chord{G}te, gelbe Kragen, Helme oder Hüte tragen, \\
|
||||
Stiefel tr \chord{G}agen oder Sc \chord{D}huh´; oder o \chord{G}b wir R \chord{C}öcke \\
|
||||
nähen und zu Sch \chord{D}uhen Dräh \chord{G}te drehen, das tut, da \chord{e}s \\
|
||||
tut, n \chord{a}ichts \chord{D}d \chord{G}azu.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ob wir können präsidieren \\
|
||||
oder müssen Akten schmieren \\
|
||||
ohne Rast und ohne Ruh´; \\
|
||||
ob wir just Collegia lesen, \\
|
||||
oder aber binden Besen, \\
|
||||
das tut, das tut nichts dazu.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ob wir stolz zu Rosse reiten, \\
|
||||
oder ob zu Fuß wir schreiten \\
|
||||
immer unserm Ziele zu; \\
|
||||
ob uns Kreuze vorne schmücken, \\
|
||||
oder Kreuze hinten drücken, \\
|
||||
das tut, das tut nichts dazu.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Das Bürgerlied (auch Königsberger Volkslied) ist ein 1845 von Adalbert \\
|
||||
Harnisch (* 1815, † 1889) geschriebenes Volkslied. Ursprünglich für den Bür- \\
|
||||
gerverein der Stadt Elbing (heute Elbląg) geschrieben, entwickelte es sich \\
|
||||
rasch zu einem beliebten Lied, zunächst in der nationalliberalen Bewegung \\
|
||||
und später in der Arbeiterbewegung. In der zweiten Hälfte des 20. Jahrhun- \\
|
||||
derts wurde es von deutschen Folk-Musikern wie Hannes Wader neu aufge- \\
|
||||
griffen und so einem breiten Publikum bekannt.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Aber ob wir Neues bauen, \\
|
||||
oder Altes nur verdauen, \\
|
||||
wie das Gras verdaut die Kuh; \\
|
||||
ob wir in der Welt was schaffen, \\
|
||||
oder nur die Welt begaffen, \\
|
||||
das tut, das tut was dazu.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ob wir rüstig und geschäftig, \\
|
||||
wo es gilt zu wirken kräftig, \\
|
||||
immer tapfer greifen zu, \\
|
||||
oder ob wir schläfrig denken: \\
|
||||
“Gott wird´s schon im Schlafe schenken“, \\
|
||||
das tut, das tut was dazu.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Drum ihr Bürger, drum ihr Brüder, \\
|
||||
alle eines Bundes Glieder; \\
|
||||
was auch jeder von uns tu! \\
|
||||
Alle die dies Lied gesungen, \\
|
||||
so die Alten wie die Jungen, \\
|
||||
tun wir, tun wir was dazu!
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
43
songs/burschen-burschen.tex
Normal file
43
songs/burschen-burschen.tex
Normal file
@@ -0,0 +1,43 @@
|
||||
\begin{song}{
|
||||
title = {Burschen, Burschen},
|
||||
lyrics = {aus Lettland, überliefert von Helmut König Worte (deutsch): Rudolf Blaumanis, Hans Schmidt},
|
||||
composer = {aus Lettland, überliefert von Helmut König Worte (deutsch): Rudolf Blaumanis, Hans Schmidt},
|
||||
bulibu = 341,
|
||||
cl = 50,
|
||||
swa = 29,
|
||||
barde = 41,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Bu \chord{a}rschen, Burs \chord{G}chen, wir v \chord{d}erderben \chord{a}, \\
|
||||
geht es fort so wil \chord{E}d und tol \chord{a}l. \\
|
||||
/: H \chord{a}ej \chord{G}, \chord{a}hej, hej, hej \chord{G}o, \chord{a} w \chord{E}il \chord{a}d und toll. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Von den Füßen weg gesoffen, \\
|
||||
werden bald die Stiefel sein.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Eine Nacht, zwei tolle Tage, \\
|
||||
zechen wir an diesem Ort.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Zechen wir an diesem Orte, \\
|
||||
hier in diesem blauen Krug.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Süß das Bier und weiß die Kannen, \\
|
||||
schön die flinke Krügerin.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Trinkt das Bier, zerschlagt die Kannen, \\
|
||||
küsst die schöne Krügerin. \\
|
||||
/ Hej, hej, hej, hejo, wenn sie will. /
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
37
songs/come-by-the-hills.tex
Normal file
37
songs/come-by-the-hills.tex
Normal file
@@ -0,0 +1,37 @@
|
||||
\begin{song}{
|
||||
title = {Come by the hills},
|
||||
lyrics = {Gordon Smith},
|
||||
composer = {traditionelles gälisches Lied “Buachaill On Eirne”},
|
||||
cl = 54,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Co \chord{D}me by the hi \chord{G}lls to the l \chord{D}and / where f \chord{G}ancy is fr \chord{D}ee. \\
|
||||
D G fis \\
|
||||
And stand where the peaks meet the sky / and the \\
|
||||
lo \chord{e}ughs meet the se \chord{A}a / where the ri \chord{D}vers run c \chord{D7}lear and \\
|
||||
the br \chord{G}acken is g \chord{D}old in the s \chord{A}un. \\
|
||||
Ref.: \\
|
||||
And the care \chord{h}s of tomor \chord{G}row must w \chord{D}ait \\
|
||||
until t \chord{G}his day is d \chord{D}on \chord{A}e.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Come by the hills to the land \\
|
||||
where life is a song. \\
|
||||
And sing while the birds fill the air \\
|
||||
with there joy all day long. \\
|
||||
Where the trees sway in time \\
|
||||
and even the wind blows in tune
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Come by the hills to the land \\
|
||||
where legends remain. \\
|
||||
Where the stories of old fill the hearts \\
|
||||
and may yet come again. \\
|
||||
Where our past have been lost \\
|
||||
and the future is still to be won.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
32
songs/daemmert-von-fern.tex
Normal file
32
songs/daemmert-von-fern.tex
Normal file
@@ -0,0 +1,32 @@
|
||||
\begin{song}{
|
||||
title = {Dämmert von fern},
|
||||
lyrics = {Dietz Kuhnke},
|
||||
composer = {Dietz Kuhnke},
|
||||
bulibu = 12,
|
||||
cl = 55,
|
||||
swa = 30,
|
||||
barde = 45,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Dä \chord{E}mmert von fern über Hügel \chord{H7} der Morgen \chord{E}, \\
|
||||
geht durch das Lager der Weckr \chord{H7}uf der Poste \chord{E}n: \\
|
||||
Au \chord{E}f, Kamera \chord{H7}den, sa \chord{E}ttelt eure Pf \chord{H7}erde, \\
|
||||
/: we \chord{E}iter geht \chord{A}unser Ritt \chord{E}über die r \chord{H7}ote \chord{E} Erde. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Staub wirbelt auf, dumpfes Prasseln der Hufe, \\
|
||||
von Mann zu Mann geht ein Lachen und Rufen. \\
|
||||
Singend grüßt ein Reiter hell die Sonne \\
|
||||
und im Chor fällt dann ein brausend die ganze Kolonne.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Jeder der Reiterkameraden mir zur Seite, \\
|
||||
mit seinem Leben dem Zeichen sich verschrieb. \\
|
||||
Höre du, mein Bruder dort am Wege, \\
|
||||
schwinge dich auf dein Pferd, zwinge die rote Erde.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
38
songs/das-gotenlied.tex
Normal file
38
songs/das-gotenlied.tex
Normal file
@@ -0,0 +1,38 @@
|
||||
\begin{song}{
|
||||
title = {Das Gotenlied},
|
||||
lyrics = {Felix Dahn, 1876},
|
||||
composer = {mündlich überliefert},
|
||||
cl = 163,
|
||||
swa = 109,
|
||||
barde = 125,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Gebt Raum \chord{C}, ihr Völker, un \chord{G7}serm Sch \chord{C}ritt, \\
|
||||
wir s \chord{F}ind die \chord{C}letzten \chord{G7}Got \chord{C}en. \\
|
||||
/: Wir tr \chord{F}agen k \chord{C}eine Kr \chord{F}one mi \chord{C}t, \\
|
||||
wir tragen einen T \chord{G7}ote \chord{C}n. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Mit Schild an Schild und Speer an Speer \\
|
||||
ziehn` wir nach Nordlands Winden, \\
|
||||
/: bis wir im fernsten grauen Meer \\
|
||||
die Insel Thule finden. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Das soll der Treue Insel sein, \\
|
||||
dort gilt noch Eid und Ehre, \\
|
||||
/: dort senken wir den König ein \\
|
||||
im Sarg der Eichenspeere. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Wir kommen her, gebt Raum dem Schritt, \\
|
||||
aus Romas falschen Toren. \\
|
||||
/: Wir tragen nur den König mit, \\
|
||||
die Krone ging verloren. :/
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
57
songs/das-leben.tex
Normal file
57
songs/das-leben.tex
Normal file
@@ -0,0 +1,57 @@
|
||||
\begin{song}{
|
||||
title = {Das Leben},
|
||||
lyrics = {Paul Dilz},
|
||||
composer = {Martin Möckel},
|
||||
bulibu = 314,
|
||||
cl = 56,
|
||||
swa = 33,
|
||||
libock = 46,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Das Le \chord{D}ben ist ein Wür \chord{G}felspiel, \\
|
||||
wir w \chord{A}ürfeln alle T \chord{D}age, \\
|
||||
dem ei \chord{D}nen schenkt das Leb \chord{G}en viel, \\
|
||||
dem an \chord{A7}dern Müh und Plage \chord{D}. \\
|
||||
Ref.: \\
|
||||
/: Drum frisch \chord{D}auf, Kameraden, den Be \chord{G}cher zur Hand, \\
|
||||
zwei Se \chord{D}chsen auf den Tis \chord{A}ch, ja auf den Tisch: \\
|
||||
die e \chord{D}ine für mein V \chord{G}aterland, die \chord{A7}andere für m \chord{D}ich. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Wir würfeln, dass die Platte kracht, \\
|
||||
nach alter Landsknechtssitte. \\
|
||||
Schon mancher, der das Spiel verlacht, \\
|
||||
verschwand aus unsrer Mitte.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Noch würfeln wir um unser Glück, \\
|
||||
und um ein gut Gelingen. \\
|
||||
Vielleicht auch bald um das Genick, \\
|
||||
wenn die Granaten klingen.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Doch Furcht, die ist uns unbekannt, \\
|
||||
wie auch die Würfel liegen. \\
|
||||
Wir kämpfen für das Vaterland, \\
|
||||
und glauben, dass wir siegen.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und noch beim Petrus wollen wir, \\
|
||||
den Würfelbecher schwingen, \\
|
||||
und noch im himmlischen Revier, \\
|
||||
Soldatenlieder singen.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
/: Drum frisch auf, Kameraden, den Becher zur Hand, \\
|
||||
zwei Sechsen auf den Tisch, ja auf den Tisch. \\
|
||||
Die beiden für die Seligkeit, \\
|
||||
das langt für dich und mich. :/
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
37
songs/das-lilienbanner.tex
Normal file
37
songs/das-lilienbanner.tex
Normal file
@@ -0,0 +1,37 @@
|
||||
\begin{song}{
|
||||
title = {Das Lilienbanner},
|
||||
lyrics = {Winü (Winfried Nuter)},
|
||||
composer = {Willi Jahn (Wir traben in die Weite)},
|
||||
bulibu = 13,
|
||||
cl = 58,
|
||||
swa = 34,
|
||||
barde = 47,
|
||||
libock = 48,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
Das Lil \chord{E}ie \chord{H7}nbanner wehe \chord{E}t, \\
|
||||
komm, Brude \chord{A}r, reic \chord{H7}h die Han \chord{E}d \\
|
||||
und wen \chord{E}n der Stur \chord{H7}m auch wehe \chord{E}t, \\
|
||||
wir f \chord{A}ahren dur \chord{H7}ch das La \chord{E}nd. \\
|
||||
Wir fa \chord{A}hren auf und ni \chord{E}e \chord{H7}de \chord{E}r, zur gu \chord{H7}ten Tat berei \chord{E}t, \\
|
||||
/: hell erkl \chord{E}ingen unsre Lieder: Gut P \chord{H7}fad, Allzeit Ber \chord{E}eit.:/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Und unsre gelbe Lilie ermahnet uns zur Pflicht, \\
|
||||
dass keiner je die Treue zu dieser Fahne bricht. \\
|
||||
/: Und wie des Löwen Stärke und wie des Adlers Flug, \\
|
||||
so sein auch uns‘re Werke uns selber nie genug. :/
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Kommt, Brüder, reicht die Hände \\
|
||||
kommt, Brüder, haltet Schritt, \\
|
||||
lasst lohn des Herzens Brände \\
|
||||
und singet freudig mit. \\
|
||||
/: Seht, wie die Banner wehen, im jungen Morgenrot. \\
|
||||
Wir wollen kämpfend siegen, wohl über unsre Not. :/
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
39
songs/das-schiff-im-nebel.tex
Normal file
39
songs/das-schiff-im-nebel.tex
Normal file
@@ -0,0 +1,39 @@
|
||||
\begin{song}{
|
||||
title = {Das Schiff im Nebel},
|
||||
lyrics = {Manu, Stamm Normannen, BdP Berlin},
|
||||
composer = {Manu, Stamm Normannen, BdP Berlin},
|
||||
bulibull = 2,
|
||||
cl = 86,
|
||||
libock = 302,
|
||||
}
|
||||
|
||||
\begin{verse}
|
||||
S \chord{H7}iebzehn Tage fäh \chord{e}rt das Schiff, durch Ne \chord{a}bel Wellen, \\
|
||||
Gi \chord{H7}scht. Und wir ha \chord{C}ben keinen Kur \chord{H7}s und auch \chord{a} zu ess \chord{H7}en \\
|
||||
n \chord{e}icht. Wir t \chord{F}asten uns v \chord{C}or \chord{a}, durch die graue W \chord{H7}and, fast \\
|
||||
s \chord{F}tirbt in uns s \chord{e}chon die H \chord{C}offnung auf L \chord{H7}and.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ref.: \\
|
||||
Wir fahr’n dah \chord{e}in, wir fahr’n da \chord{a}hin, wir fahr’n da \chord{e}hin, auf \\
|
||||
hoher Se \chord{H7}e, wir fahr’n dahi \chord{e}n, durch graue Not \chord{C}, wir \\
|
||||
fahr’n da \chord{e}hin, denn unser Ste \chord{H7}uermann ist tot.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Ein Schlag links und einer rechts, doch geradeaus \\
|
||||
geht’s nicht. Wabernd graue Teufelsflossen nehmen \\
|
||||
jedes Licht. Angst greift um sich, wann endet diese \\
|
||||
Fahrt? Vielleicht stirbt schon morgen auch der erste \\
|
||||
Maat.
|
||||
\end{verse}
|
||||
|
||||
\begin{verse}
|
||||
Das Schiff im Nebel ist verlor’n, denn keiner weiß wo- \\
|
||||
hin. Nur Rudern, Rudern, Ausschau halten ohne viel \\
|
||||
Gewinn! Wir tasten uns vor, durch die graue Wand, \\
|
||||
fast stirbt in uns schon die Hoffnung auf Land.
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
40
songs/dat-du-min-leewsten-buest.tex
Normal file
40
songs/dat-du-min-leewsten-buest.tex
Normal file
@@ -0,0 +1,40 @@
|
||||
\begin{song}{
|
||||
title = {Dat du min Leewsten büst},
|
||||
lyrics = {Karl Müllenhoff 1845: 1-3.Strophe Iven Kruse 1925: 4+5. Strophe},
|
||||
composer = {aus Norddeutschland, möglicherweise nach „Dass du mein Schätzgen bist.“ Oder nach einer Weise von Josef Anton Steffau ,1760},
|
||||
bulibu = 143,
|
||||
cl = 59,
|
||||
swa = 35,
|
||||
barde = 48,
|
||||
}
|
||||
|
||||
% Der Verfasser des Textes ist unbekannt. Veröffentlicht wurde der Text zum
|
||||
% ersten Mal 1845 von Karl Müllenhoff. Nach den Forschungsergebnissen des
|
||||
% Historisch-kritischen Liederlexikons (Liederlexikon) „hat das Lied seinen Ur-
|
||||
% sprung in dem bereits in der zweiten Hälfte des 18.Jahrhunderts recht bekann-
|
||||
% ten erotischen Gassenhauer: Daß du mein Schätzgen bist“. Während viele
|
||||
% Lieder das Freimauerlied „Laßt uns, ihr Brüder, Freiheit erhöhn“, Entstehungs-
|
||||
% jahr 1778, als Herkunft der Melodie angeben, führt das Liederlexikon die
|
||||
% Grundlage der heute noch gesungenen Melodie auf einen 1760 erschienen
|
||||
% Variationssatz für Cembalo des Komponisten Josef Anton Steffan zurück. Die
|
||||
% meisten Veröffentlichungen weisen nur drei Strophen auf. Das nächtliche Ge-
|
||||
% schehen wird der Vorstellungskraft überlassen. Auch die beiden zusätzlichen
|
||||
% Strophen, erstmals veröffentlicht 1925 in Hamburger Jugendlicher (2.Heft-
|
||||
% Nestlieder) die vom holsteinischen Schriftsteller Iven Kruse stammen sollen,
|
||||
% setzen erst am anderen Morgen an.
|
||||
|
||||
\begin{verse}
|
||||
D \chord{D}at du min L \chord{A}eewsten büst, d \chord{D}at du wull w \chord{A}eest. \\
|
||||
/: K \chord{G}umm bi de Nacht, k \chord{D}umm bi de Nacht, \\
|
||||
se \chord{A}gg, wo du h \chord{D}eeßt! :/ \\
|
||||
Kumm du üm Middernacht, kumm du Klock een! \\
|
||||
/: Vader slöpt, Moder slöpt, ick slaap aleen! :/ \\
|
||||
Klopp an de Kammerdör, fat an de Klink! \\
|
||||
/: Vader meent, Moder meent, dat deit de Wind. :/ \\
|
||||
Kummt denn de Morgenstund, kreiht de ol Hahn. \\
|
||||
/:Leevster min, Leevster min, denn mösst du gahn!:/ \\
|
||||
Sachen den Gang henlank, leis mit de Klink! \\
|
||||
/:Vader meent, Moder meent, dat deit de Wind.:/
|
||||
\end{verse}
|
||||
|
||||
\end{song}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user