Compare commits
6 Commits
ba035159f7
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5378bdbc24 | ||
|
|
ab91ad2db6 | ||
|
|
b339c10ca0 | ||
|
|
8dca7d7131 | ||
|
|
8c92c7d78b | ||
|
|
0139327034 |
46
CLAUDE.md
46
CLAUDE.md
@@ -35,16 +35,17 @@ Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Pipeline:** Parse → Measure → Paginate → Render
|
||||
**Pipeline:** Parse → Validate → 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
|
||||
2. `ChordProParser` reads `.chopro`/`.cho`/`.crd` files → `Song` objects
|
||||
3. `ForewordParser` reads optional `foreword.txt` → `Foreword` (if configured)
|
||||
4. `Validator` checks config and songs
|
||||
5. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
|
||||
6. `TocGenerator` estimates TOC page count and creates entries
|
||||
7. `PaginationEngine` arranges songs into pages (greedy spread packing)
|
||||
8. `PdfBookRenderer` generates the PDF via OpenPDF
|
||||
|
||||
**Module dependency graph:**
|
||||
```
|
||||
@@ -62,14 +63,39 @@ app, parser ← gui (Compose Desktop)
|
||||
|
||||
## Key Types
|
||||
|
||||
- `Song` → sections → `SongLine` → `LineSegment(chord?, text)` — chord is placed above the text segment
|
||||
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`
|
||||
- `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`
|
||||
- `SongLine` — holds `segments` plus optional `imagePath` (when set, the line is an inline image)
|
||||
- `Foreword` — `quote`, `paragraphs`, `signatures` — parsed from a plain-text file
|
||||
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`, `ForewordPage`
|
||||
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
|
||||
- `BookConfig` — top-level config with `FontsConfig`, `LayoutConfig`, `TocConfig`, `ForewordConfig`, `ReferenceBook` list. `FontSpec.file` supports custom font files. `LayoutConfig.metadataLabels` (`"abbreviated"` or `"german"`) and `metadataPosition` (`"top"` or `"bottom"`) control metadata rendering
|
||||
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
|
||||
|
||||
## Song Format
|
||||
|
||||
ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
|
||||
ChordPro-compatible `.chopro`/`.cho`/`.crd` 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
|
||||
|
||||
|
||||
@@ -28,9 +28,12 @@ class SongbookPipeline(private val projectDir: File) {
|
||||
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
||||
}
|
||||
logger.info { "Parsing config: ${configFile.absolutePath}" }
|
||||
val config = ConfigParser.parse(configFile)
|
||||
val rawConfig = ConfigParser.parse(configFile)
|
||||
|
||||
// Validate config
|
||||
// Resolve font file paths relative to the project directory
|
||||
val config = resolveFontPaths(rawConfig)
|
||||
|
||||
// Validate config (including font file existence)
|
||||
val configErrors = Validator.validateConfig(config)
|
||||
if (configErrors.isNotEmpty()) {
|
||||
return BuildResult(false, errors = configErrors)
|
||||
@@ -149,13 +152,39 @@ 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> {
|
||||
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 rawConfig = ConfigParser.parse(configFile)
|
||||
val config = resolveFontPaths(rawConfig)
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
errors.addAll(Validator.validateConfig(config))
|
||||
|
||||
|
||||
@@ -15,9 +15,16 @@ class MeasurementEngine(
|
||||
// Title height
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.title, config.fonts.title.size) * 1.5f
|
||||
|
||||
// Metadata line (composer/lyricist)
|
||||
// Metadata lines (composer/lyricist) - may be 1 or 2 lines depending on label style
|
||||
if (song.composer != null || song.lyricist != null) {
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
|
||||
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.8f
|
||||
val useGerman = config.layout.metadataLabels == "german"
|
||||
if (useGerman && song.lyricist != null && song.composer != null && song.lyricist != song.composer) {
|
||||
// Two separate lines: "Worte: ..." and "Weise: ..."
|
||||
heightMm += metaLineHeight * 2
|
||||
} else {
|
||||
heightMm += metaLineHeight
|
||||
}
|
||||
}
|
||||
|
||||
// Key/capo line
|
||||
@@ -43,6 +50,11 @@ class MeasurementEngine(
|
||||
|
||||
// Lines in section
|
||||
for (line in section.lines) {
|
||||
if (line.imagePath != null) {
|
||||
// Inline image: estimate height as 40mm (default image block height)
|
||||
heightMm += 40f
|
||||
heightMm += 2f // gap around image
|
||||
} else {
|
||||
val hasChords = line.segments.any { it.chord != null }
|
||||
val lyricHeight = fontMetrics.measureLineHeight(config.fonts.lyrics, config.fonts.lyrics.size)
|
||||
if (hasChords) {
|
||||
@@ -53,16 +65,29 @@ class MeasurementEngine(
|
||||
}
|
||||
heightMm += 0.35f // ~1pt gap between lines
|
||||
}
|
||||
}
|
||||
|
||||
// Verse spacing
|
||||
heightMm += config.layout.verseSpacing
|
||||
}
|
||||
|
||||
// Notes at bottom
|
||||
// Notes at bottom (with word-wrap estimation for multi-paragraph notes)
|
||||
if (song.notes.isNotEmpty()) {
|
||||
heightMm += 1.5f // gap
|
||||
for (note in song.notes) {
|
||||
heightMm += fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
|
||||
heightMm += 1.5f // gap before notes
|
||||
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
|
||||
// A5 content width in mm = 148 - inner margin - outer margin
|
||||
val contentWidthMm = 148f - config.layout.margins.inner - config.layout.margins.outer
|
||||
|
||||
for ((idx, note) in song.notes.withIndex()) {
|
||||
// Estimate how many wrapped lines this note paragraph needs
|
||||
val noteWidthMm = fontMetrics.measureTextWidth(note, config.fonts.metadata, config.fonts.metadata.size)
|
||||
val estimatedLines = maxOf(1, kotlin.math.ceil((noteWidthMm / contentWidthMm).toDouble()).toInt())
|
||||
heightMm += metaLineHeight * estimatedLines
|
||||
|
||||
// Paragraph spacing between note paragraphs
|
||||
if (idx < song.notes.size - 1) {
|
||||
heightMm += metaLineHeight * 0.3f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -324,4 +324,40 @@ class MeasurementEngineTest {
|
||||
// Should be the same since no reference books are configured
|
||||
heightWith shouldBe heightWithout
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inline image adds significant height`() {
|
||||
val songWithImage = Song(
|
||||
title = "With Image",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(
|
||||
SongLine(listOf(LineSegment(text = "Line before"))),
|
||||
SongLine(imagePath = "images/test.png"),
|
||||
SongLine(listOf(LineSegment(text = "Line after")))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val songWithoutImage = Song(
|
||||
title = "No Image",
|
||||
sections = listOf(
|
||||
SongSection(
|
||||
type = SectionType.VERSE,
|
||||
lines = listOf(
|
||||
SongLine(listOf(LineSegment(text = "Line before"))),
|
||||
SongLine(listOf(LineSegment(text = "Line after")))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val heightWith = engine.measure(songWithImage).totalHeightMm
|
||||
val heightWithout = engine.measure(songWithoutImage).totalHeightMm
|
||||
|
||||
// Inline image adds ~42mm (40mm image + 2mm gap)
|
||||
val diff = heightWith - heightWithout
|
||||
diff shouldBeGreaterThan 30f // should be substantial
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,12 @@ data class BookConfig(
|
||||
val images: ImagesConfig = ImagesConfig(),
|
||||
val referenceBooks: List<ReferenceBook> = emptyList(),
|
||||
val output: OutputConfig = OutputConfig(),
|
||||
val foreword: ForewordConfig? = null
|
||||
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(
|
||||
@@ -46,7 +51,9 @@ data class LayoutConfig(
|
||||
val margins: Margins = Margins(),
|
||||
val chordLineSpacing: Float = 3f, // 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(
|
||||
|
||||
@@ -23,7 +23,10 @@ enum class SectionType {
|
||||
VERSE, CHORUS, BRIDGE, REPEAT
|
||||
}
|
||||
|
||||
data class SongLine(val segments: List<LineSegment>)
|
||||
data class SongLine(
|
||||
val segments: List<LineSegment> = emptyList(),
|
||||
val imagePath: String? = null // when non-null, this "line" is an inline image (segments ignored)
|
||||
)
|
||||
|
||||
data class LineSegment(
|
||||
val chord: String? = null, // null = no chord above this segment
|
||||
|
||||
@@ -25,6 +25,17 @@ object ChordProParser {
|
||||
var currentLabel: String? = null
|
||||
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() {
|
||||
if (currentType != null) {
|
||||
sections.add(SongSection(type = currentType!!, label = currentLabel, lines = currentLines.toList()))
|
||||
@@ -37,6 +48,27 @@ object ChordProParser {
|
||||
for (rawLine in lines) {
|
||||
val line = rawLine.trimEnd()
|
||||
|
||||
// Inside a notes block: collect lines as paragraphs
|
||||
if (inNotesBlock) {
|
||||
if (line.trimStart().startsWith("{") && line.trimEnd().endsWith("}")) {
|
||||
val inner = line.trim().removePrefix("{").removeSuffix("}").trim().lowercase()
|
||||
if (inner == "end_of_notes" || inner == "eon") {
|
||||
flushNoteParagraph()
|
||||
inNotesBlock = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (line.isBlank()) {
|
||||
flushNoteParagraph()
|
||||
} else {
|
||||
if (currentNoteParagraph.isNotEmpty()) {
|
||||
currentNoteParagraph.append(" ")
|
||||
}
|
||||
currentNoteParagraph.append(line.trim())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip comments
|
||||
if (line.trimStart().startsWith("#")) continue
|
||||
|
||||
@@ -97,6 +129,21 @@ object ChordProParser {
|
||||
"end_of_repeat", "eor" -> {
|
||||
flushSection()
|
||||
}
|
||||
"image" -> if (value != null) {
|
||||
// Inline image within a song section
|
||||
if (currentType == null) {
|
||||
currentType = SectionType.VERSE
|
||||
}
|
||||
currentLines.add(SongLine(imagePath = value.trim()))
|
||||
}
|
||||
"start_of_notes", "son" -> {
|
||||
inNotesBlock = true
|
||||
}
|
||||
"end_of_notes", "eon" -> {
|
||||
// Should have been handled in the notes block above
|
||||
flushNoteParagraph()
|
||||
inNotesBlock = false
|
||||
}
|
||||
"chorus" -> {
|
||||
flushSection()
|
||||
sections.add(SongSection(type = SectionType.CHORUS))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.pfadfinder.songbook.parser
|
||||
|
||||
import de.pfadfinder.songbook.model.BookConfig
|
||||
import de.pfadfinder.songbook.model.FontSpec
|
||||
import de.pfadfinder.songbook.model.Song
|
||||
import java.io.File
|
||||
|
||||
data class ValidationError(val file: String?, val line: Int?, val message: String)
|
||||
|
||||
@@ -50,6 +52,27 @@ object Validator {
|
||||
if (outer <= 0) errors.add(ValidationError(file = null, line = null, message = "Outer margin must be greater than 0"))
|
||||
}
|
||||
|
||||
// Validate font files exist (paths should already be resolved to absolute by the pipeline)
|
||||
validateFontFile(config.fonts.lyrics, "lyrics", errors)
|
||||
validateFontFile(config.fonts.chords, "chords", errors)
|
||||
validateFontFile(config.fonts.title, "title", errors)
|
||||
validateFontFile(config.fonts.metadata, "metadata", errors)
|
||||
validateFontFile(config.fonts.toc, "toc", errors)
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
private fun validateFontFile(font: FontSpec, fontRole: String, errors: MutableList<ValidationError>) {
|
||||
val fontFile = font.file ?: return
|
||||
val file = File(fontFile)
|
||||
if (!file.exists()) {
|
||||
errors.add(
|
||||
ValidationError(
|
||||
file = null,
|
||||
line = null,
|
||||
message = "Font file for '$fontRole' not found: $fontFile"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,4 +485,147 @@ class ChordProParserTest {
|
||||
line.segments[2].chord shouldBe "G"
|
||||
line.segments[2].text shouldBe "End"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse notes block with multiple paragraphs`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_notes}
|
||||
First paragraph of the notes.
|
||||
It continues on the next line.
|
||||
|
||||
Second paragraph with different content.
|
||||
{end_of_notes}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.notes shouldHaveSize 2
|
||||
song.notes[0] shouldBe "First paragraph of the notes. It continues on the next line."
|
||||
song.notes[1] shouldBe "Second paragraph with different content."
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse notes block with single paragraph`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_notes}
|
||||
A single note paragraph.
|
||||
{end_of_notes}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.notes shouldHaveSize 1
|
||||
song.notes[0] shouldBe "A single note paragraph."
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse notes block with short directives son eon`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{son}
|
||||
Short form notes.
|
||||
{eon}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.notes shouldHaveSize 1
|
||||
song.notes[0] shouldBe "Short form notes."
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notes block and single note directives combine`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{note: Single line note}
|
||||
{start_of_notes}
|
||||
Block note paragraph.
|
||||
{end_of_notes}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.notes shouldHaveSize 2
|
||||
song.notes[0] shouldBe "Single line note"
|
||||
song.notes[1] shouldBe "Block note paragraph."
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse notes block with three paragraphs`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_notes}
|
||||
Paragraph one.
|
||||
|
||||
Paragraph two.
|
||||
|
||||
Paragraph three.
|
||||
{end_of_notes}
|
||||
{start_of_verse}
|
||||
text
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.notes shouldHaveSize 3
|
||||
song.notes[0] shouldBe "Paragraph one."
|
||||
song.notes[1] shouldBe "Paragraph two."
|
||||
song.notes[2] shouldBe "Paragraph three."
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse image directive within song section`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_verse}
|
||||
[Am]Hello world
|
||||
{image: images/drawing.png}
|
||||
[C]Goodbye world
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].lines shouldHaveSize 3
|
||||
song.sections[0].lines[0].segments[0].chord shouldBe "Am"
|
||||
song.sections[0].lines[1].imagePath shouldBe "images/drawing.png"
|
||||
song.sections[0].lines[1].segments.shouldBeEmpty()
|
||||
song.sections[0].lines[2].segments[0].chord shouldBe "C"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse image directive outside section creates implicit verse`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{image: images/landscape.jpg}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections shouldHaveSize 1
|
||||
song.sections[0].type shouldBe SectionType.VERSE
|
||||
song.sections[0].lines shouldHaveSize 1
|
||||
song.sections[0].lines[0].imagePath shouldBe "images/landscape.jpg"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse multiple image directives`() {
|
||||
val input = """
|
||||
{title: Song}
|
||||
{start_of_verse}
|
||||
{image: img1.png}
|
||||
Some text
|
||||
{image: img2.png}
|
||||
{end_of_verse}
|
||||
""".trimIndent()
|
||||
val song = ChordProParser.parse(input)
|
||||
song.sections[0].lines shouldHaveSize 3
|
||||
song.sections[0].lines[0].imagePath shouldBe "img1.png"
|
||||
song.sections[0].lines[0].segments.shouldBeEmpty()
|
||||
song.sections[0].lines[1].imagePath.shouldBeNull()
|
||||
song.sections[0].lines[1].segments[0].text shouldBe "Some text"
|
||||
song.sections[0].lines[2].imagePath shouldBe "img2.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,53 @@ class ConfigParserTest {
|
||||
config.foreword.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse config with toc highlight column`() {
|
||||
val yaml = """
|
||||
book:
|
||||
title: "Test"
|
||||
toc:
|
||||
highlight_column: "CL"
|
||||
""".trimIndent()
|
||||
val config = ConfigParser.parse(yaml)
|
||||
config.toc.highlightColumn shouldBe "CL"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse config without toc section uses defaults`() {
|
||||
val yaml = """
|
||||
book:
|
||||
title: "Test"
|
||||
""".trimIndent()
|
||||
val config = ConfigParser.parse(yaml)
|
||||
config.toc.highlightColumn.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse config with german metadata labels`() {
|
||||
val yaml = """
|
||||
book:
|
||||
title: "Test"
|
||||
layout:
|
||||
metadata_labels: german
|
||||
metadata_position: bottom
|
||||
""".trimIndent()
|
||||
val config = ConfigParser.parse(yaml)
|
||||
config.layout.metadataLabels shouldBe "german"
|
||||
config.layout.metadataPosition shouldBe "bottom"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse config with default metadata settings`() {
|
||||
val yaml = """
|
||||
book:
|
||||
title: "Test"
|
||||
""".trimIndent()
|
||||
val config = ConfigParser.parse(yaml)
|
||||
config.layout.metadataLabels shouldBe "abbreviated"
|
||||
config.layout.metadataPosition shouldBe "top"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse config ignores unknown properties`() {
|
||||
val yaml = """
|
||||
@@ -204,4 +251,21 @@ class ConfigParserTest {
|
||||
val config = ConfigParser.parse(yaml)
|
||||
config.book.title shouldBe "Test"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse config with custom title font file only`() {
|
||||
val yaml = """
|
||||
book:
|
||||
title: "Fraktur Test"
|
||||
fonts:
|
||||
title: { file: "./fonts/Fraktur.ttf", size: 16 }
|
||||
""".trimIndent()
|
||||
val config = ConfigParser.parse(yaml)
|
||||
config.fonts.title.file shouldBe "./fonts/Fraktur.ttf"
|
||||
config.fonts.title.size shouldBe 16f
|
||||
config.fonts.title.family shouldBe "Helvetica" // default family as fallback
|
||||
// Other fonts should still use defaults
|
||||
config.fonts.lyrics.file.shouldBeNull()
|
||||
config.fonts.lyrics.family shouldBe "Helvetica"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,4 +206,53 @@ class ValidatorTest {
|
||||
errors shouldHaveSize 1
|
||||
errors[0].file shouldContain "myfile.chopro"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing font file produces validation error`() {
|
||||
val config = BookConfig(
|
||||
fonts = FontsConfig(
|
||||
title = FontSpec(file = "/nonexistent/path/FrakturFont.ttf", size = 14f)
|
||||
)
|
||||
)
|
||||
val errors = Validator.validateConfig(config)
|
||||
errors shouldHaveSize 1
|
||||
errors[0].message shouldContain "title"
|
||||
errors[0].message shouldContain "not found"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple missing font files produce multiple errors`() {
|
||||
val config = BookConfig(
|
||||
fonts = FontsConfig(
|
||||
title = FontSpec(file = "/nonexistent/title.ttf", size = 14f),
|
||||
lyrics = FontSpec(file = "/nonexistent/lyrics.ttf", size = 10f)
|
||||
)
|
||||
)
|
||||
val errors = Validator.validateConfig(config)
|
||||
errors shouldHaveSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `config with no font files produces no font errors`() {
|
||||
val config = BookConfig() // all default built-in fonts
|
||||
val errors = Validator.validateConfig(config)
|
||||
errors.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `config with existing font file produces no error`() {
|
||||
// Create a temporary file to simulate an existing font file
|
||||
val tempFile = kotlin.io.path.createTempFile(suffix = ".ttf").toFile()
|
||||
try {
|
||||
val config = BookConfig(
|
||||
fonts = FontsConfig(
|
||||
title = FontSpec(file = tempFile.absolutePath, size = 14f)
|
||||
)
|
||||
)
|
||||
val errors = Validator.validateConfig(config)
|
||||
errors.shouldBeEmpty()
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,8 @@ class PdfBookRenderer : BookRenderer {
|
||||
) {
|
||||
var y = contentTop
|
||||
|
||||
val renderMetaAtBottom = config.layout.metadataPosition == "bottom"
|
||||
|
||||
if (pageIndex == 0) {
|
||||
// Render title
|
||||
val titleFont = fontMetrics.getBaseFont(config.fonts.title)
|
||||
@@ -116,21 +118,23 @@ class PdfBookRenderer : BookRenderer {
|
||||
cb.endText()
|
||||
y -= titleSize * 1.5f
|
||||
|
||||
// Render metadata line (composer/lyricist)
|
||||
val metaParts = mutableListOf<String>()
|
||||
song.composer?.let { metaParts.add("M: $it") }
|
||||
song.lyricist?.let { metaParts.add("T: $it") }
|
||||
// Render metadata line (composer/lyricist) - at top position only
|
||||
if (!renderMetaAtBottom) {
|
||||
val metaParts = buildMetadataLines(song, config)
|
||||
if (metaParts.isNotEmpty()) {
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
for (metaLine in metaParts) {
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(metaParts.joinToString(" / "))
|
||||
cb.showText(metaLine)
|
||||
cb.endText()
|
||||
y -= metaSize * 1.8f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render key and capo
|
||||
val infoParts = mutableListOf<String>()
|
||||
@@ -205,9 +209,15 @@ class PdfBookRenderer : BookRenderer {
|
||||
|
||||
// Render lines
|
||||
for (line in section.lines) {
|
||||
val imgPath = line.imagePath
|
||||
if (imgPath != null) {
|
||||
// Render inline image
|
||||
y -= renderInlineImage(cb, imgPath, leftMargin, y, contentWidth)
|
||||
} else {
|
||||
val height = chordLyricRenderer.renderLine(cb, line, leftMargin, y, contentWidth)
|
||||
y -= height + 1f // 1pt gap between lines
|
||||
}
|
||||
}
|
||||
|
||||
// End repeat marker
|
||||
if (section.type == SectionType.REPEAT) {
|
||||
@@ -226,21 +236,52 @@ class PdfBookRenderer : BookRenderer {
|
||||
y -= config.layout.verseSpacing / 0.3528f
|
||||
}
|
||||
|
||||
// Render notes at the bottom
|
||||
// Render notes at the bottom (with word-wrap for multi-paragraph notes)
|
||||
if (pageIndex == 0 && song.notes.isNotEmpty()) {
|
||||
y -= 4f
|
||||
val metaFont = fontMetrics.getBaseFont(config.fonts.metadata)
|
||||
val metaSize = config.fonts.metadata.size
|
||||
for (note in song.notes) {
|
||||
val noteLineHeight = metaSize * 1.5f
|
||||
|
||||
for ((idx, note) in song.notes.withIndex()) {
|
||||
val wrappedLines = wrapText(note, metaFont, metaSize, contentWidth)
|
||||
for (wrappedLine in wrappedLines) {
|
||||
cb.beginText()
|
||||
cb.setFontAndSize(metaFont, metaSize)
|
||||
cb.setColorFill(Color.GRAY)
|
||||
cb.setTextMatrix(leftMargin, y - metaSize)
|
||||
cb.showText(note)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render reference book footer on the last page of the song
|
||||
if (config.referenceBooks.isNotEmpty() && song.references.isNotEmpty()) {
|
||||
@@ -255,6 +296,35 @@ 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
|
||||
* at the bottom of the song page, above the page number.
|
||||
@@ -437,6 +507,37 @@ class PdfBookRenderer : BookRenderer {
|
||||
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) {
|
||||
try {
|
||||
val img = Image.getInstance(imagePath)
|
||||
|
||||
@@ -3,15 +3,21 @@ package de.pfadfinder.songbook.renderer.pdf
|
||||
import com.lowagie.text.pdf.BaseFont
|
||||
import de.pfadfinder.songbook.model.FontMetrics
|
||||
import de.pfadfinder.songbook.model.FontSpec
|
||||
import java.io.File
|
||||
|
||||
class PdfFontMetrics : FontMetrics {
|
||||
private val fontCache = mutableMapOf<String, BaseFont>()
|
||||
|
||||
fun getBaseFont(font: FontSpec): BaseFont {
|
||||
val key = font.file ?: font.family
|
||||
val fontFile = font.file
|
||||
val key = if (fontFile != null) File(fontFile).canonicalPath else font.family
|
||||
return fontCache.getOrPut(key) {
|
||||
if (font.file != null) {
|
||||
BaseFont.createFont(font.file, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
|
||||
if (fontFile != null) {
|
||||
val file = File(fontFile)
|
||||
if (!file.exists()) {
|
||||
throw IllegalArgumentException("Font file not found: ${file.absolutePath}")
|
||||
}
|
||||
BaseFont.createFont(file.absolutePath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
|
||||
} else {
|
||||
// Map common family names to built-in PDF fonts
|
||||
val pdfFontName = when (font.family.lowercase()) {
|
||||
|
||||
@@ -3,11 +3,15 @@ package de.pfadfinder.songbook.renderer.pdf
|
||||
import com.lowagie.text.*
|
||||
import com.lowagie.text.pdf.*
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import java.awt.Color
|
||||
|
||||
class TocRenderer(
|
||||
private val fontMetrics: PdfFontMetrics,
|
||||
private val config: BookConfig
|
||||
) {
|
||||
// Light gray background for the highlighted column
|
||||
private val highlightColor = Color(220, 220, 220)
|
||||
|
||||
fun render(document: Document, writer: PdfWriter, tocEntries: List<TocEntry>) {
|
||||
val tocFont = fontMetrics.getBaseFont(config.fonts.toc)
|
||||
val tocBoldFont = fontMetrics.getBaseFontBold(config.fonts.toc)
|
||||
@@ -35,12 +39,25 @@ class TocRenderer(
|
||||
}
|
||||
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
|
||||
val headerFont = Font(tocBoldFont, fontSize, Font.BOLD)
|
||||
table.addCell(headerCell("Titel", headerFont))
|
||||
table.addCell(headerCell("Seite", headerFont))
|
||||
for (book in refBooks) {
|
||||
table.addCell(headerCell(book.abbreviation, headerFont))
|
||||
table.addCell(headerCell("Titel", headerFont, isHighlighted = false))
|
||||
table.addCell(headerCell("Seite", headerFont, isHighlighted = highlightColumnIndex == 1))
|
||||
for ((i, book) in refBooks.withIndex()) {
|
||||
val isHighlighted = highlightColumnIndex == 2 + i
|
||||
table.addCell(headerCell(book.abbreviation, headerFont, isHighlighted = isHighlighted))
|
||||
}
|
||||
table.headerRows = 1
|
||||
|
||||
@@ -49,31 +66,43 @@ class TocRenderer(
|
||||
val aliasFont = Font(tocFont, fontSize, Font.ITALIC)
|
||||
for (entry in tocEntries.sortedBy { it.title.lowercase() }) {
|
||||
val font = if (entry.isAlias) aliasFont else entryFont
|
||||
table.addCell(entryCell(entry.title, font))
|
||||
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT))
|
||||
for (book in refBooks) {
|
||||
table.addCell(entryCell(entry.title, font, isHighlighted = false))
|
||||
table.addCell(entryCell(entry.pageNumber.toString(), entryFont, Element.ALIGN_RIGHT, isHighlighted = highlightColumnIndex == 1))
|
||||
for ((i, book) in refBooks.withIndex()) {
|
||||
val ref = entry.references[book.abbreviation]
|
||||
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT))
|
||||
val isHighlighted = highlightColumnIndex == 2 + i
|
||||
table.addCell(entryCell(ref?.toString() ?: "", entryFont, Element.ALIGN_RIGHT, isHighlighted = isHighlighted))
|
||||
}
|
||||
}
|
||||
|
||||
document.add(table)
|
||||
}
|
||||
|
||||
private fun headerCell(text: String, font: Font): PdfPCell {
|
||||
private fun headerCell(text: String, font: Font, isHighlighted: Boolean): PdfPCell {
|
||||
val cell = PdfPCell(Phrase(text, font))
|
||||
cell.borderWidth = 0f
|
||||
cell.borderWidthBottom = 0.5f
|
||||
cell.paddingBottom = 4f
|
||||
if (isHighlighted) {
|
||||
cell.backgroundColor = highlightColor
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
private fun entryCell(text: String, font: Font, alignment: Int = Element.ALIGN_LEFT): PdfPCell {
|
||||
private fun entryCell(
|
||||
text: String,
|
||||
font: Font,
|
||||
alignment: Int = Element.ALIGN_LEFT,
|
||||
isHighlighted: Boolean = false
|
||||
): PdfPCell {
|
||||
val cell = PdfPCell(Phrase(text, font))
|
||||
cell.borderWidth = 0f
|
||||
cell.horizontalAlignment = alignment
|
||||
cell.paddingTop = 1f
|
||||
cell.paddingBottom = 1f
|
||||
if (isHighlighted) {
|
||||
cell.backgroundColor = highlightColor
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import io.kotest.matchers.floats.shouldBeLessThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeSameInstanceAs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class PdfFontMetricsTest {
|
||||
|
||||
@@ -158,4 +159,73 @@ class PdfFontMetricsTest {
|
||||
height shouldBeGreaterThan 3f
|
||||
height shouldBeLessThan 6f
|
||||
}
|
||||
|
||||
// --- Custom font file tests ---
|
||||
|
||||
private val testFontPath: String
|
||||
get() = this::class.java.getResource("/TestFont.ttf")!!.file
|
||||
|
||||
@Test
|
||||
fun `getBaseFont loads custom font from file path`() {
|
||||
val font = FontSpec(file = testFontPath, size = 12f)
|
||||
val baseFont = metrics.getBaseFont(font)
|
||||
// Custom font should load successfully and have a non-null PostScript name
|
||||
baseFont.postscriptFontName.isNotEmpty() shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFont caches custom font by canonical path`() {
|
||||
val font1 = FontSpec(file = testFontPath, size = 12f)
|
||||
val font2 = FontSpec(file = testFontPath, size = 14f) // different size, same file
|
||||
val first = metrics.getBaseFont(font1)
|
||||
val second = metrics.getBaseFont(font2)
|
||||
first shouldBeSameInstanceAs second
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFont throws for missing font file`() {
|
||||
val font = FontSpec(file = "/nonexistent/path/MissingFont.ttf", size = 12f)
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
metrics.getBaseFont(font)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getBaseFontBold returns same font when file is specified`() {
|
||||
val font = FontSpec(file = testFontPath, size = 12f)
|
||||
val regular = metrics.getBaseFont(font)
|
||||
val bold = metrics.getBaseFontBold(font)
|
||||
// Custom fonts don't have auto-resolved bold variants
|
||||
regular shouldBeSameInstanceAs bold
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureTextWidth works with custom font file`() {
|
||||
val font = FontSpec(file = testFontPath, size = 12f)
|
||||
val width = metrics.measureTextWidth("Hello World", font, 12f)
|
||||
width shouldBeGreaterThan 0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureTextWidth handles German umlauts with custom font`() {
|
||||
val font = FontSpec(file = testFontPath, size = 12f)
|
||||
// These should not throw and should return positive widths
|
||||
val umlautWidth = metrics.measureTextWidth("\u00e4\u00f6\u00fc\u00df", font, 12f)
|
||||
umlautWidth shouldBeGreaterThan 0f
|
||||
|
||||
// Full German words with umlauts
|
||||
val wordWidth = metrics.measureTextWidth("Gr\u00fc\u00dfe aus \u00d6sterreich", font, 12f)
|
||||
wordWidth shouldBeGreaterThan 0f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measureTextWidth with custom font returns different width than built-in font`() {
|
||||
val customFont = FontSpec(file = testFontPath, size = 10f)
|
||||
val builtInFont = FontSpec(family = "Courier", size = 10f) // use Courier for contrast
|
||||
val customWidth = metrics.measureTextWidth("Test text", customFont, 10f)
|
||||
val builtInWidth = metrics.measureTextWidth("Test text", builtInFont, 10f)
|
||||
// They should both be positive but likely different
|
||||
customWidth shouldBeGreaterThan 0f
|
||||
builtInWidth shouldBeGreaterThan 0f
|
||||
}
|
||||
}
|
||||
|
||||
BIN
renderer-pdf/src/test/resources/TestFont.ttf
Normal file
BIN
renderer-pdf/src/test/resources/TestFont.ttf
Normal file
Binary file not shown.
@@ -14,6 +14,11 @@ fonts:
|
||||
title: { family: "Helvetica", size: 14 }
|
||||
metadata: { family: "Helvetica", size: 8 }
|
||||
toc: { family: "Helvetica", size: 9 }
|
||||
# To use a custom font file (e.g. Fraktur/Blackletter for titles):
|
||||
# title: { file: "./fonts/FrakturFont.ttf", size: 16 }
|
||||
# The file path is relative to the project directory.
|
||||
# Supported formats: .ttf, .otf
|
||||
# Custom fonts are embedded in the PDF and support Unicode (including umlauts).
|
||||
|
||||
layout:
|
||||
margins: { top: 15, bottom: 15, inner: 20, outer: 12 }
|
||||
|
||||
Reference in New Issue
Block a user