1 Commits

Author SHA1 Message Date
shahondin1624
13d1d4525c feat: display reference book page numbers in song page footer (Closes #3)
Render a two-row footer at the bottom of each song page showing reference
book abbreviations as column headers with corresponding page numbers below.
A thin separator line is drawn above the footer. MeasurementEngine now
reserves vertical space for the reference footer when reference books are
configured and the song has references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:32:09 +01:00
25 changed files with 70 additions and 1511 deletions

View File

@@ -35,17 +35,16 @@ Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.
## Architecture ## Architecture
**Pipeline:** Parse → Validate → Measure → Paginate → Render **Pipeline:** Parse → Measure → Paginate → Render
`SongbookPipeline` (in `app`) orchestrates the full flow: `SongbookPipeline` (in `app`) orchestrates the full flow:
1. `ConfigParser` reads `songbook.yaml``BookConfig` 1. `ConfigParser` reads `songbook.yaml``BookConfig`
2. `ChordProParser` reads `.chopro`/`.cho`/`.crd` files → `Song` objects 2. `ChordProParser` reads `.chopro` files → `Song` objects
3. `ForewordParser` reads optional `foreword.txt``Foreword` (if configured) 3. `Validator` checks config and songs
4. `Validator` checks config and songs 4. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
5. `MeasurementEngine` calculates each song's height in mm using `FontMetrics` 5. `TocGenerator` estimates TOC page count and creates entries
6. `TocGenerator` estimates TOC page count and creates entries 6. `PaginationEngine` arranges songs into pages (greedy spread packing)
7. `PaginationEngine` arranges songs into pages (greedy spread packing) 7. `PdfBookRenderer` generates the PDF via OpenPDF
8. `PdfBookRenderer` generates the PDF via OpenPDF
**Module dependency graph:** **Module dependency graph:**
``` ```
@@ -63,39 +62,14 @@ app, parser ← gui (Compose Desktop)
## Key Types ## Key Types
- `Song` → sections → `SongLine``LineSegment(chord?, text)` — chord is placed above the text segment. Also has `aliases`, `lyricist`, `composer`, `key`, `tags`, `notes: List<String>`, `references: Map<String, Int>` (bookId → page), `capo` - `Song` → sections → `SongLine``LineSegment(chord?, text)` — chord is placed above the text segment
- `SongLine` — holds `segments` plus optional `imagePath` (when set, the line is an inline image) - `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`
- `Foreword``quote`, `paragraphs`, `signatures` — parsed from a plain-text file
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`, `ForewordPage`
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT` - `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
- `BookConfig` — top-level config with `FontsConfig`, `LayoutConfig`, `TocConfig`, `ForewordConfig`, `ReferenceBook` list. `FontSpec.file` supports custom font files. `LayoutConfig.metadataLabels` (`"abbreviated"` or `"german"`) and `metadataPosition` (`"top"` or `"bottom"`) control metadata rendering
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts - `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
## Song Format ## Song Format
ChordPro-compatible `.chopro`/`.cho`/`.crd` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples. ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
**Metadata directives:** `{title: }` / `{t: }`, `{alias: }`, `{lyricist: }`, `{composer: }`, `{key: }`, `{tags: }`, `{note: }`, `{capo: }`
**Section directives:** `{start_of_verse}` / `{sov}`, `{end_of_verse}` / `{eov}`, `{start_of_chorus}` / `{soc}`, `{end_of_chorus}` / `{eoc}`, `{start_of_repeat}` / `{sor}`, `{end_of_repeat}` / `{eor}`. Section starts accept an optional label. `{chorus}` inserts a chorus reference, `{repeat}` sets a repeat label.
**Notes block:** `{start_of_notes}` / `{son}``{end_of_notes}` / `{eon}` — multi-paragraph rich-text notes rendered at the end of a song.
**Inline image:** `{image: path}` — embeds an image within a song section.
**Reference:** `{ref: bookId pageNumber}` — cross-reference to a page in another songbook (configured in `reference_books`).
## Configuration
`songbook.yaml` at the project root. Key options beyond the basics:
- `fonts.<role>.file` — path to a custom font file (TTF/OTF) for any font role (`lyrics`, `chords`, `title`, `metadata`, `toc`)
- `layout.metadata_labels``"abbreviated"` (M:/T:) or `"german"` (Worte:/Weise:)
- `layout.metadata_position``"top"` (after title) or `"bottom"` (bottom of last page)
- `toc.highlight_column` — abbreviation of the reference-book column to highlight (e.g. `"CL"`)
- `foreword.file` — path to a foreword text file (default `./foreword.txt`)
- `reference_books` — list of `{id, name, abbreviation}` for cross-reference columns in the TOC
- `songs.order``"alphabetical"` or `"manual"` (file-system order)
## Test Patterns ## Test Patterns

View File

@@ -2,7 +2,6 @@ package de.pfadfinder.songbook.app
import de.pfadfinder.songbook.model.* import de.pfadfinder.songbook.model.*
import de.pfadfinder.songbook.parser.* import de.pfadfinder.songbook.parser.*
import de.pfadfinder.songbook.parser.ForewordParser
import de.pfadfinder.songbook.layout.* import de.pfadfinder.songbook.layout.*
import de.pfadfinder.songbook.renderer.pdf.* import de.pfadfinder.songbook.renderer.pdf.*
import mu.KotlinLogging import mu.KotlinLogging
@@ -28,12 +27,9 @@ class SongbookPipeline(private val projectDir: File) {
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))) return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
} }
logger.info { "Parsing config: ${configFile.absolutePath}" } logger.info { "Parsing config: ${configFile.absolutePath}" }
val rawConfig = ConfigParser.parse(configFile) val config = ConfigParser.parse(configFile)
// Resolve font file paths relative to the project directory // Validate config
val config = resolveFontPaths(rawConfig)
// Validate config (including font file existence)
val configErrors = Validator.validateConfig(config) val configErrors = Validator.validateConfig(config)
if (configErrors.isNotEmpty()) { if (configErrors.isNotEmpty()) {
return BuildResult(false, errors = configErrors) return BuildResult(false, errors = configErrors)
@@ -84,19 +80,6 @@ class SongbookPipeline(private val projectDir: File) {
logger.info { "Parsed ${sortedSongs.size} songs" } 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 // 3. Measure songs
val fontMetrics = PdfFontMetrics() val fontMetrics = PdfFontMetrics()
val measurementEngine = MeasurementEngine(fontMetrics, config) val measurementEngine = MeasurementEngine(fontMetrics, config)
@@ -106,25 +89,14 @@ class SongbookPipeline(private val projectDir: File) {
val tocGenerator = TocGenerator(config) val tocGenerator = TocGenerator(config)
val tocPages = tocGenerator.estimateTocPages(sortedSongs) 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 paginationEngine = PaginationEngine(config)
val pages = paginationEngine.paginate(measuredSongs, tocPages + forewordPages) val pages = paginationEngine.paginate(measuredSongs, tocPages)
val tocEntries = tocGenerator.generate(pages, tocPages + forewordPages) val tocEntries = tocGenerator.generate(pages, tocPages)
// 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( val layoutResult = LayoutResult(
tocPages = tocPages, tocPages = tocPages,
pages = allPages, pages = pages,
tocEntries = tocEntries tocEntries = tocEntries
) )
@@ -152,39 +124,13 @@ class SongbookPipeline(private val projectDir: File) {
) )
} }
/**
* Resolves font file paths relative to the project directory.
* If a FontSpec has a `file` property, it is resolved against projectDir
* to produce an absolute path.
*/
private fun resolveFontPaths(config: BookConfig): BookConfig {
fun FontSpec.resolveFile(): FontSpec {
val fontFile = this.file ?: return this
val fontFileObj = File(fontFile)
// Only resolve relative paths; absolute paths are left as-is
if (fontFileObj.isAbsolute) return this
val resolved = File(projectDir, fontFile)
return this.copy(file = resolved.absolutePath)
}
val resolvedFonts = config.fonts.copy(
lyrics = config.fonts.lyrics.resolveFile(),
chords = config.fonts.chords.resolveFile(),
title = config.fonts.title.resolveFile(),
metadata = config.fonts.metadata.resolveFile(),
toc = config.fonts.toc.resolveFile()
)
return config.copy(fonts = resolvedFonts)
}
fun validate(): List<ValidationError> { fun validate(): List<ValidationError> {
val configFile = File(projectDir, "songbook.yaml") val configFile = File(projectDir, "songbook.yaml")
if (!configFile.exists()) { if (!configFile.exists()) {
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found")) return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
} }
val rawConfig = ConfigParser.parse(configFile) val config = ConfigParser.parse(configFile)
val config = resolveFontPaths(rawConfig)
val errors = mutableListOf<ValidationError>() val errors = mutableListOf<ValidationError>()
errors.addAll(Validator.validateConfig(config)) errors.addAll(Validator.validateConfig(config))

Binary file not shown.

View File

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

248
gradlew vendored
View File

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

93
gradlew.bat vendored
View File

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

View File

@@ -15,16 +15,9 @@ class MeasurementEngine(
// Title height // Title height
heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
// Metadata lines (composer/lyricist) - may be 1 or 2 lines depending on label style // Metadata line (composer/lyricist)
if (song.composer != null || song.lyricist != null) { if (song.composer != null || song.lyricist != null) {
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
val useGerman = config.layout.metadataLabels == "german"
if (useGerman && song.lyricist != null && song.composer != null && song.lyricist != song.composer) {
// Two separate lines: "Worte: ..." and "Weise: ..."
heightMm += metaLineHeight * 2
} else {
heightMm += metaLineHeight
}
} }
// Key/capo line // Key/capo line
@@ -50,44 +43,26 @@ class MeasurementEngine(
// Lines in section // Lines in section
for (line in section.lines) { for (line in section.lines) {
if (line.imagePath != null) { val hasChords = line.segments.any { it.chord != null }
// Inline image: estimate height as 40mm (default image block height) val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size)
heightMm += 40f if (hasChords) {
heightMm += 2f // gap around image val chordHeight = fontMetrics.measureLineHeight(config.fonts.chords, config.fonts.chords.size)
heightMm += chordHeight + config.layout.chordLineSpacing + lyricHeight
} else { } else {
val hasChords = line.segments.any { it.chord != null } heightMm += lyricHeight
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
} }
heightMm += 0.35f // ~1pt gap between lines
} }
// Verse spacing // Verse spacing
heightMm += config.layout.verseSpacing heightMm += config.layout.verseSpacing
} }
// Notes at bottom (with word-wrap estimation for multi-paragraph notes) // Notes at bottom
if (song.notes.isNotEmpty()) { if (song.notes.isNotEmpty()) {
heightMm += 1.5f // gap before notes heightMm += 1.5f // gap
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f for (note in song.notes) {
// A5 content width in mm = 148 - inner margin - outer margin heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
val contentWidthMm = 148f - config.layout.margins.inner - config.layout.margins.outer
for ((idx, note) in song.notes.withIndex()) {
// Estimate how many wrapped lines this note paragraph needs
val noteWidthMm = fontMetrics.measureTextWidth(note, config.fonts.metadata, config.fonts.metadata.size)
val estimatedLines = maxOf(1, kotlin.math.ceil((noteWidthMm / contentWidthMm).toDouble()).toInt())
heightMm += metaLineHeight * estimatedLines
// Paragraph spacing between note paragraphs
if (idx < song.notes.size - 1) {
heightMm += metaLineHeight * 0.3f
}
} }
} }

View File

@@ -324,40 +324,4 @@ class MeasurementEngineTest {
// Should be the same since no reference books are configured // Should be the same since no reference books are configured
heightWith shouldBe heightWithout heightWith shouldBe heightWithout
} }
@Test
fun `inline image adds significant height`() {
val songWithImage = Song(
title = "With Image",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(
SongLine(listOf(LineSegment(text = "Line before"))),
SongLine(imagePath = "images/test.png"),
SongLine(listOf(LineSegment(text = "Line after")))
)
)
)
)
val songWithoutImage = Song(
title = "No Image",
sections = listOf(
SongSection(
type = SectionType.VERSE,
lines = listOf(
SongLine(listOf(LineSegment(text = "Line before"))),
SongLine(listOf(LineSegment(text = "Line after")))
)
)
)
)
val heightWith = engine.measure(songWithImage).totalHeightMm
val heightWithout = engine.measure(songWithoutImage).totalHeightMm
// Inline image adds ~42mm (40mm image + 2mm gap)
val diff = heightWith - heightWithout
diff shouldBeGreaterThan 30f // should be substantial
}
} }

