2 Commits

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:35:27 +01:00
shahondin1624
8e4728c55a feat: add support for foreword/preface pages (Closes #1)
Add ForewordConfig to BookConfig, Foreword model type, ForewordParser for
text files (quote/paragraphs/signatures), ForewordPage in PageContent,
pipeline integration to insert foreword after TOC, and PDF rendering with
styled quote, horizontal rule separator, word-wrapped paragraphs, and
right-aligned signatures.

Also adds Gradle wrapper and adjusts build toolchain for JDK 25 compat.

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

37
.gitignore vendored
View File

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

118
CLAUDE.md
View File

@@ -2,83 +2,79 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build Commands
## Build & Test Commands
```bash
# Build the songbook PDF (two-pass for TOC)
make
# Build everything
gradle build
# Remove auxiliary files
make clean
# Run all tests
gradle test
# Remove everything including PDF
make distclean
# 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
```
Requires LuaLaTeX (TeX Live) and the `leadsheets` package.
Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.1.
## Project Structure
## Architecture
**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:**
```
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 ← parser
model ← layout
model ← renderer-pdf
parser, layout, renderer-pdf ← app
app ← cli (Clikt)
app, parser ← gui (Compose Desktop)
```
## How It Works
`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.
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
**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
## Song Format
Each song uses the `leadsheets` `song` environment:
ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
```latex
\begin{song}{
title = Song Title,
lyrics = Lyricist,
composer = Composer,
key = G,
mundorgel = 42,
pfadfinderliederbuch = 118,
note = {Optional note text.},
}
## Test Patterns
\begin{verse}
\chord{G}Lyrics with \chord{D}chords above. \\
Next \chord{C}line here.
\end{verse}
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}
Second verse without chords (or with).
\end{verse}
## Package
\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)
All code under `de.pfadfinder.songbook.*` — subpackages match module names (`.model`, `.parser`, `.layout`, `.renderer.pdf`, `.app`, `.cli`, `.gui`).

View File

@@ -1,36 +0,0 @@
MAIN = songbook
ENGINE = lualatex
OUTDIR = output
TEXENV = TEXINPUTS=.:$(shell pwd):$(shell pwd)/$(OUTDIR):
FLAGS = --output-directory=$(OUTDIR) --interaction=nonstopmode
.PHONY: all clean distclean
all: $(OUTDIR)/$(MAIN).pdf
$(OUTDIR):
mkdir -p $(OUTDIR)
# Run until page references stabilize (max 5 passes).
# The .songtoc file needs: pass 1 to write, pass 2 to read into TOC,
# pass 3+ to stabilize page numbers after TOC page count changes.
$(OUTDIR)/$(MAIN).pdf: $(MAIN).tex songbook-style.sty songs/*.tex | $(OUTDIR)
@for i in 1 2 3 4 5; do \
echo "=== LaTeX pass $$i ==="; \
$(TEXENV) $(ENGINE) $(FLAGS) $(MAIN).tex || true; \
if [ -f $(OUTDIR)/$(MAIN).songtoc ]; then \
python3 -c "import re;lines=open('$(OUTDIR)/$(MAIN).songtoc').readlines();lines.sort(key=lambda l:re.sub(r'[^a-zäöüß ]','',re.search(r'\{(?:\\\\textit\s*\{)?([^}]+)',l).group(1).lower()) if re.search(r'\{(?:\\\\textit\s*\{)?([^}]+)',l) else '');open('$(OUTDIR)/$(MAIN).songtoc','w').writelines(lines)" ; \
fi; \
if [ $$i -ge 3 ] && ! grep -q "Rerun" $(OUTDIR)/$(MAIN).log 2>/dev/null; then \
echo "=== Stable after $$i passes ==="; \
break; \
fi; \
done
clean:
rm -f $(OUTDIR)/*.aux $(OUTDIR)/*.log $(OUTDIR)/*.out \
$(OUTDIR)/*.toc $(OUTDIR)/*.fls $(OUTDIR)/*.fdb_latexmk \
$(OUTDIR)/*.sxd $(OUTDIR)/*.sxc $(OUTDIR)/*.songtoc
distclean: clean
rm -f $(OUTDIR)/$(MAIN).pdf

View File

@@ -1,298 +0,0 @@
% 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}

16
app/build.gradle.kts Normal file
View File

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

View File

@@ -0,0 +1,183 @@
package de.pfadfinder.songbook.app
import de.pfadfinder.songbook.model.*
import de.pfadfinder.songbook.parser.*
import de.pfadfinder.songbook.parser.ForewordParser
import de.pfadfinder.songbook.layout.*
import de.pfadfinder.songbook.renderer.pdf.*
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" }
// 2b. Parse foreword (if configured)
var foreword: Foreword? = null
val forewordConfig = config.foreword
if (forewordConfig != null) {
val forewordFile = File(projectDir, forewordConfig.file)
if (forewordFile.exists()) {
logger.info { "Parsing foreword: ${forewordFile.absolutePath}" }
foreword = ForewordParser.parseFile(forewordFile)
} else {
logger.warn { "Foreword file not found: ${forewordFile.absolutePath}" }
}
}
// 3. Measure songs
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)
// Foreword always takes 2 pages (for double-sided printing)
val forewordPages = if (foreword != null) 2 else 0
val paginationEngine = PaginationEngine(config)
val pages = paginationEngine.paginate(measuredSongs, tocPages + forewordPages)
val tocEntries = tocGenerator.generate(pages, tocPages + forewordPages)
// Build final page list with foreword pages inserted before song content
val allPages = mutableListOf<PageContent>()
if (foreword != null) {
allPages.add(PageContent.ForewordPage(foreword, 0))
allPages.add(PageContent.ForewordPage(foreword, 1))
}
allPages.addAll(pages)
val layoutResult = LayoutResult(
tocPages = tocPages,
pages = allPages,
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
}
}

View File

@@ -0,0 +1,534 @@
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
}
}

4
build.gradle.kts Normal file
View File

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

12
buildSrc/build.gradle.kts Normal file
View File

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

View File

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

17
cli/build.gradle.kts Normal file
View File

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

View File

@@ -0,0 +1,37 @@
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)
}
}
}

View File

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

View File

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

Binary file not shown.

1
gradle.properties Normal file
View File

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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

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

248
gradlew vendored Executable file
View File

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

93
gradlew.bat vendored Normal file
View File

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

20
gui/build.gradle.kts Normal file
View File

@@ -0,0 +1,20 @@
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"
}
}

View File

@@ -0,0 +1,347 @@
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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Some files were not shown because too many files have changed in this diff Show More