feat: support rich multi-paragraph notes with formatting (Closes #7) #11
@@ -58,11 +58,23 @@ class MeasurementEngine(
|
|||||||
heightMm += config.layout.verseSpacing
|
heightMm += config.layout.verseSpacing
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notes at bottom
|
// Notes at bottom (with word-wrap estimation for multi-paragraph notes)
|
||||||
if (song.notes.isNotEmpty()) {
|
if (song.notes.isNotEmpty()) {
|
||||||
heightMm += 1.5f // gap
|
heightMm += 1.5f // gap before notes
|
||||||
for (note in song.notes) {
|
val metaLineHeight = fontMetrics.measureLineHeight(config.fonts.metadata, config.fonts.metadata.size) * 1.5f
|
||||||
heightMm += 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ 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()))
|
||||||
@@ -37,6 +48,27 @@ 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
|
||||||
|
|
||||||
@@ -97,6 +129,14 @@ object ChordProParser {
|
|||||||
"end_of_repeat", "eor" -> {
|
"end_of_repeat", "eor" -> {
|
||||||
flushSection()
|
flushSection()
|
||||||
}
|
}
|
||||||
|
"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))
|
||||||
|
|||||||
@@ -485,4 +485,96 @@ 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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,19 +226,28 @@ class PdfBookRenderer : BookRenderer {
|
|||||||
y -= config.layout.verseSpacing / 0.3528f
|
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()) {
|
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
|
||||||
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.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(note)
|
cb.showText(wrappedLine)
|
||||||
cb.endText()
|
cb.endText()
|
||||||
y -= metaSize * 1.5f
|
y -= noteLineHeight
|
||||||
|
}
|
||||||
|
// Add paragraph spacing between note paragraphs
|
||||||
|
if (idx < song.notes.size - 1) {
|
||||||
|
y -= noteLineHeight * 0.3f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user