View File

@@ -7,17 +7,7 @@ data class BookConfig(
val layout: LayoutConfig = LayoutConfig(), val layout: LayoutConfig = LayoutConfig(),
val images: ImagesConfig = ImagesConfig(), val images: ImagesConfig = ImagesConfig(),
val referenceBooks: List<ReferenceBook> = emptyList(), val referenceBooks: List<ReferenceBook> = emptyList(),
val output: OutputConfig = OutputConfig(), val output: OutputConfig = OutputConfig()
val foreword: ForewordConfig? = null,
val toc: TocConfig = TocConfig()
)
data class TocConfig(
val highlightColumn: String? = null // abbreviation of the column to highlight (e.g. "CL")
)
data class ForewordConfig(
val file: String = "./foreword.txt"
) )
data class BookMeta( data class BookMeta(
@@ -51,9 +41,7 @@ data class LayoutConfig(
val margins: Margins = Margins(), val margins: Margins = Margins(),
val chordLineSpacing: Float = 3f, // mm val chordLineSpacing: Float = 3f, // mm
val verseSpacing: Float = 4f, // mm val verseSpacing: Float = 4f, // mm
val pageNumberPosition: String = "bottom-outer", val pageNumberPosition: String = "bottom-outer"
val metadataLabels: String = "abbreviated", // "abbreviated" (M:/T:) or "german" (Worte:/Weise:)
val metadataPosition: String = "top" // "top" (after title) or "bottom" (bottom of last page)
) )
data class Margins( data class Margins(

View File

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

View File

@@ -10,7 +10,6 @@ sealed class PageContent {
data class SongPage(val song: Song, val pageIndex: Int) : PageContent() // pageIndex 0 or 1 for 2-page songs 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 class FillerImage(val imagePath: String) : PageContent()
data object BlankPage : PageContent() data object BlankPage : PageContent()
data class ForewordPage(val foreword: Foreword, val pageIndex: Int) : PageContent() // pageIndex 0 or 1 for multi-page forewords
} }
data class LayoutResult( data class LayoutResult(

View File

@@ -23,10 +23,7 @@ enum class SectionType {
VERSE, CHORUS, BRIDGE, REPEAT VERSE, CHORUS, BRIDGE, REPEAT
} }
data class SongLine( data class SongLine(val segments: List<LineSegment>)
val segments: List<LineSegment> = emptyList(),
val imagePath: String? = null // when non-null, this "line" is an inline image (segments ignored)
)
data class LineSegment( data class LineSegment(
val chord: String? = null, // null = no chord above this segment val chord: String? = null, // null = no chord above this segment

View File

@@ -25,17 +25,6 @@ object ChordProParser {
var currentLabel: String? = null var currentLabel: String? = null
var currentLines = mutableListOf<SongLine>() var currentLines = mutableListOf<SongLine>()
// Notes block state
var inNotesBlock = false
var currentNoteParagraph = StringBuilder()
fun flushNoteParagraph() {
if (currentNoteParagraph.isNotEmpty()) {
notes.add(currentNoteParagraph.toString().trim())
currentNoteParagraph = StringBuilder()
}
}
fun flushSection() { fun flushSection() {
if (currentType != null) { if (currentType != null) {
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList())) sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
@@ -48,27 +37,6 @@ object ChordProParser {
for (rawLine in lines) { for (rawLine in lines) {
val line = rawLine.trimEnd() val line = rawLine.trimEnd()
// Inside a notes block: collect lines as paragraphs
if (inNotesBlock) {
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
val inner = line.trim().removePrefix("{").removeSuffix("}").trim().lowercase()
if (inner == "end_of_notes" || inner == "eon") {
flushNoteParagraph()
inNotesBlock = false
continue
}
}
if (line.isBlank()) {
flushNoteParagraph()
} else {
if (currentNoteParagraph.isNotEmpty()) {
currentNoteParagraph.append(" ")
}
currentNoteParagraph.append(line.trim())
}
continue
}
// Skip comments // Skip comments
if (line.trimStart().startsWith("#")) continue if (line.trimStart().startsWith("#")) continue
@@ -129,21 +97,6 @@ object ChordProParser {
"end_of_repeat", "eor" -> { "end_of_repeat", "eor" -> {
flushSection() flushSection()
} }
"image" -> if (value != null) {
// Inline image within a song section
if (currentType == null) {
currentType = SectionType.VERSE
}
currentLines.add(SongLine(imagePath = value.trim()))
}
"start_of_notes", "son" -> {
inNotesBlock = true
}
"end_of_notes", "eon" -> {
// Should have been handled in the notes block above
flushNoteParagraph()
inNotesBlock = false
}
"chorus" -> { "chorus" -> {
flushSection() flushSection()
sections.add(SongSection(type = SectionType.CHORUS)) sections.add(SongSection(type = SectionType.CHORUS))

View File

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

View File

@@ -1,9 +1,7 @@
package de.pfadfinder.songbook.parser package de.pfadfinder.songbook.parser
import de.pfadfinder.songbook.model.BookConfig import de.pfadfinder.songbook.model.BookConfig
import de.pfadfinder.songbook.model.FontSpec
import de.pfadfinder.songbook.model.Song import de.pfadfinder.songbook.model.Song
import java.io.File
data class ValidationError(val file: String?, val line: Int?, val message: String) data class ValidationError(val file: String?, val line: Int?, val message: String)
@@ -52,27 +50,6 @@ object Validator {
if (outer <= 0) errors.add(ValidationError(file = null, line = null, message = "Outer margin must be greater than 0")) if (outer <= 0) errors.add(ValidationError(file = null, line = null, message = "Outer margin must be greater than 0"))
} }
// Validate font files exist (paths should already be resolved to absolute by the pipeline)
validateFontFile(config.fonts.lyrics, "lyrics", errors)
validateFontFile(config.fonts.chords, "chords", errors)
validateFontFile(config.fonts.title, "title", errors)
validateFontFile(config.fonts.metadata, "metadata", errors)
validateFontFile(config.fonts.toc, "toc", errors)
return errors return errors
} }
private fun validateFontFile(font: FontSpec, fontRole: String, errors: MutableList<ValidationError>) {
val fontFile = font.file ?: return
val file = File(fontFile)
if (!file.exists()) {
errors.add(
ValidationError(
file = null,
line = null,
message = "Font file for '$fontRole' not found: $fontFile"
)
)
}
}
} }

View File

@@ -485,147 +485,4 @@ class ChordProParserTest {
line.segments[2].chord shouldBe "G" line.segments[2].chord shouldBe "G"
line.segments[2].text shouldBe "End" line.segments[2].text shouldBe "End"
} }
@Test
fun `parse notes block with multiple paragraphs`() {
val input = """
{title: Song}
{start_of_notes}
First paragraph of the notes.
It continues on the next line.
Second paragraph with different content.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 2
song.notes[0] shouldBe "First paragraph of the notes. It continues on the next line."
song.notes[1] shouldBe "Second paragraph with different content."
}
@Test
fun `parse notes block with single paragraph`() {
val input = """
{title: Song}
{start_of_notes}
A single note paragraph.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 1
song.notes[0] shouldBe "A single note paragraph."
}
@Test
fun `parse notes block with short directives son eon`() {
val input = """
{title: Song}
{son}
Short form notes.
{eon}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 1
song.notes[0] shouldBe "Short form notes."
}
@Test
fun `notes block and single note directives combine`() {
val input = """
{title: Song}
{note: Single line note}
{start_of_notes}
Block note paragraph.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 2
song.notes[0] shouldBe "Single line note"
song.notes[1] shouldBe "Block note paragraph."
}
@Test
fun `parse notes block with three paragraphs`() {
val input = """
{title: Song}
{start_of_notes}
Paragraph one.
Paragraph two.
Paragraph three.
{end_of_notes}
{start_of_verse}
text
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.notes shouldHaveSize 3
song.notes[0] shouldBe "Paragraph one."
song.notes[1] shouldBe "Paragraph two."
song.notes[2] shouldBe "Paragraph three."
}
@Test
fun `parse image directive within song section`() {
val input = """
{title: Song}
{start_of_verse}
[Am]Hello world
{image: images/drawing.png}
[C]Goodbye world
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].lines shouldHaveSize 3
song.sections[0].lines[0].segments[0].chord shouldBe "Am"
song.sections[0].lines[1].imagePath shouldBe "images/drawing.png"
song.sections[0].lines[1].segments.shouldBeEmpty()
song.sections[0].lines[2].segments[0].chord shouldBe "C"
}
@Test
fun `parse image directive outside section creates implicit verse`() {
val input = """
{title: Song}
{image: images/landscape.jpg}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections shouldHaveSize 1
song.sections[0].type shouldBe SectionType.VERSE
song.sections[0].lines shouldHaveSize 1
song.sections[0].lines[0].imagePath shouldBe "images/landscape.jpg"
}
@Test
fun `parse multiple image directives`() {
val input = """
{title: Song}
{start_of_verse}
{image: img1.png}
Some text
{image: img2.png}
{end_of_verse}
""".trimIndent()
val song = ChordProParser.parse(input)
song.sections[0].lines shouldHaveSize 3
song.sections[0].lines[0].imagePath shouldBe "img1.png"
song.sections[0].lines[0].segments.shouldBeEmpty()
song.sections[0].lines[1].imagePath.shouldBeNull()
song.sections[0].lines[1].segments[0].text shouldBe "Some text"
song.sections[0].lines[2].imagePath shouldBe "img2.png"
}
} }

View File

@@ -1,8 +1,6 @@
package de.pfadfinder.songbook.parser package de.pfadfinder.songbook.parser
import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlin.test.Test import kotlin.test.Test
@@ -169,76 +167,6 @@ class ConfigParserTest {
config.layout.verseSpacing shouldBe 6f config.layout.verseSpacing shouldBe 6f
} }
@Test
fun `parse config with foreword section`() {
val yaml = """
book:
title: "Test"
foreword:
file: "./vorwort.txt"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.foreword.shouldNotBeNull()
config.foreword!!.file shouldBe "./vorwort.txt"
}
@Test
fun `parse config without foreword section has null foreword`() {
val yaml = """
book:
title: "Test"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.foreword.shouldBeNull()
}
@Test
fun `parse config with toc highlight column`() {
val yaml = """
book:
title: "Test"
toc:
highlight_column: "CL"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.toc.highlightColumn shouldBe "CL"
}
@Test
fun `parse config without toc section uses defaults`() {
val yaml = """
book:
title: "Test"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.toc.highlightColumn.shouldBeNull()
}
@Test
fun `parse config with german metadata labels`() {
val yaml = """
book:
title: "Test"
layout:
metadata_labels: german
metadata_position: bottom
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.layout.metadataLabels shouldBe "german"
config.layout.metadataPosition shouldBe "bottom"
}
@Test
fun `parse config with default metadata settings`() {
val yaml = """
book:
title: "Test"
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.layout.metadataLabels shouldBe "abbreviated"
config.layout.metadataPosition shouldBe "top"
}
@Test @Test
fun `parse config ignores unknown properties`() { fun `parse config ignores unknown properties`() {
val yaml = """ val yaml = """
@@ -251,21 +179,4 @@ class ConfigParserTest {
val config = ConfigParser.parse(yaml) val config = ConfigParser.parse(yaml)
config.book.title shouldBe "Test" config.book.title shouldBe "Test"
} }
@Test
fun `parse config with custom title font file only`() {
val yaml = """
book:
title: "Fraktur Test"
fonts:
title: { file: "./fonts/Fraktur.ttf", size: 16 }
""".trimIndent()
val config = ConfigParser.parse(yaml)
config.fonts.title.file shouldBe "./fonts/Fraktur.ttf"
config.fonts.title.size shouldBe 16f
config.fonts.title.family shouldBe "Helvetica" // default family as fallback
// Other fonts should still use defaults
config.fonts.lyrics.file.shouldBeNull()
config.fonts.lyrics.family shouldBe "Helvetica"
}
} }

View File

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

View File

@@ -206,53 +206,4 @@ class ValidatorTest {
errors shouldHaveSize 1 errors shouldHaveSize 1
errors[0].file shouldContain "myfile.chopro" errors[0].file shouldContain "myfile.chopro"
} }
@Test
fun `missing font file produces validation error`() {
val config = BookConfig(
fonts = FontsConfig(
title = FontSpec(file = "/nonexistent/path/FrakturFont.ttf", size = 14f)
)
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 1
errors[0].message shouldContain "title"
errors[0].message shouldContain "not found"
}
@Test
fun `multiple missing font files produce multiple errors`() {
val config = BookConfig(
fonts = FontsConfig(
title = FontSpec(file = "/nonexistent/title.ttf", size = 14f),
lyrics = FontSpec(file = "/nonexistent/lyrics.ttf", size = 10f)
)
)
val errors = Validator.validateConfig(config)
errors shouldHaveSize 2
}
@Test
fun `config with no font files produces no font errors`() {
val config = BookConfig() // all default built-in fonts
val errors = Validator.validateConfig(config)
errors.shouldBeEmpty()
}
@Test
fun `config with existing font file produces no error`() {
// Create a temporary file to simulate an existing font file
val tempFile = kotlin.io.path.createTempFile(suffix = ".ttf").toFile()
try {
val config = BookConfig(
fonts = FontsConfig(
title = FontSpec(file = tempFile.absolutePath, size = 14f)
)
)
val errors = Validator.validateConfig(config)
errors.shouldBeEmpty()
} finally {
tempFile.delete()
}
}
} }

View File

@@ -74,14 +74,6 @@ class PdfBookRenderer : BookRenderer {
cb.beginText() cb.beginText()
cb.endText() cb.endText()
} }
is PageContent.ForewordPage -> {
val leftMargin = if (isRightPage) marginInner else marginOuter
renderForewordPage(
cb, fontMetrics, config,
pageContent.foreword, pageContent.pageIndex,
contentTop, leftMargin, contentWidth
)
}
} }
pageDecorator.addPageNumber(cb, currentPageNum, pageSize.width, pageSize.height) pageDecorator.addPageNumber(cb, currentPageNum, pageSize.width, pageSize.height)
@@ -104,8 +96,6 @@ class PdfBookRenderer : BookRenderer {
) { ) {
var y = contentTop var y = contentTop
val renderMetaAtBottom = config.layout.metadataPosition == "bottom"
if (pageIndex == 0) { if (pageIndex == 0) {
// Render title // Render title
val titleFont = fontMetrics.getBaseFont(config.fonts.title) val titleFont = fontMetrics.getBaseFont(config.fonts.title)
@@ -118,22 +108,20 @@ class PdfBookRenderer : BookRenderer {
cb.endText() cb.endText()
y -= titleSize * 1.5f y -= titleSize * 1.5f
// Render metadata line (composer/lyricist) - at top position only // Render metadata line (composer/lyricist)
if (!renderMetaAtBottom) { val metaParts = mutableListOf<String>()
val metaParts = buildMetadataLines(song, config) song.composer?.let { metaParts.add("M: $it") }
if (metaParts.isNotEmpty()) { song.lyricist?.let { metaParts.add("T: $it") }
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata) if (metaParts.isNotEmpty()) {
val metaSize = config.fonts.metadata.size val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
for (metaLine in metaParts) { val metaSize = config.fonts.metadata.size
cb.beginText() cb.beginText()
cb.setFontAndSize(metaFont, metaSize) cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY) cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, y - metaSize) cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(metaLine) cb.showText(metaParts.joinToString(" / "))
cb.endText() cb.endText()
y -= metaSize * 1.8f y -= metaSize * 1.8f
}
}
} }
// Render key and capo // Render key and capo
@@ -209,14 +197,8 @@ class PdfBookRenderer : BookRenderer {
// Render lines // Render lines
for (line in section.lines) { for (line in section.lines) {
val imgPath = line.imagePath val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth)
if (imgPath != null) { y -= height + 1f // 1pt gap between lines
// Render inline image
y -= renderInlineImage(cb, imgPath, leftMargin, y, contentWidth)
} else {
val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth)
y -= height + 1f // 1pt gap between lines
}
} }
// End repeat marker // End repeat marker
@@ -236,50 +218,19 @@ class PdfBookRenderer : BookRenderer {
y -= config.layout.verseSpacing / 0.3528f y -= config.layout.verseSpacing / 0.3528f
} }
// Render notes at the bottom (with word-wrap for multi-paragraph notes) // Render notes at the bottom
if (pageIndex == 0 && song.notes.isNotEmpty()) { if (pageIndex == 0 && song.notes.isNotEmpty()) {
y -= 4f y -= 4f
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata) val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size val metaSize = config.fonts.metadata.size
val noteLineHeight = metaSize * 1.5f for (note in song.notes) {
cb.beginText()
for ((idx, note) in song.notes.withIndex()) { cb.setFontAndSize(metaFont, metaSize)
val wrappedLines = wrapText(note, metaFont, metaSize, contentWidth) cb.setColorFill(Color.GRAY)
for (wrappedLine in wrappedLines) { cb.setTextMatrix(leftMargin, y - metaSize)
cb.beginText() cb.showText(note)
cb.setFontAndSize(metaFont, metaSize) cb.endText()
cb.setColorFill(Color.GRAY) y -= metaSize * 1.5f
cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(wrappedLine)
cb.endText()
y -= noteLineHeight
}
// Add paragraph spacing between note paragraphs
if (idx < song.notes.size - 1) {
y -= noteLineHeight * 0.3f
}
}
}
// Render metadata at bottom of song page (if configured)
if (renderMetaAtBottom && pageIndex == 0) {
val metaParts = buildMetadataLines(song, config)
if (metaParts.isNotEmpty()) {
y -= 4f
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
for (metaLine in metaParts) {
val wrappedLines = wrapText(metaLine, metaFont, metaSize, contentWidth)
for (wrappedLine in wrappedLines) {
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.GRAY)
cb.setTextMatrix(leftMargin, y - metaSize)
cb.showText(wrappedLine)
cb.endText()
y -= metaSize * 1.5f
}
}
} }
} }
@@ -296,35 +247,6 @@ class PdfBookRenderer : BookRenderer {
} }
} }
/**
* Build metadata lines based on configured label style.
* Returns a list of lines to render (may be empty).
*/
private fun buildMetadataLines(song: Song, config: BookConfig): List<String> {
val useGerman = config.layout.metadataLabels == "german"
val lines = mutableListOf<String>()
if (useGerman) {
// German labels: "Worte und Weise:" when same person, otherwise separate
if (song.lyricist != null && song.composer != null && song.lyricist == song.composer) {
lines.add("Worte und Weise: ${song.lyricist}")
} else {
song.lyricist?.let { lines.add("Worte: $it") }
song.composer?.let { lines.add("Weise: $it") }
}
} else {
// Abbreviated labels on a single line
val parts = mutableListOf<String>()
song.composer?.let { parts.add("M: $it") }
song.lyricist?.let { parts.add("T: $it") }
if (parts.isNotEmpty()) {
lines.add(parts.joinToString(" / "))
}
}
return lines
}
/** /**
* Renders reference book abbreviations and page numbers as a footer row * Renders reference book abbreviations and page numbers as a footer row
* at the bottom of the song page, above the page number. * at the bottom of the song page, above the page number.
@@ -394,150 +316,6 @@ class PdfBookRenderer : BookRenderer {
cb.stroke() cb.stroke()
} }
private fun renderForewordPage(
cb: PdfContentByte,
fontMetrics: PdfFontMetrics,
config: BookConfig,
foreword: Foreword,
pageIndex: Int,
contentTop: Float,
leftMargin: Float,
contentWidth: Float
) {
var y = contentTop
val bodyFontSpec = config.fonts.lyrics
val bodyFont = fontMetrics.getBaseFont(bodyFontSpec)
val bodySize = bodyFontSpec.size
val lineHeight = bodySize * 1.5f
if (pageIndex == 0) {
// Page 1: Quote + separator + first paragraphs
// Render quote in italic (bold)
val quoteText = foreword.quote
if (quoteText != null) {
val quoteFont = fontMetrics.getBaseFontBold(bodyFontSpec)
val quoteSize = bodySize * 1.1f
// Word-wrap the quote text
val quoteLines = wrapText(quoteText, quoteFont, quoteSize, contentWidth)
for (quoteLine in quoteLines) {
cb.beginText()
cb.setFontAndSize(quoteFont, quoteSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(leftMargin, y - quoteSize)
cb.showText(quoteLine)
cb.endText()
y -= quoteSize * 1.5f
}
y -= 6f // gap before separator
// Horizontal rule
cb.setLineWidth(0.5f)
cb.setColorStroke(Color.GRAY)
cb.moveTo(leftMargin, y)
cb.lineTo(leftMargin + contentWidth, y)
cb.stroke()
y -= 10f // gap after separator
}
// Render body paragraphs
for (paragraph in foreword.paragraphs) {
val wrappedLines = wrapText(paragraph, bodyFont, bodySize, contentWidth)
for (wrappedLine in wrappedLines) {
cb.beginText()
cb.setFontAndSize(bodyFont, bodySize)
cb.setColorFill(Color.BLACK)
cb.setTextMatrix(leftMargin, y - bodySize)
cb.showText(wrappedLine)
cb.endText()
y -= lineHeight
}
y -= lineHeight * 0.5f // paragraph spacing
}
// Render signatures (right-aligned)
if (foreword.signatures.isNotEmpty()) {
y -= lineHeight // extra gap before signatures
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
val metaSize = config.fonts.metadata.size
for (signature in foreword.signatures) {
val sigWidth = metaFont.getWidthPoint(signature, metaSize)
cb.beginText()
cb.setFontAndSize(metaFont, metaSize)
cb.setColorFill(Color.DARK_GRAY)
cb.setTextMatrix(leftMargin + contentWidth - sigWidth, y - metaSize)
cb.showText(signature)
cb.endText()
y -= metaSize * 1.8f
}
}
} else {
// Page 2: blank (or overflow content if needed in the future)
cb.beginText()
cb.endText()
}
}
/**
* Simple word-wrap: splits text into lines that fit within maxWidth (in points).
*/
private fun wrapText(text: String, font: BaseFont, fontSize: Float, maxWidth: Float): List<String> {
val words = text.split(" ")
val lines = mutableListOf<String>()
var currentLine = StringBuilder()
for (word in words) {
val testLine = if (currentLine.isEmpty()) word else "$currentLine $word"
val testWidth = font.getWidthPoint(testLine, fontSize)
if (testWidth <= maxWidth) {
currentLine = StringBuilder(testLine)
} else {
if (currentLine.isNotEmpty()) {
lines.add(currentLine.toString())
}
currentLine = StringBuilder(word)
}
}
if (currentLine.isNotEmpty()) {
lines.add(currentLine.toString())
}
return lines
}
/**
* Renders an inline image within a song page at the given position.
* Returns the total height consumed in PDF points.
*/
private fun renderInlineImage(
cb: PdfContentByte,
imagePath: String,
leftMargin: Float,
y: Float,
contentWidth: Float
): Float {
try {
val img = Image.getInstance(imagePath)
// Scale to fit within content width, max height 40mm (~113 points)
val maxHeight = 40f / 0.3528f // 40mm in points
img.scaleToFit(contentWidth * 0.8f, maxHeight)
// Center horizontally
val imgX = leftMargin + (contentWidth - img.scaledWidth) / 2
val imgY = y - img.scaledHeight - 3f // 3pt gap above
img.setAbsolutePosition(imgX, imgY)
cb.addImage(img)
return img.scaledHeight + 6f // image height + gaps above/below
} catch (_: Exception) {
// If image can't be loaded, consume minimal space
return 5f
}
}
private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) { private fun renderFillerImage(document: Document, imagePath: String, pageSize: Rectangle) {
try { try {
val img = Image.getInstance(imagePath) val img = Image.getInstance(imagePath)

View File

@@ -3,21 +3,15 @@ package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.pdf.BaseFont import com.lowagie.text.pdf.BaseFont
import de.pfadfinder.songbook.model.FontMetrics import de.pfadfinder.songbook.model.FontMetrics
import de.pfadfinder.songbook.model.FontSpec import de.pfadfinder.songbook.model.FontSpec
import java.io.File
class PdfFontMetrics : FontMetrics { class PdfFontMetrics : FontMetrics {
private val fontCache = mutableMapOf<String, BaseFont>() private val fontCache = mutableMapOf<String, BaseFont>()
fun getBaseFont(font: FontSpec): BaseFont { fun getBaseFont(font: FontSpec): BaseFont {
val fontFile = font.file val key = font.file ?: font.family
val key = if (fontFile != null) File(fontFile).canonicalPath else font.family
return fontCache.getOrPut(key) { return fontCache.getOrPut(key) {
if (fontFile != null) { if (font.file != null) {
val file = File(fontFile) BaseFont.createFont(font.file, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
if (!file.exists()) {
throw IllegalArgumentException("Font file not found: ${file.absolutePath}")
}
BaseFont.createFont(file.absolutePath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
} else { } else {
// Map common family names to built-in PDF fonts // Map common family names to built-in PDF fonts
val pdfFontName = when (font.family.lowercase()) { val pdfFontName = when (font.family.lowercase()) {

View File

@@ -3,15 +3,11 @@ package de.pfadfinder.songbook.renderer.pdf
import com.lowagie.text.* import com.lowagie.text.*
import com.lowagie.text.pdf.* import com.lowagie.text.pdf.*
import de.pfadfinder.songbook.model.* import de.pfadfinder.songbook.model.*
import java.awt.Color
class TocRenderer( class TocRenderer(
private val fontMetrics: PdfFontMetrics, private val fontMetrics: PdfFontMetrics,
private val config: BookConfig private val config: BookConfig
) { ) {
// Light gray background for the highlighted column
private val highlightColor = Color(220, 220, 220)
fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) { fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) {
val tocFont = fontMetrics.getBaseFont(config.fonts.toc) val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc) val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
@@ -39,25 +35,12 @@ class TocRenderer(
} }
table.setWidths(widths) table.setWidths(widths)
// Determine which column index should be highlighted
val highlightAbbrev = config.toc.highlightColumn
val highlightColumnIndex: Int? = if (highlightAbbrev != null) {
// Check "Seite" (page) column first - the current book's page number column
if (highlightAbbrev == "Seite") {
1
} else {
val refIndex = refBooks.indexOfFirst { it.abbreviation == highlightAbbrev }
if (refIndex >= 0) 2 + refIndex else null
}
} else null
// Header row // Header row
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD) val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
table.addCell(headerCell("Titel", headerFont, isHighlighted = false)) table.addCell(headerCell("Titel", headerFont))
table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1)) table.addCell(headerCell("Seite", headerFont))
for ((i, book) in refBooks.withIndex()) { for (book in refBooks) {
val isHighlighted = highlightColumnIndex == 2 + i table.addCell(headerCell(book.abbreviation, headerFont))
table.addCell(headerCell(book.abbreviation, headerFont, isHighlighted = isHighlighted))
} }
table.headerRows = 1 table.headerRows = 1
@@ -66,43 +49,31 @@ class TocRenderer(
val aliasFont = Font(tocFont, fontSize, Font.ITALIC) val aliasFont = Font(tocFont, fontSize, Font.ITALIC)
for (entry in tocEntries.sortedBy { it.title.lowercase() }) { for (entry in tocEntries.sortedBy { it.title.lowercase() }) {
val font = if (entry.isAlias) aliasFont else entryFont val font = if (entry.isAlias) aliasFont else entryFont
table.addCell(entryCell(entry.title, font, isHighlighted = false)) table.addCell(entryCell(entry.title, font))
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT, isHighlighted = highlightColumnIndex == 1)) table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT))
for ((i, book) in refBooks.withIndex()) { for (book in refBooks) {
val ref = entry.references[book.abbreviation] val ref = entry.references[book.abbreviation]
val isHighlighted = highlightColumnIndex == 2 + i table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT))
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT, isHighlighted = isHighlighted))
} }
} }
document.add(table) document.add(table)
} }
private fun headerCell(text: String, font: Font, isHighlighted: Boolean): PdfPCell { private fun headerCell(text: String, font: Font): PdfPCell {
val cell = PdfPCell(Phrase(text, font)) val cell = PdfPCell(Phrase(text, font))
cell.borderWidth = 0f cell.borderWidth = 0f
cell.borderWidthBottom = 0.5f cell.borderWidthBottom = 0.5f
cell.paddingBottom = 4f cell.paddingBottom = 4f
if (isHighlighted) {
cell.backgroundColor = highlightColor
}
return cell return cell
} }
private fun entryCell( private fun entryCell(text: String, font: Font, alignment: Int = Element.ALIGN_LEFT): PdfPCell {
text: String,
font: Font,
alignment: Int = Element.ALIGN_LEFT,
isHighlighted: Boolean = false
): PdfPCell {
val cell = PdfPCell(Phrase(text, font)) val cell = PdfPCell(Phrase(text, font))
cell.borderWidth = 0f cell.borderWidth = 0f
cell.horizontalAlignment = alignment cell.horizontalAlignment = alignment
cell.paddingTop = 1f cell.paddingTop = 1f
cell.paddingBottom = 1f cell.paddingBottom = 1f
if (isHighlighted) {
cell.backgroundColor = highlightColor
}
return cell return cell
} }
} }

View File

@@ -6,7 +6,6 @@ import io.kotest.matchers.floats.shouldBeLessThan
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeSameInstanceAs import io.kotest.matchers.types.shouldBeSameInstanceAs
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertFailsWith
class PdfFontMetricsTest { class PdfFontMetricsTest {
@@ -159,73 +158,4 @@ class PdfFontMetricsTest {
height shouldBeGreaterThan 3f height shouldBeGreaterThan 3f
height shouldBeLessThan 6f height shouldBeLessThan 6f
} }
// --- Custom font file tests ---
private val testFontPath: String
get() = this::class.java.getResource("/TestFont.ttf")!!.file
@Test
fun `getBaseFont loads custom font from file path`() {
val font = FontSpec(file = testFontPath, size = 12f)
val baseFont = metrics.getBaseFont(font)
// Custom font should load successfully and have a non-null PostScript name
baseFont.postscriptFontName.isNotEmpty() shouldBe true
}
@Test
fun `getBaseFont caches custom font by canonical path`() {
val font1 = FontSpec(file = testFontPath, size = 12f)
val font2 = FontSpec(file = testFontPath, size = 14f) // different size, same file
val first = metrics.getBaseFont(font1)
val second = metrics.getBaseFont(font2)
first shouldBeSameInstanceAs second
}
@Test
fun `getBaseFont throws for missing font file`() {
val font = FontSpec(file = "/nonexistent/path/MissingFont.ttf", size = 12f)
assertFailsWith<IllegalArgumentException> {
metrics.getBaseFont(font)
}
}
@Test
fun `getBaseFontBold returns same font when file is specified`() {
val font = FontSpec(file = testFontPath, size = 12f)
val regular = metrics.getBaseFont(font)
val bold = metrics.getBaseFontBold(font)
// Custom fonts don't have auto-resolved bold variants
regular shouldBeSameInstanceAs bold
}
@Test
fun `measureTextWidth works with custom font file`() {
val font = FontSpec(file = testFontPath, size = 12f)
val width = metrics.measureTextWidth("Hello World", font, 12f)
width shouldBeGreaterThan 0f
}
@Test
fun `measureTextWidth handles German umlauts with custom font`() {
val font = FontSpec(file = testFontPath, size = 12f)
// These should not throw and should return positive widths
val umlautWidth = metrics.measureTextWidth("\u00e4\u00f6\u00fc\u00df", font, 12f)
umlautWidth shouldBeGreaterThan 0f
// Full German words with umlauts
val wordWidth = metrics.measureTextWidth("Gr\u00fc\u00dfe aus \u00d6sterreich", font, 12f)
wordWidth shouldBeGreaterThan 0f
}
@Test
fun `measureTextWidth with custom font returns different width than built-in font`() {
val customFont = FontSpec(file = testFontPath, size = 10f)
val builtInFont = FontSpec(family = "Courier", size = 10f) // use Courier for contrast
val customWidth = metrics.measureTextWidth("Test text", customFont, 10f)
val builtInWidth = metrics.measureTextWidth("Test text", builtInFont, 10f)
// They should both be positive but likely different
customWidth shouldBeGreaterThan 0f
builtInWidth shouldBeGreaterThan 0f
}
} }

View File

@@ -14,11 +14,6 @@ fonts:
title: { family: "Helvetica", size: 14 } title: { family: "Helvetica", size: 14 }
metadata: { family: "Helvetica", size: 8 } metadata: { family: "Helvetica", size: 8 }
toc: { family: "Helvetica", size: 9 } toc: { family: "Helvetica", size: 9 }
# To use a custom font file (e.g. Fraktur/Blackletter for titles):
# title: { file: "./fonts/FrakturFont.ttf", size: 16 }
# The file path is relative to the project directory.
# Supported formats: .ttf, .otf
# Custom fonts are embedded in the PDF and support Unicode (including umlauts).
layout: layout:
margins: { top: 15, bottom: 15, inner: 20, outer: 12 } margins: { top: 15, bottom: 15, inner: 20, outer: 12 }