diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 9b8e06b..8bf9ba5 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -53,6 +53,7 @@ kotlin {
implementation(libs.koin.androidx.compose)
implementation("com.google.zxing:core:3.5.4")
implementation("com.github.billthefarmer:mididriver:1.25")
+ implementation("com.tom-roush:pdfbox-android:2.0.27.0")
}
commonMain.dependencies {
// implementation(compose.components.resources)
@@ -86,6 +87,7 @@ kotlin {
implementation(libs.kotlinx.coroutinesSwing)
implementation("com.google.zxing:core:3.5.4")
implementation("com.google.zxing:javase:3.5.4")
+ implementation("org.apache.pdfbox:pdfbox:3.0.7")
}
}
targets.all {
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index c0af1cb..5002896 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -1,6 +1,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidApp.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidApp.kt
index cb9cdb7..d4b335b 100644
--- a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidApp.kt
+++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidApp.kt
@@ -1,6 +1,7 @@
package mg.dot.feufaro
import android.app.Application
+import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import org.koin.core.context.GlobalContext.startKoin
import mg.dot.feufaro.di.commonModule // Importez votre module commun
import org.koin.android.ext.koin.androidContext
@@ -12,6 +13,7 @@ import mg.dot.feufaro.di.platformModule
class AndroidApp: Application() {
override fun onCreate() {
super.onCreate()
+ PDFBoxResourceLoader.init(this)
startKoin {
// Log Koin messages (INFO pour le développement, ERROR pour la production)
androidLogger(Level.INFO)
diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt
index 8bdbe5c..5f58e4c 100644
--- a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt
+++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt
@@ -1,7 +1,11 @@
package mg.dot.feufaro
import android.content.Context
+import android.content.Intent
import android.net.Uri
+import android.os.Environment
+import android.widget.Toast
+import androidx.core.content.FileProvider
import feufaro.composeapp.generated.resources.Res
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -62,10 +66,10 @@ class AndroidFileRepository(private val context: Context) : FileRepository {
}
}
- override fun getFileName(shortName: String): String {
+ /*override fun getFileName(shortName: String): String {
val folder = context.getExternalFilesDir(null)
return "$folder/$shortName"
- }
+ }*/
// Dans androidMain / AndroidFileRepository.kt
/*override suspend fun readFileBytes(filePath: String): ByteArray = withContext(Dispatchers.IO) {
val folder = context.getExternalFilesDir(null)
@@ -73,5 +77,59 @@ class AndroidFileRepository(private val context: Context) : FileRepository {
if (!file.exists()) throw IOException("File not found: ${file.absolutePath}")
file.readBytes()
}*/
+ override suspend fun saveLocalFile(filePath: String, data: ByteArray) {
+ val cleanPath = if (filePath.contains("/storage/emulated/0/")) {
+ filePath.substring(filePath.indexOf("/storage/emulated/0/"))
+ } else {
+ filePath
+ }
+ val file = File(cleanPath)
+ try {
+ file.parentFile?.mkdirs()
+ file.writeBytes(data)
+ println("Fichier sauvegardé avec succès dans : ${file.absolutePath}")
+ openPdfFile(context = context, filePath = file.absolutePath)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ throw e
+ }
+ }
+ override fun getFileName(shortName: String): String {
+ return File(context.filesDir, shortName).absolutePath
+ }
+
+ override fun pickSavePath(defaultName: String): String? {
+ val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ return File(downloadDir, defaultName).absolutePath
+ }
+ suspend fun openPdfFile(context: Context, filePath: String) {
+ val file = File(filePath)
+ if (!file.exists()) return
+
+ // 1. Transformer le File en Uri sécurisée
+ val uri: Uri = FileProvider.getUriForFile(
+ context,
+ "mg.dot.feufaro.fileprovider",
+ file
+ )
+
+ // uvrir un PDF
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/pdf")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // Important !
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ try {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context.applicationContext, "Fichier sauvegardé avec succès", Toast.LENGTH_LONG).show()
+ }
+ context.startActivity(intent)
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context.applicationContext, "Aucun lecteur PDF trouvé", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/MainActivity.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/MainActivity.kt
index 088df3a..cff8293 100644
--- a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/MainActivity.kt
@@ -1,19 +1,26 @@
package mg.dot.feufaro
+import android.Manifest
import android.os.Bundle
+import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
+import androidx.core.app.ActivityCompat
import androidx.core.view.WindowCompat
-import android.view.WindowManager
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import org.koin.androidx.compose.KoinAndroidContext
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
+ ActivityCompat.requestPermissions(
+ this,
+ arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
+ 1
+ )
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setFilePickerActivity(this)
diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt
new file mode 100644
index 0000000..eaaf3c1
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt
@@ -0,0 +1,1041 @@
+package mg.dot.feufaro.pdf
+
+import com.tom_roush.pdfbox.pdmodel.PDDocument
+import com.tom_roush.pdfbox.pdmodel.PDPage
+import com.tom_roush.pdfbox.pdmodel.PDPageContentStream
+import com.tom_roush.pdfbox.pdmodel.common.PDRectangle
+import com.tom_roush.pdfbox.pdmodel.font.PDType0Font
+import com.tom_roush.pdfbox.util.Matrix
+import feufaro.composeapp.generated.resources.Res
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import mg.dot.feufaro.FileRepository
+import mg.dot.feufaro.data.GridTUOData
+import mg.dot.feufaro.solfa.TimeUnitObject
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+
+private data class PdfColor(val r: Float, val g: Float, val b: Float)
+
+private val COLOR_BLACK = PdfColor(0f, 0f, 0f)
+private val COLOR_SOPRANO = PdfColor(180 / 255f, 0f, 0f)
+private val COLOR_ALTO = PdfColor(0f, 120 / 255f, 0f)
+private val COLOR_TENOR = PdfColor(0f, 0f, 180 / 255f)
+private val COLOR_BASSE = PdfColor(0f, 100 / 255f, 120 / 255f)
+
+private fun PDPageContentStream.setNonStrokingPdfColor(c: PdfColor) =
+ setNonStrokingColor(c.r, c.g, c.b)
+
+// ── Sanitize ──────────────────────────────────────────────────────────────────
+private fun String.sanitize(): String = this
+ .replace('\u2015', '—')
+ .replace('\u2014', '—')
+ .replace('\u2013', '—')
+ .replace('―', '-')
+ .replace('\u00A0', ' ')
+ .trim()
+ .filter { it.code in 32..126 || it.code > 160 }
+
+// ── Mesures texte ─────────────────────────────────────────────────────────────
+private fun textWidth(font: PDType0Font, fontSize: Float, text: String): Float =
+ try { font.getStringWidth(text.sanitize()) / 1000f * fontSize }
+ catch (e: Exception) { fontSize * text.length * 0.6f }
+
+
+
+private val REGEX_PREFIX_DIGITS2 = Regex("(\\d+)\\.")
+private val REGEX_CLEAN = Regex("^(\\d+\\.)+")
+private val REGEX_HAS_LYRIC = Regex("[a-zA-Z0-9]")
+private val REGEX_CLEAN_GLOBAL = Regex("\\d+\\.")
+
+private fun cleanSyllable(raw: String): String =
+ raw.replace(REGEX_CLEAN_GLOBAL, "").replace(Regex("\\s+"), " ").trim()
+
+private fun prefixIsOnlyOne(rawSyl: String): Boolean {
+ val digits = REGEX_PREFIX_DIGITS2.findAll(rawSyl).map { it.groupValues[1] }.toList()
+ return digits.size == 1 && digits[0] == "1"
+}
+
+private fun measureNoteWidth(font: PDType0Font, fontSize: Float, text: String): Float {
+ var total = 0f
+ text.forEach { ch ->
+ total += try { font.getStringWidth(ch.toString().sanitize()) / 1000f * fontSize }
+ catch (e: Exception) { 0f }
+ }
+ return total
+}
+
+private fun bestColWidthPt(
+ tuoList: List,
+ font: org.apache.pdfbox.pdmodel.font.PDType0Font,
+ noteFontSize: Float,
+ lyricFont: org.apache.pdfbox.pdmodel.font.PDType0Font,
+ lyricFontSize: Float
+): Float {
+ var maxNoteW = 0f
+ var maxLyrW = 0f
+
+ tuoList.forEach { tuo ->
+ tuo.noteAsMultiString()
+ .split("\n")
+ .forEach { line ->
+ val w = measureNoteWidth(font, noteFontSize, line)
+ if (w > maxNoteW) {
+ maxNoteW = w
+ }
+ }
+ //lyrics
+ (1..2).forEach { index ->
+ tuo.lyricsAsMultiString(index)
+ .split("\n")
+ .forEach { line ->
+ val trimmedLine = cleanSyllable(line)
+ val x = measureNoteWidth(font, lyricFontSize, trimmedLine)
+ if (x > maxLyrW) {
+ maxLyrW = x
+ }
+ }
+ }
+ }
+ val minRequiredByLyrics = maxLyrW * 0.65f
+
+ return maxOf(maxNoteW + 2f, minRequiredByLyrics)
+}
+
+private fun spacedSyllable(
+ currentSyl: String,
+ allTemps: List>,
+ sIdx: Int,
+ lineIdx: Int,
+ colWidthPt: Float,
+ font: PDType0Font,
+ fontSize: Float
+): Pair {
+ val clean = currentSyl.sanitize()
+ if (clean.isEmpty()) return "" to 0f
+
+ val currentW = textWidth(font, fontSize, clean)
+
+ // Si ça rentre, on ne décale pas (aligné à gauche sur la note)
+ if (currentW <= colWidthPt) return clean to 0f
+
+ val nextSyl = allTemps.getOrNull(sIdx + 1)?.getOrNull(lineIdx) ?: ""
+ val prevSyl = if (sIdx > 0) allTemps.getOrNull(sIdx - 1)?.getOrNull(lineIdx) ?: "" else ""
+
+ val nextEmpty = nextSyl.sanitize().trim().isEmpty()
+ val prevEmpty = prevSyl.sanitize().trim().isEmpty()
+
+ return when {
+ // Si place libre à droite : on reste à gauche, on déborde à droite
+ nextEmpty -> clean to 0f
+
+ // Si place libre à gauche : on se colle à droite, on déborde à gauche
+ prevEmpty -> clean to (colWidthPt - currentW)
+
+ // Si bloqué ou libre des deux côtés : on centre pour équilibrer
+ else -> clean to (colWidthPt - currentW) / 2f
+ }
+}
+
+// ── getVirtualLineIndex ───────────────────────────────────────────────────────
+fun getVirtualLineIndex(realLi: Int, spacings: List): Int {
+ var sum = realLi
+ for (i in 0 until realLi) sum += spacings[i]
+ return sum
+}
+
+// ── drawHairPinForTUO ─────────────────────────────────────────────────────────
+private fun drawHairPinForTUO(
+ cs: PDPageContentStream,
+ tuo: TimeUnitObject,
+ gridColumnCount: Int,
+ colX: Float, colWidth: Float,
+ markerY: Float,
+ noteLineH: Float
+) {
+ val hairPinSymbol = tuo.hasHairPin() ?: return
+
+ if (hairPinSymbol != '=' && TimeUnitObject.lastHairPinSymbol == null) {
+ TimeUnitObject.startHairPin(hairPinSymbol, tuo.numBlock)
+ return
+ }
+
+ if (hairPinSymbol == '=' && TimeUnitObject.lastHairPinSymbol != null) {
+ val hairPinStart = TimeUnitObject.lastHairPinStart
+ val lastHairPinSymbol = TimeUnitObject.lastHairPinSymbol!!
+
+ val hairPinStartLine = (hairPinStart - 1) / gridColumnCount
+ val hairPinEndLine = (tuo.numBlock - 1) / gridColumnCount
+ if (hairPinStartLine != hairPinEndLine) { TimeUnitObject.endHairPin(); return }
+
+ val colsDelta = (tuo.numBlock - hairPinStart).toFloat()
+ val xStart = if (lastHairPinSymbol == '>') colX - colWidth * colsDelta else colX + colWidth / 2f
+ val xEnd = if (lastHairPinSymbol == '>') colX + colWidth / 2f else colX - colWidth * colsDelta
+
+ val markerMidY = markerY + noteLineH * 0.4f
+ cs.setStrokingColor(0.2f, 0.2f, 0.2f); cs.setLineWidth(1f)
+
+ cs.moveTo(xStart, markerMidY + noteLineH * 0.6f); cs.lineTo(xEnd, markerMidY); cs.stroke()
+ cs.moveTo(xStart, markerMidY - noteLineH * 0.4f); cs.lineTo(xEnd, markerMidY); cs.stroke()
+
+ TimeUnitObject.endHairPin()
+ }
+}
+
+// ── drawTUOUnderlines ─────────────────────────────────────────────────────────
+private fun drawTUOUnderlines(
+ cs: PDPageContentStream,
+ tuo: TimeUnitObject,
+ font: PDType0Font,
+ fontSize: Float,
+ colX: Float,
+ y: Float,
+ noteLineH: Float,
+ colWidth: Float,
+ customSpacing: List
+) {
+ val multiLineText = tuo.noteAsMultiString()
+ val noteLines = multiLineText.split("\n")
+ val separatorLength = if (tuo.sep0 in listOf(":", "!")) 1 else 0
+ val leftMarginUnderline = when (tuo.sep0) {
+ "!" -> 4f; ":" -> 3f; "|" -> 4f; "/" -> 4f; else -> 0f
+ }
+
+ tuo.annotations().forEach { ta ->
+ val voiceLineIndex = ta.voiceNumber - 1
+ val vLi = getVirtualLineIndex(voiceLineIndex, customSpacing)
+ val underlineY = y - (vLi * noteLineH) - 3f
+ val lineText = noteLines.getOrNull(voiceLineIndex) ?: return@forEach
+
+ ta.underlineSpec.forEach { us ->
+ var xStart = if (us.x > -1) colX + measureNoteWidth(font, fontSize, lineText.take(us.x)) else colX
+ if (us.isNewParen) xStart += leftMarginUnderline
+
+ val xEnd = if (us.y > -1) {
+ val endIdx = (us.y + separatorLength + 1).coerceAtMost(lineText.length)
+ colX + measureNoteWidth(font, fontSize, lineText.take(endIdx))
+ } else colX + colWidth
+
+ if (xEnd > xStart) {
+ cs.setStrokingColor(0.3f, 0.3f, 0.3f); cs.setLineWidth(0.9f)
+ cs.moveTo(xStart, underlineY); cs.lineTo(xEnd, underlineY); cs.stroke()
+ }
+ }
+ }
+}
+
+// ── drawNoteWithModulation ────────────────────────────────────────────────────
+private fun drawNoteWithModulation(
+ cs: PDPageContentStream, font: PDType0Font, fontSize: Float,
+ text: String, x: Float, y: Float
+) {
+ val parts = text.split(">")
+ var curX = x
+ parts.forEachIndexed { index, part ->
+ if (part.isEmpty()) return@forEachIndexed
+ val isExposant = index == 1
+ val currentSize = if (isExposant) fontSize * 0.7f else fontSize
+ val currentY = if (isExposant) y + (fontSize * 0.45f) else y
+ cs.beginText()
+ cs.setTextMatrix(Matrix.getTranslateInstance(curX, currentY))
+ cs.setFont(font, currentSize)
+ cs.showText(part)
+ cs.endText()
+ curX += textWidth(font, currentSize, part) + 0.5f
+ }
+}
+
+// ── drawNoteWithSubscript ─────────────────────────────────────────────────────
+private fun drawNoteWithSubscript(
+ cs: PDPageContentStream, font: PDType0Font, fontSize: Float,
+ text: String, x: Float, y: Float
+) {
+ var curX = x
+ var i = 0
+ while (i < text.length) {
+ val str = text[i].toString()
+ cs.beginText()
+ cs.setFont(font, fontSize)
+ cs.newLineAtOffset(curX, y)
+ cs.showText(str.sanitize())
+ cs.endText()
+ curX += font.getStringWidth(str.sanitize()) / 1000f * fontSize
+ i++
+ }
+}
+
+// ── drawHeader ────────────────────────────────────────────────────────────────
+private fun drawHeader(
+ cs: PDPageContentStream,
+ noteFont: PDType0Font, lyricFont: PDType0Font?,
+ pageWidth: Float, pageHeight: Float, marginX: Float, usableWidth: Float,
+ songTitle: String, songAuthor: String, songRhythm: String,
+ songComposer: String, songKey: String, measure: String,
+ stanza: Int, nbStanza: Int
+) {
+ val titleFont = lyricFont ?: noteFont
+ val normalFont = lyricFont ?: noteFont
+ val smallFont = noteFont
+
+ fun tw(f: PDType0Font, sz: Float, t: String) =
+ try { f.getStringWidth(t.sanitize()) / 1000f * sz } catch (e: Exception) { 0f }
+
+ // Ligne 1 — Titre centré
+ val titleSize = 15f
+ val titleW = tw(titleFont, titleSize, songTitle)
+ cs.beginText(); cs.setFont(titleFont, titleSize)
+ cs.newLineAtOffset(marginX + (usableWidth - titleW) / 2f, pageHeight - 48f)
+ cs.showText(songTitle.sanitize()); cs.endText()
+
+ // Ligne 2 — Auteur | Compositeur
+ val line2Y = pageHeight - 64f; val sz2 = 9f
+ cs.beginText(); cs.setFont(normalFont, sz2)
+ cs.newLineAtOffset(marginX, line2Y); cs.showText(songAuthor.sanitize()); cs.endText()
+
+ val composerW = tw(normalFont, sz2, songComposer)
+ cs.beginText(); cs.setFont(normalFont, sz2)
+ cs.newLineAtOffset(marginX + usableWidth - composerW, line2Y)
+ cs.showText(songComposer.sanitize()); cs.endText()
+
+ // Ligne 3 — Key | Measure | Rythme
+ val line3Y = pageHeight - 76f; val sz3 = 8f
+ val keyTxt = "Dô dia ${songKey.sanitize()}"
+ cs.beginText(); cs.setFont(smallFont, sz3)
+ cs.newLineAtOffset(marginX, line3Y); cs.showText(keyTxt); cs.endText()
+
+ val keyW = tw(smallFont, sz3, keyTxt)
+ cs.beginText(); cs.setFont(smallFont, sz3)
+ cs.newLineAtOffset(marginX + keyW + 10f, line3Y)
+ cs.showText(measure.sanitize()); cs.endText()
+
+ val rhythmW = tw(normalFont, sz3, songRhythm.sanitize())
+ cs.beginText(); cs.setFont(normalFont, sz2)
+ cs.newLineAtOffset(marginX + (usableWidth - rhythmW) / 2f, line3Y)
+ cs.showText(songRhythm.sanitize()); cs.endText()
+
+ // Ligne de séparation
+ cs.setStrokingColor(0.6f, 0.6f, 0.6f); cs.setLineWidth(0.4f)
+ cs.moveTo(marginX, line3Y - 5f); cs.lineTo(marginX + usableWidth, line3Y - 5f); cs.stroke()
+}
+
+// ── actual fun ────────────────────────────────────────────────────────────────
+actual fun rememberPdfExportAction(
+ scope: CoroutineScope,
+ fileRepository: FileRepository,
+ gridData: GridTUOData,
+ songTitle: String,
+ measure: String,
+ stanza: Int,
+ nbStanza: Int,
+ songKey: String,
+ songAut: String,
+ songComp: String,
+ songRythm: String
+): () -> Unit {
+ return {
+ scope.launch(Dispatchers.IO) {
+ val (pdfBytes, computedFileName) = generatePdfToBytes(
+ gridData, songTitle, measure, stanza,
+ nbStanza, songKey, songAut, songComp, songRythm
+ )
+ val chosenPath = withContext(Dispatchers.Main) {
+ fileRepository.pickSavePath(computedFileName)
+ }
+ if (chosenPath != null) {
+ fileRepository.saveLocalFile(chosenPath, pdfBytes)
+ }
+ }
+ }
+}
+
+// ── generatePdfToBytes ────────────────────────────────────────────────────────
+private suspend fun generatePdfToBytes(
+ gridData: GridTUOData,
+ songTitle: String,
+ measure: String,
+ stanza: Int,
+ nbStanza: Int,
+ songKey: String,
+ songAut: String,
+ songComp: String,
+ songRythm: String
+): Pair {
+
+ val tuoList = gridData.tuoList.drop(1)
+
+ val REGEX_PREFIX_DIGITS = Regex("(\\d+)\\.")
+
+ fun getVoicesFromPrefix(syl: String): List =
+ REGEX_PREFIX_DIGITS.findAll(syl).map { it.groupValues[1].toInt() }.toList()
+
+ val markerHeight = 16f
+ val noteFontSize = 12.5f
+ val lyricFontSize = 13.25f
+ val noteLineH = 15f
+ val lyricLineH = 13f
+ val extraSylLineH = 14f
+ val adjustedMargin = 14f
+ val interRowGap = 16f
+ val marginX = 40f
+ val marginY = 50f
+ val headerH = 55f
+ val pageWidth = PDRectangle.A4.width
+ val pageHeight = PDRectangle.A4.height
+ val usableWidth = pageWidth - 2 * marginX
+
+ // ── Polices ────────────────────────────────────────────────────────────────
+ val noteFontBytes = Res.readBytes("files/PTSerif-Regular.ttf")
+ val lyricFontBytes = Res.readBytes("files/LinLibertine_R.ttf")
+ val markerItalicBoldFontBytes = Res.readBytes("files/PT Serif Bold Italic.ttf")
+ val markerBoldFontBytes = Res.readBytes("files/PTSerif-Bold.ttf")
+ val emmentalerFontBytes = Res.readBytes("files/emmentaler-20.ttf")
+
+ PDDocument().use { doc ->
+
+ val customFont = PDType0Font.load(doc, ByteArrayInputStream(noteFontBytes), false)
+ val lyricFont = PDType0Font.load(doc, ByteArrayInputStream(lyricFontBytes), false)
+ val markerItalicFont = PDType0Font.load(doc, ByteArrayInputStream(markerItalicBoldFontBytes), false)
+ val markerBoldFont = PDType0Font.load(doc, ByteArrayInputStream(markerBoldFontBytes), false)
+ val markerEmmetFont = PDType0Font.load(doc, ByteArrayInputStream(emmentalerFontBytes), false)
+
+ // ── Largeur optimale de colonne ────────────────────────────────────────
+ val bestColW = bestColWidthPt(tuoList, customFont, noteFontSize, lyricFont, lyricFontSize)
+ val colCount = ((usableWidth / bestColW).toInt()).coerceAtLeast(1)
+ val colWidth = bestColW
+
+ val maxSylsSize = tuoList.maxOfOrNull { tuo ->
+ (1..nbStanza).maxOfOrNull { sz -> tuo.getSingleSyllable(sz).size } ?: 1
+ } ?: 1
+
+ // ── Fonctions de dessin locales ────────────────────────────────────────
+ fun newPage(): PDPageContentStream {
+ val page = PDPage(PDRectangle.A4)
+ doc.addPage(page)
+ return PDPageContentStream(doc, page)
+ }
+
+ fun drawCurlyBrace(cs: PDPageContentStream, x: Float, top: Float, bottom: Float) {
+ val height = top - bottom
+ val braceW = (height * 0.12f).coerceIn(8f, 22f)
+ cs.saveGraphicsState()
+ cs.transform(Matrix(braceW / 10f, 0f, 0f, -(height / 90f), x - braceW, top))
+ cs.setNonStrokingColor(0f, 0f, 0f)
+ cs.moveTo(2.5255237f, 42.511266f)
+ cs.curveTo(2.9018235f, 41.543703f, 2.9183988f, 40.479268f, 2.9295801f, 39.441167f)
+ cs.curveTo(3.0257633f, 30.51126f, 3.0823959f, 21.580072f, 2.947325f, 12.650669f)
+ cs.curveTo(2.9231886f, 11.055039f, 2.8933167f, 9.4523308f, 3.1035398f, 7.8704257f)
+ cs.curveTo(3.3137629f, 6.2885207f, 3.7758163f, 4.7150177f, 4.6625942f, 3.3882765f)
+ cs.curveTo(5.8680949f, 1.5846823f, 7.8548731f, 0.32344155f, 10f, 0f)
+ cs.curveTo(9.1651831f, 0.77722338f, 8.4802709f, 1.7148791f, 7.9937845f, 2.746541f)
+ cs.curveTo(6.9576584f, 4.9437899f, 6.8533308f, 7.4514513f, 6.8235522f, 9.8805609f)
+ cs.curveTo(6.7206706f, 18.272857f, 7.2905092f, 26.672179f, 6.8823909f, 35.055177f)
+ cs.curveTo(6.8167718f, 36.403033f, 6.7250316f, 37.755886f, 6.4343209f, 39.073653f)
+ cs.curveTo(6.1436102f, 40.39142f, 5.6454801f, 41.680731f, 4.8313656f, 42.756947f)
+ cs.curveTo(4.0971435f, 43.727549f, 3.1128448f, 44.507326f, 2f, 45f)
+ cs.curveTo(3.2050792f, 45.603993f, 4.2555169f, 46.513477f, 5.0257355f, 47.619726f)
+ cs.curveTo(5.9928965f, 49.008695f, 6.5043972f, 50.673120f, 6.7431543f, 52.348661f)
+ cs.curveTo(6.9819115f, 54.024202f, 6.9612618f, 55.724436f, 6.9478472f, 57.416849f)
+ cs.curveTo(6.8820492f, 65.718083f, 7.0018422f, 74.020174f, 6.9072398f, 82.321127f)
+ cs.curveTo(6.8879888f, 84.004806f, 6.8714598f, 85.749672f, 7.5929830f, 87.289708f)
+ cs.curveTo(8.0528414f, 88.422141f, 8.9242492f, 89.387018f, 10f, 90f)
+ cs.curveTo(8.1813551f, 89.702562f, 6.4820251f, 88.725349f, 5.3102118f, 87.3031f)
+ cs.curveTo(4.2259102f, 85.987066f, 3.606374f, 84.337657f, 3.2912749f, 82.661838f)
+ cs.curveTo(2.9761757f, 80.986019f, 2.9488582f, 79.270938f, 2.9359838f, 77.565801f)
+ cs.curveTo(2.869984f, 68.824508f, 3.1582519f, 60.082204f, 3.0067424f, 51.341975f)
+ cs.curveTo(2.9840763f, 50.034421f, 2.9431715f, 48.687654f, 2.4144109f, 47.491567f)
+ cs.curveTo(1.9369295f, 46.411476f, 1.0645415f, 45.51121f, 0f, 45f)
+ cs.curveTo(1.1412417f, 44.575325f, 2.0841488f, 43.646153f, 2.5255237f, 42.511266f)
+ cs.closePath(); cs.fill()
+ cs.restoreGraphicsState()
+ }
+
+ // ── Fermata (dessin vectoriel — remplace le rendu glyphe Emmentaler) ──
+ fun drawFermata(cs: PDPageContentStream, x: Float, y: Float, width: Float = 10f, height: Float = 12f) {
+ cs.setStrokingColor(0f, 0f, 0f); cs.setNonStrokingColor(0f, 0f, 0f)
+ val circleRadius = width * 0.22f
+ val circleX = x + width / 2
+ val circleY = y + height * 0.25f
+ val k = 0.5522847498f
+ cs.moveTo(circleX + circleRadius, circleY)
+ cs.curveTo(circleX + circleRadius, circleY + circleRadius * k, circleX + circleRadius * k, circleY + circleRadius, circleX, circleY + circleRadius)
+ cs.curveTo(circleX - circleRadius * k, circleY + circleRadius, circleX - circleRadius, circleY + circleRadius * k, circleX - circleRadius, circleY)
+ cs.curveTo(circleX - circleRadius, circleY - circleRadius * k, circleX - circleRadius * k, circleY - circleRadius, circleX, circleY - circleRadius)
+ cs.curveTo(circleX + circleRadius * k, circleY - circleRadius, circleX + circleRadius, circleY - circleRadius * k, circleX + circleRadius, circleY)
+ cs.fill()
+ cs.setLineWidth(1.5f)
+ val arcStartX = x + width * 0.2f; val arcStartY = y + height * 0.55f
+ val arcPeakX = x + width / 2; val arcPeakY = y + height * 0.85f
+ val arcEndX = x + width * 0.8f; val arcEndY = y + height * 0.55f
+ cs.moveTo(arcStartX, arcStartY)
+ cs.curveTo(arcStartX + width * 0.15f, arcPeakY, arcPeakX - width * 0.15f, arcPeakY, arcPeakX, arcPeakY)
+ cs.curveTo(arcPeakX + width * 0.15f, arcPeakY, arcEndX - width * 0.15f, arcStartY, arcEndX, arcStartY)
+ cs.stroke()
+ }
+
+ // ── drawMarkers ────────────────────────────────────────────────────────
+ // NOTE : pas de TrueTypeFont / emmentalerTTF ici sur Android.
+ // La fermata (𝄐) est dessinée via drawFermata() vectoriel.
+ fun drawMarkers(
+ cs: PDPageContentStream,
+ tuo: TimeUnitObject,
+ colX: Float, colWidth: Float, y: Float,
+ boldFont: PDType0Font, italicBoldFont: PDType0Font, emmentalerFont: PDType0Font,
+ fontSize: Float
+ ) {
+ val markerText = tuo.pTemplate.markerToString()
+ val hairPinSymbol = tuo.hasHairPin()
+ val isTriolet = tuo.isTriolet()
+
+ val textMarkerOffset = 6f
+ val markerY = y - markerHeight / 2 + 4f + textMarkerOffset
+
+ // Point d'orgue → drawFermata (pas de GeneralPath sur Android)
+ if (markerText.contains("𝄐") || tuo.pTemplate.template.contains("𝄐")) {
+ drawFermata(cs, colX + colWidth / 2 - 5f, markerY - 2f, 10f, 12f)
+ }
+
+ // Marqueur texte
+ if (markerText.isNotBlank() && !markerText.contains("𝄐")) {
+ val dsRegex = Regex("""D\.?S\.?""")
+ val dcRegex = Regex("""D\.?C\.?""")
+ cs.beginText()
+ when {
+ markerText.matches(Regex("^\\s*(ppp|pp|p|mp|mf|f|ff|fff)\\s*$")) ->
+ cs.setFont(emmentalerFont, fontSize + 10f)
+ markerText.contains(dsRegex) || markerText.contains(dcRegex) ||
+ markerText.contains("$") || markerText.contains("Do dia", false) ->
+ cs.setFont(boldFont, fontSize)
+ else -> cs.setFont(italicBoldFont, fontSize)
+ }
+ cs.newLineAtOffset(colX + 2f, markerY)
+ cs.showText(markerText)
+ cs.endText()
+ }
+
+ // Triolet
+ if (isTriolet) {
+ val arcX = colX + colWidth * 0.125f
+ val arcWidth = colWidth * 0.75f
+ val arcHeight = 7f
+ cs.setLineWidth(1.2f)
+ cs.moveTo(arcX, markerY + 2f)
+ cs.curveTo(
+ arcX + arcWidth * 0.25f, markerY + arcHeight,
+ arcX + arcWidth * 0.75f, markerY + arcHeight,
+ arcX + arcWidth, markerY + arcHeight
+ )
+ cs.stroke()
+ cs.beginText()
+ cs.setFont(boldFont, fontSize * 0.7f)
+ cs.newLineAtOffset(arcX + arcWidth * 0.45f, markerY + arcHeight * 0.85f)
+ cs.showText("3")
+ cs.endText()
+ }
+ }
+
+ // ── Génération des pages ───────────────────────────────────────────────
+ var cs = newPage()
+ var y = pageHeight - marginY - headerH
+
+ drawHeader(
+ cs, customFont, lyricFont,
+ pageWidth, pageHeight, marginX, usableWidth,
+ songTitle = songTitle,
+ songAuthor = songAut,
+ songRhythm = songRythm,
+ songComposer = songComp,
+ songKey = songKey,
+ measure = measure,
+ stanza = stanza,
+ nbStanza = nbStanza
+ )
+
+ val rows = tuoList.chunked(colCount)
+
+ rows.forEachIndexed { rowIdx, rowTuos ->
+ val maxNoteLines = (rowTuos.maxOfOrNull { it.noteAsMultiString().split("\n").size } ?: 1) + 1
+ val maxLyricLines = rowTuos.maxOfOrNull { it.getSingleSyllable(stanza).size } ?: 0
+
+ val rowHasMarkers = rowTuos.any { tuo ->
+ tuo.pTemplate.markerToString().isNotBlank() || tuo.isTriolet() || tuo.hasHairPin() != null
+ }
+
+ val noteBlockH = maxNoteLines * noteLineH
+ val nbStanzasToShow = if (maxSylsSize >= 2) 1 else if (nbStanza >= 2) 2 else 1
+ val lyricBlockH = maxLyricLines * lyricLineH * nbStanzasToShow
+ val rowH = (if (rowHasMarkers) markerHeight else 0f) + noteBlockH + lyricBlockH + 10f * nbStanzasToShow + 8f
+
+ if (y - rowH < marginY) { cs.close(); cs = newPage(); y = pageHeight - marginY }
+
+ val notesStartY = if (rowHasMarkers) y - markerHeight else y
+
+ val pitchPattern = "\\b(d|di|r|ri|m|mi|f|fi|s|si|l|la|t|ta)\\b".toRegex()
+
+ fun singingVoiceIndices(tuo: TimeUnitObject): List =
+ tuo.noteAsMultiString().split("\n").mapIndexedNotNull { li, line ->
+ val clean = line.trim()
+ val sings = clean.isNotBlank()
+ && !clean.all { it == '-' || it.isWhitespace() }
+ && clean != "—" && clean != "–"
+ && pitchPattern.containsMatchIn(clean)
+ if (sings) li else null
+ }
+
+ val spacings = MutableList(4) { 0 }
+
+ rowTuos.forEach { tuo ->
+ val singing = singingVoiceIndices(tuo)
+ val rawSyls = tuo.getSingleSyllable(stanza)
+ val rawSyl0 = rawSyls.getOrNull(0) ?: ""
+ val cleanSyl0 = rawSyl0.replace(REGEX_CLEAN, "").trim()
+ val syl0HasRealText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) &&
+ !cleanSyl0.all { it == '―' || it == '-' || it == '—' }
+ val syl0EstVraimentVide = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
+ cleanSyl0.all { it == '―' || it == '-' || it == '—' }
+ val uneAutreVoixPrendLeSoprano = (1..4).any { vIdx ->
+ val s = rawSyls.getOrNull(vIdx) ?: ""
+ s.contains("1.") && REGEX_HAS_LYRIC.containsMatchIn(s.replace(REGEX_CLEAN, ""))
+ }
+ val aDuTexteAuNiveauSoprano = !syl0EstVraimentVide || uneAutreVoixPrendLeSoprano
+
+ (1..4).forEach { vIdx ->
+ val rawSyl = rawSyls.getOrNull(vIdx) ?: ""
+ val cleanContent = cleanSyllable(rawSyl)
+ val hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanContent) &&
+ !cleanContent.all { it == '―' || it == '-' || it == '—' }
+
+ if (rawSyl.isNotBlank() && hasActualText) {
+ val prefixInts = REGEX_PREFIX_DIGITS2.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList()
+ if (prefixInts.isNotEmpty()) {
+ val hasTrioATB = prefixInts.size == 3 && prefixInts.containsAll(listOf(2, 3, 4))
+ val hasDuoSA = prefixInts.size == 2 && prefixInts.containsAll(listOf(1, 2))
+ val hasDuoTB = prefixInts.size == 2 && prefixInts.containsAll(listOf(3, 4))
+ val isUnisson = prefixInts.containsAll(listOf(1, 2, 3, 4))
+
+ if (prefixInts.contains(2) && prefixInts.contains(3)) {
+ spacings[2] = 1; spacings[1] = 0
+ }
+ if (isUnisson) { /* rien */ }
+ else if (hasTrioATB) spacings[3] = 1
+ else if (hasDuoSA) spacings[1] = 1
+ else if (hasDuoTB) { if (syl0HasRealText) spacings[1] = 1; spacings[3] = 1 }
+ else {
+ prefixInts.forEach { voiceNum ->
+ when (voiceNum) {
+ 1 -> if (syl0HasRealText) spacings[0] = 1
+ 2 -> if (!prefixInts.contains(1)) spacings[1] = 1
+ 3 -> if (!prefixInts.contains(2)) spacings[2] = 1
+ 4 -> if (!prefixInts.contains(3)) spacings[3] = 1
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val customSpacings = spacings.toList()
+
+ // ---- 1. Marqueurs ----
+ if (rowHasMarkers) {
+ rowTuos.forEachIndexed { colIdx, tuo ->
+ val x = marginX + colIdx * colWidth
+ // ⚠️ Pas de emmentalerTTF sur Android → signature sans ce param
+ drawMarkers(cs, tuo, x, colWidth, y, markerBoldFont, markerItalicFont, markerEmmetFont, noteFontSize)
+ drawHairPinForTUO(cs, tuo, colCount, x, colWidth, y, noteLineH)
+ }
+ }
+
+ // ---- 2. Accolade & étiquettes SATB ----
+ val braceTop = notesStartY + noteLineH * 0.85f
+ val lastVoiceIdx = (maxNoteLines - 1).coerceIn(0, 3)
+ val totalVirtualSlots = getVirtualLineIndex(lastVoiceIdx, customSpacings) + 1
+ val braceBottom = notesStartY - (totalVirtualSlots - 1) * noteLineH
+ drawCurlyBrace(cs, marginX, braceTop, braceBottom)
+
+ val lineBottom = braceBottom
+ val voiceLabels = listOf("S", "A", "T", "B")
+ val labelFont = lyricFont
+ val labelSize = noteFontSize * 0.85f
+ val braceW = ((braceTop - braceBottom) * 0.12f).coerceIn(8f, 22f)
+ val labelX = marginX - braceW - 3f
+
+ voiceLabels.take(maxNoteLines).forEachIndexed { li, label ->
+ val vLi = getVirtualLineIndex(li, customSpacings)
+ val labelY = notesStartY - (vLi * noteLineH)
+ // Couleur par voix (sans java.awt.Color)
+ val labelColor = when (label) {
+ "S" -> COLOR_SOPRANO
+ "A" -> COLOR_ALTO
+ "T" -> COLOR_TENOR
+ "B" -> COLOR_BASSE
+ else -> COLOR_BLACK
+ }
+ cs.saveGraphicsState()
+ cs.beginText()
+ cs.setFont(labelFont, labelSize)
+ cs.setNonStrokingPdfColor(labelColor)
+ cs.newLineAtOffset(labelX - textWidth(labelFont, labelSize, label), labelY)
+ cs.showText(label)
+ cs.endText()
+ cs.restoreGraphicsState()
+ }
+
+ // ---- 3. Notes ----
+ val lineTop = notesStartY + noteLineH * 0.85f
+
+ rowTuos.forEachIndexed { colIdx, tuo ->
+ val x = marginX + colIdx * colWidth
+ val sepW = textWidth(customFont, noteFontSize, "|")
+ val noteX = when (tuo.sep0) {
+ "/", "|" -> {
+ cs.setStrokingColor(0f, 0f, 0f); cs.setLineWidth(0.8f)
+ cs.moveTo(x + sepW * 0.2f, lineTop); cs.lineTo(x + sepW * 0.2f, lineBottom); cs.stroke()
+ if (tuo.sep0 == "/") {
+ cs.moveTo(x + sepW * 0.7f, lineTop); cs.lineTo(x + sepW * 0.7f, lineBottom); cs.stroke()
+ }
+ x + sepW + 2f
+ }
+ else -> x
+ }
+
+ val noteLines = tuo.noteAsMultiString().split("\n")
+ noteLines.forEachIndexed { li, line ->
+ val vLi = getVirtualLineIndex(li, customSpacings)
+ val currentY = notesStartY - (vLi * noteLineH)
+ if (line.isNotBlank()) {
+ if (line.contains(">")) drawNoteWithModulation(cs, customFont, noteFontSize, line, noteX, currentY)
+ else drawNoteWithSubscript(cs, customFont, noteFontSize, line, noteX, currentY)
+ }
+ }
+
+ drawTUOUnderlines(cs, tuo, customFont, noteFontSize, x, notesStartY, noteLineH, colWidth, customSpacings)
+ }
+
+ // ---- 4. Paroles ----
+ val lastVoiceIndex = (maxNoteLines - 1).coerceIn(0, 3)
+ val lastVirtualIndex = getVirtualLineIndex(lastVoiceIndex, customSpacings)
+ val totalAreaSlots = lastVirtualIndex + customSpacings[lastVoiceIndex]
+ val finalBraceBottom = notesStartY - (totalAreaSlots * noteLineH)
+
+ val stanzasToShow = if (maxSylsSize >= 2) listOf(1) else if (nbStanza >= 2) listOf(1, 2) else listOf(1)
+ var lowestYOfThisRow = finalBraceBottom
+
+ stanzasToShow.forEachIndexed { stanzaIdx, currentStanza ->
+ val nbOverrideLines = customSpacings.sum()
+ val yLyric = finalBraceBottom - adjustedMargin - (nbOverrideLines * noteLineH * 0.2f)
+ val yLyricOffset = stanzaIdx * (lyricLineH * 1.1f)
+ val currentYLyric = yLyric - yLyricOffset
+
+ val allTemps: List> = rowTuos.map { it.getSingleSyllable(currentStanza) }
+ val maxLinesThisStanza = allTemps.maxOfOrNull { it.size } ?: 1
+
+ // Numéro de stanza
+ val stanzaNumTxt = "$currentStanza."
+ val stanzaNumSize = lyricFontSize * 0.85f
+ val stanzaNumW = try { lyricFont.getStringWidth(stanzaNumTxt) / 1000f * stanzaNumSize } catch (e: Exception) { 0f }
+ cs.beginText(); cs.setFont(lyricFont, stanzaNumSize)
+ cs.setNonStrokingColor(0f, 0f, 0f) // ← remplace setNonStrokingColor(Color.BLACK)
+ cs.newLineAtOffset(marginX - stanzaNumW - 4f, currentYLyric)
+ cs.showText(stanzaNumTxt); cs.endText()
+
+ // ── drawInOverrideSlot (closure locale) ───────────────────────
+ fun drawInOverrideSlot(
+ cs: PDPageContentStream,
+ targetIdx: Int,
+ cleanSyl: String,
+ sIdx: Int,
+ lineIdx: Int,
+ allTemps: List>,
+ noteX: Float,
+ colWidth: Float,
+ customFont: PDType0Font,
+ lyricFontSize: Float,
+ notesStartY: Float,
+ noteLineH: Float,
+ customSpacings: List,
+ isDirectY: Boolean = false
+ ) {
+ // Couleur par voix (sans java.awt.Color)
+ val lyricColor = when (targetIdx) {
+ -1, 0 -> COLOR_SOPRANO
+ 1 -> COLOR_ALTO
+ 2 -> COLOR_TENOR
+ 3 -> COLOR_BASSE
+ else -> COLOR_BLACK
+ }
+ val finalY = if (isDirectY) notesStartY
+ else {
+ val vLi = getVirtualLineIndex(targetIdx, customSpacings)
+ notesStartY - ((vLi + 1) * noteLineH) + 2f
+ }
+ val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
+ val lyricX = noteX + offsetX
+
+ cs.saveGraphicsState()
+ cs.beginText()
+ cs.setFont(customFont, lyricFontSize * 0.9f)
+ cs.setNonStrokingPdfColor(lyricColor) // ← remplace setNonStrokingColor(lyricColor)
+ cs.newLineAtOffset(lyricX, finalY)
+ cs.showText(spacedSyl.sanitize())
+ cs.endText()
+ cs.restoreGraphicsState()
+ }
+
+ // Syllabes
+ allTemps.forEachIndexed { sIdx, syllables ->
+ val x = marginX + sIdx * colWidth
+ val noteX = if (rowTuos.getOrNull(sIdx)?.sep0 in listOf("/", "|")) x + 3f else x
+ val filledSlots = mutableSetOf()
+ val isStandardLayout = customSpacings.all { it == 0 }
+
+ syllables.forEachIndexed { lineIdx, rawSyl ->
+ if (lineIdx == 0) {
+ val (spacedSyl, offsetX) = spacedSyllable(rawSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
+ val lyricX = noteX + offsetX
+ cs.beginText()
+ cs.setFont(lyricFont, lyricFontSize)
+ cs.newLineAtOffset(lyricX, currentYLyric)
+ cs.showText(spacedSyl.sanitize())
+ cs.endText()
+ } else {
+ val isAllZeroSpacing = customSpacings.all { it == 0 }
+
+ // 1. D'abord, on vérifie si c'est un Solo Soprano "1." même si spacing est à 0
+ val prefixes = REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList()
+ val rawSyl0 = syllables.getOrNull(0) ?: ""
+ val cleanSyl0 = cleanSyllable(rawSyl0)
+ val cleanSyl = cleanSyllable(rawSyl)
+
+ val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
+ cleanSyl0.all { it == '―' || it == '-' || it == '—' }
+
+ val isAllZeroSpacing = customSpacings.all { it == 0 }
+
+ if (prefixes.contains(1) && isAllZeroSpacing && lignePrincipaleEstLibre) {
+ // FORCE LE DESSIN SUR LA LIGNE PRINCIPALE (SYL0)
+ val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
+ val lyricX = noteX + offsetX /*if (alignLeft) noteX else noteX + (colWidth / 2f) - (textWidth(customFont, lyricFontSize, spacedSyl) / 2f)*/
+
+ cs.beginText()
+ cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize)
+ cs.newLineAtOffset(lyricX, currentYLyric)
+ cs.showText(spacedSyl.sanitize())
+ cs.endText()
+
+ } else if (isAllZeroSpacing) {
+ val cleanSyl = cleanSyllable(rawSyl)
+ if (cleanSyl.isNotBlank()) {
+ val thisLineY = currentYLyric - lineIdx * extraSylLineH
+ val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
+ val lyricX = noteX + offsetX
+ cs.beginText()
+ cs.setFont(lyricFont, lyricFontSize)
+ cs.setNonStrokingColor(0f, 0f, 0f) // ← remplace Color.BLACK
+ cs.newLineAtOffset(lyricX, thisLineY)
+ cs.showText(spacedSyl.sanitize())
+ cs.endText()
+ }
+ } else {
+ val prefixes = REGEX_PREFIX_DIGITS2.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList()
+ val cleanSyl = cleanSyllable(rawSyl)
+ val rawSyl0 = syllables.getOrNull(0) ?: ""
+ val cleanSyl0 = cleanSyllable(rawSyl0)
+ val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
+ cleanSyl0.all { it == '―' || it == '-' || it == '—' }
+ val hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl) &&
+ !cleanSyl.all { it == '―' || it == '-' }
+
+ if (prefixes.isNotEmpty() && hasActualText) {
+ prefixes.forEach { voiceNum ->
+ val targetIdx = voiceNum - 1
+ if (voiceNum == 1) {
+ if (lignePrincipaleEstLibre && !filledSlots.contains(-1)) {
+ drawInOverrideSlot(cs, -1, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth,
+ customFont, lyricFontSize, currentYLyric, noteLineH, customSpacings, isDirectY = true)
+ filledSlots.add(-1)
+ } else if (customSpacings.getOrElse(0) { 0 } == 1 && !filledSlots.contains(0)) {
+ drawInOverrideSlot(cs, 0, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth,
+ customFont, lyricFontSize, notesStartY - yLyricOffset, noteLineH, customSpacings)
+ filledSlots.add(0)
+ }
+ } else {
+ if (customSpacings.getOrElse(targetIdx) { 0 } == 1 && !filledSlots.contains(targetIdx)) {
+ drawInOverrideSlot(cs, targetIdx, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth,
+ customFont, lyricFontSize, notesStartY, noteLineH, customSpacings)
+ filledSlots.add(targetIdx)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val isAllZeroSpacing = customSpacings.all { it == 0 }
+ val maxExtraLines = if (isAllZeroSpacing) {
+ allTemps.maxOfOrNull { sylList ->
+ sylList.count { rawSyl ->
+ val clean = cleanSyllable(rawSyl)
+ REGEX_HAS_LYRIC.containsMatchIn(clean) && !clean.all { it == '―' || it == '-' || it == '—' }
+ }.coerceAtLeast(1)
+ } ?: 1
+ } else 1
+
+ val bottomOfCurrentStanza = currentYLyric - ((maxExtraLines - 1) * extraSylLineH) - 4f
+ if (bottomOfCurrentStanza < lowestYOfThisRow) lowestYOfThisRow = bottomOfCurrentStanza
+ }
+
+ y = lowestYOfThisRow - interRowGap
+ }
+
+ // ── Stanzas restantes en texte ─────────────────────────────────────────
+ val stanzasDisplayed = if (maxSylsSize >= 2) listOf(1) else if (nbStanza >= 2) listOf(1, 2) else listOf(1)
+ val stanzasRemaining = (1..nbStanza).filter { it !in stanzasDisplayed }
+
+ if (stanzasRemaining.isNotEmpty()) {
+ val textFont = lyricFont
+ val textSize = lyricFontSize
+ val lineH = lyricLineH
+ val stanzaGapY = 12f
+
+ y -= 7f
+ if (y < marginY) { cs.close(); cs = newPage(); y = pageHeight - marginY }
+ cs.setStrokingColor(0.5f, 0.5f, 0.5f); cs.setLineWidth(0.3f)
+ cs.moveTo(marginX, y + 5f); cs.lineTo(marginX + usableWidth, y + 5f); cs.stroke()
+ y -= 6f
+
+ fun buildStanzaText(sz: Int): List {
+ val normalLyrics = mutableListOf>()
+ val allDcLyrics = mutableListOf>()
+
+ tuoList.forEachIndexed { tuoIdx, tuo ->
+ val syls = tuo.getSingleSyllable(sz)
+ val normal = syls.getOrNull(0) ?: ""
+ val dcPart = syls.drop(1).joinToString(" ") { it.trim() }
+ if (normal.isNotEmpty()) normalLyrics.add(tuoIdx to normal)
+ if (dcPart.isNotEmpty()) allDcLyrics.add(tuoIdx to dcPart)
+ }
+
+ val result = StringBuilder()
+ var dcIndex = 0
+ var inDcBlock = false
+
+ tuoList.forEachIndexed { tuoIdx, tuo ->
+ val syls = tuo.getSingleSyllable(sz)
+ val normal = syls.getOrNull(0)?.trim() ?: ""
+ val dcPart = syls.drop(1).joinToString(" ") { it.trim() }
+ if (normal.isNotEmpty()) result.append(normal).append(" ")
+ if (dcPart.isNotEmpty()) result.append("[$dcPart] ")
+ val marker = tuo.pTemplate.markerToString()
+ if (marker.contains("DC") && !inDcBlock && allDcLyrics.isNotEmpty()) {
+ result.append("(")
+ while (dcIndex < allDcLyrics.size) {
+ val (dcIdx, dcText) = allDcLyrics[dcIndex]
+ if (dcIdx > tuoIdx) break
+ result.append(dcText).append(" "); dcIndex++
+ }
+ result.append(")"); inDcBlock = false
+ }
+ }
+ return listOf(result.toString().trim())
+ }
+
+ fun wrapLine(line: String, maxW: Float): List {
+ if (line.isBlank()) return listOf(line)
+ val totalW = try { textFont.getStringWidth(line.sanitize()) / 1000f * textSize } catch (e: Exception) { 0f }
+ if (totalW <= maxW) return listOf(line)
+ val parts = mutableListOf()
+ val matcher = java.util.regex.Pattern.compile("\\S+\\s*").matcher(line)
+ while (matcher.find()) parts.add(matcher.group())
+ val result = mutableListOf()
+ var current = ""
+ parts.forEach { part ->
+ val candidate = current + part
+ val w = try { textFont.getStringWidth(candidate.sanitize()) / 1000f * textSize } catch (e: Exception) { 0f }
+ if (w <= maxW || current.isEmpty()) current = candidate
+ else { result.add(current.replace(Regex("\\s+$"), "")); current = part }
+ }
+ if (current.isNotEmpty()) result.add(current)
+ return result
+ }
+
+ fun naturalWidth(lines: List): Float =
+ lines.maxOfOrNull { line ->
+ try { textFont.getStringWidth(line.sanitize()) / 1000f * textSize } catch (e: Exception) { 0f }
+ } ?: 0f
+
+ fun stanzaHeight(wrappedLines: List): Float = lineH + wrappedLines.size * lineH + stanzaGapY
+
+ val availH = y - marginY
+ val allStanzaRaw = stanzasRemaining.map { sz -> sz to buildStanzaText(sz) }
+ val maxNatW = allStanzaRaw.maxOfOrNull { (_, lines) -> naturalWidth(lines) } ?: (usableWidth / 2)
+
+ val optimalCols: Int
+ val colW: Float
+ val innerW: Float
+
+ when (stanzasRemaining.size) {
+ 1 -> { optimalCols = 1; colW = usableWidth; innerW = colW - 10f }
+ 2 -> { optimalCols = 2; colW = usableWidth / 2f; innerW = colW - 10f }
+ else -> {
+ val best = (1..3).lastOrNull { nCols ->
+ val cWTest = usableWidth / nCols
+ val innerWTest = cWTest - 10f
+ val wrapped = allStanzaRaw.map { (sz, lines) -> sz to lines.flatMap { wrapLine(it, innerWTest) } }
+ val colHeights = Array(nCols) { 0f }
+ wrapped.forEachIndexed { idx, (_, wLines) -> colHeights[idx % nCols] += stanzaHeight(wLines) }
+ colHeights.all { it <= availH }
+ } ?: 3
+ optimalCols = best; colW = usableWidth / optimalCols; innerW = colW - 10f
+ }
+ }
+
+ val stanzaWrapped = allStanzaRaw.map { (sz, lines) -> sz to lines.flatMap { wrapLine(it, innerW) } }
+
+ data class ColItem(val sz: Int, val lines: List)
+ val rows2 = stanzaWrapped.chunked(optimalCols)
+ var rowStartY = y
+
+ rows2.forEach { rowItems ->
+ val rowH2 = rowItems.maxOfOrNull { (_, wLines) -> stanzaHeight(wLines) } ?: 0f
+ if (rowStartY - rowH2 < marginY) { cs.close(); cs = newPage(); rowStartY = pageHeight - marginY }
+
+ rowItems.forEachIndexed { colIdx, (sz, wrappedLines) ->
+ val colX = marginX + colIdx * colW
+ var colY = rowStartY
+ cs.beginText(); cs.setFont(textFont, textSize * 0.9f)
+ cs.newLineAtOffset(colX, colY); cs.showText("$sz."); cs.endText()
+ colY -= lineH
+ wrappedLines.forEach { line ->
+ cs.beginText(); cs.setFont(textFont, textSize)
+ cs.newLineAtOffset(colX + 10f, colY); cs.showText(line.sanitize()); cs.endText()
+ colY -= lineH
+ }
+ }
+ rowStartY -= (rowH2 + stanzaGapY)
+ }
+ y = rowStartY
+ }
+
+ cs.close()
+
+ val cleanTitle = songTitle.replace(" ", "_")
+ val totalUsableHeight = pageHeight - (2 * marginY)
+ val remainingHeight = (y - marginY).coerceAtLeast(0f)
+ val percentageUsed = 100f - (remainingHeight / totalUsableHeight) * 100
+ val percentageRemain = 100f - percentageUsed
+ val rStr = String.format("%.2f", percentageRemain).replace(",", ".")
+ val uStr = String.format("%.2f", percentageUsed).replace(",", ".")
+ val computedFileName = "${cleanTitle}_R_${rStr}_U_${uStr}.pdf"
+
+ val outputStream = ByteArrayOutputStream()
+ doc.save(outputStream)
+ doc.close()
+
+ return Pair(outputStream.toByteArray(), computedFileName)
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/composeResources/files/LinLibertine_R.ttf b/composeApp/src/commonMain/composeResources/files/LinLibertine_R.ttf
new file mode 100644
index 0000000..ab15444
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/LinLibertine_R.ttf differ
diff --git a/composeApp/src/commonMain/composeResources/files/PT Serif Bold Italic.ttf b/composeApp/src/commonMain/composeResources/files/PT Serif Bold Italic.ttf
new file mode 100644
index 0000000..da5a256
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/PT Serif Bold Italic.ttf differ
diff --git a/composeApp/src/commonMain/composeResources/files/PTSerif-Bold.ttf b/composeApp/src/commonMain/composeResources/files/PTSerif-Bold.ttf
new file mode 100644
index 0000000..36d47eb
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/PTSerif-Bold.ttf differ
diff --git a/composeApp/src/commonMain/composeResources/files/PTSerif-Regular.ttf b/composeApp/src/commonMain/composeResources/files/PTSerif-Regular.ttf
new file mode 100644
index 0000000..f87c0f1
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/PTSerif-Regular.ttf differ
diff --git a/composeApp/src/commonMain/composeResources/files/emmentaler-20.ttf b/composeApp/src/commonMain/composeResources/files/emmentaler-20.ttf
new file mode 100644
index 0000000..778067a
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/emmentaler-20.ttf differ
diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/FileRepository.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/FileRepository.kt
index 94e9757..358081a 100644
--- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/FileRepository.kt
+++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/FileRepository.kt
@@ -15,8 +15,10 @@ interface FileRepository {
suspend fun getOutputStream(filePath: String): OutputStream
//Lire le dernier dossier d'importation
suspend fun saveFile(filePath: String, data: ByteArray)
+ suspend fun saveLocalFile(filePath: String, data: ByteArray)
fun getFileName(shortName: String) : String
// suspend fun readFileBytes(filePath: String): ByteArray
+ fun pickSavePath(defaultName: String): String?
}
// This is just a regular class that implements the common 'FileRepository' interface.
diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/di/AppModule.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/di/AppModule.kt
index 6f81e6e..b83f5de 100644
--- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/di/AppModule.kt
+++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/di/AppModule.kt
@@ -15,7 +15,7 @@ val commonModule = module {
single { SharedScreenModel(fileRepository = get()) }
single { Solfa(get(), get()) }
- single { SolfaScreenModel(get()) }
+ single { SolfaScreenModel(get(), get()) }
single { MusicXML(get()) }
}
diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt
new file mode 100644
index 0000000..093dd36
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt
@@ -0,0 +1,19 @@
+package mg.dot.feufaro.pdf
+
+import kotlinx.coroutines.CoroutineScope
+import mg.dot.feufaro.FileRepository
+import mg.dot.feufaro.data.GridTUOData
+
+expect fun rememberPdfExportAction(
+ scope: CoroutineScope,
+ fileRepository: FileRepository,
+ gridData: GridTUOData,
+ songTitle: String,
+ measure: String,
+ stanza: Int,
+ nbStanza: Int,
+ songKey: String,
+ songAut: String,
+ songComp: String,
+ songRythm: String
+): () -> Unit
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt
index d83bef9..8e772ca 100644
--- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt
+++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt
@@ -746,6 +746,15 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
val override = O.getOrElse(overrideNumber) { null }
override?.forEachIndexed { index, value ->
val matchResult = Regex("v(\\d+):(.*)").find(value)
+ // 1. Extraire le numéro de voix (ex: "2")
+ val voiceNum = matchResult?.groupValues?.get(1) ?: ""
+ val prefix = if (voiceNum.isNotEmpty()) {
+ // .map transforme chaque caractère '3', '4' en "3.", "4."
+ // .joinToString("") les colle ensemble -> "3.4."
+ voiceNum.map { "$it." }.joinToString("")
+ } else {
+ ""
+ }
val overrideString = when (smartLyricsType) {
"E" -> smartELyrics(matchResult?.groupValues[2] ?: value)
"Y" -> smartYLyrics(matchResult?.groupValues[2] ?: value)
@@ -753,7 +762,11 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
}
for (iN in (1..9)) {
if (matchResult?.groupValues[1]?.contains(iN.digitToChar()) ?: false) {
- val overrideSyllabus = overrideString.split(Regex("[_/]")).iterator()
+ val overrideSyllabus = overrideString.split(Regex("[_/]"))
+ .map { syllable ->
+ if (syllable.isNotBlank()) "$prefix$syllable" else syllable
+ }
+ .iterator()
overrideIterator[iN] = overrideSyllabus
}
}
diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt
index b0e17eb..2eef60a 100644
--- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt
+++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt
@@ -755,6 +755,12 @@ fun LazyVerticalGridTUO(
horizontalArrangement = Arrangement.Start
) {
val columnWidthDp = gridWidthDp / gridColumnCount
+ val REGEX_CLEAN_PREFIX = Regex("(\\d+\\.)+")
+ val cleanAllTemps = allTemps.map { column ->
+ column.map { syllable ->
+ syllable.replace(REGEX_CLEAN_PREFIX, "").trim()
+ }
+ }
allTemps.forEachIndexed { syls_i, syllables ->
Column(
modifier = Modifier
@@ -763,7 +769,9 @@ fun LazyVerticalGridTUO(
val noteWidth = columnWidthDp
val density = LocalDensity.current
syllables.joinToString { " " }
- syllables.mapIndexed { index, syl ->
+
+ val cleanSyllables = cleanAllTemps[syls_i]
+ cleanSyllables.forEachIndexed { index, syl ->
var textWidth by remember { mutableStateOf(0.dp) }
@@ -777,7 +785,7 @@ fun LazyVerticalGridTUO(
val isTooLong = textWidthDp > containerWidthDp
val spacer = makeSpaceBetweenSyllables(textMeasurer, columnWidthDp)
- val (dynamicSpaceSyl, alignmentText) = spacer(syl, allTemps, syls_i, index)
+ val (dynamicSpaceSyl, alignmentText) = spacer(syl, cleanAllTemps, syls_i, index)
Text(
text = dynamicSpaceSyl,
modifier = Modifier
diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt
index 4951f23..59c2e46 100644
--- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt
+++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt
@@ -22,9 +22,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import mg.dot.feufaro.data.getDrawerItems
+import mg.dot.feufaro.pdf.rememberPdfExportAction
import mg.dot.feufaro.solfa.Solfa
import mg.dot.feufaro.viewmodel.SolfaScreenModel
@@ -47,6 +49,11 @@ fun MainScreenWithDrawer(
val filteredSongs by sharedScreenModel.filteredSongs.collectAsState()
val songKey = sharedScreenModel.songKey.collectAsState().value
val measure = sharedScreenModel.measure.collectAsState().value
+ val stanza = sharedScreenModel.stanza.collectAsState().value
+ val nbStanzas = sharedScreenModel.nbStanzas.collectAsState().value
+ val songAuthor = sharedScreenModel.songAuthor.collectAsState().value
+ val songComposer = sharedScreenModel.songComposer.collectAsState().value
+ val songRhythm = sharedScreenModel.songRhythm.collectAsState().value
val scrollState = rememberScrollState()
val currentActivePath = Solfa.currentFile
@@ -103,6 +110,20 @@ LaunchedEffect(isPlay, isPos) {
}
)
}, content = {
+ val scope = rememberCoroutineScope()
+ val pdfExportAction: () -> Unit = rememberPdfExportAction(
+ scope = scope,
+ fileRepository = solfaScreenModel.fileRepository,
+ gridData = sharedScreenModel.currentGridData,
+ songTitle = songTitle,
+ measure = measure,
+ stanza = stanza,
+ nbStanza = nbStanzas,
+ songKey = songKey,
+ songAut = songAuthor,
+ songComp = songComposer,
+ songRythm = songRhythm
+ )
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = {
TopAppBar(
modifier = Modifier.height(55.dp).windowInsetsPadding(WindowInsets.statusBars), title = {
@@ -176,7 +197,9 @@ LaunchedEffect(isPlay, isPos) {
exit = fadeOut() + scaleOut() + slideOutVertically { it / 2 }
) {
FloatingActionButton(
- onClick = {}, modifier = Modifier.alpha(0.45f)
+ onClick = {
+ pdfExportAction()
+ }, modifier = Modifier.alpha(0.45f)
) {
Icon(
imageVector = Icons.Filled.Print,
@@ -243,12 +266,12 @@ LaunchedEffect(isPlay, isPos) {
delay(100)
sharedScreenModel.setDragging(false)
}
- println("DrawerUI:335: mihetsika $newPos")
+ //println("DrawerUI:335: mihetsika $newPos")
},
mediaPlayer = player,
onVolumeChange = { newVolume ->
sharedScreenModel.setVolume(newVolume)
- println("Changement volume $newVolume -l $volumelevel")
+// println("Changement volume $newVolume -l $volumelevel")
},
onVoiceVolumeChange = { index, volume ->
player?.updateVoiceVolume(index, volume)
diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt
index 53b26a6..fc1eb0e 100644
--- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt
+++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.data.DrawerItem
+import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.data.getDrawerItems
import mg.dot.feufaro.solfa.TimeUnitObject
import mg.dot.feufaro.midi.FMediaPlayer
@@ -163,6 +164,12 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
private val _activeIndex = MutableStateFlow(-1)
val activeIndex: StateFlow = _activeIndex.asStateFlow()
+ val currentGridData: GridTUOData
+ get() = GridTUOData(
+ tuoList = _tuoList.value,
+ measure = _measure.value,
+ stanza = _stanza.value
+ )
fun updateActiveIndex(currentPosMs: Long) {
val currentPosMicros = currentPosMs * 1000
val index = _tuoTimestamps.value.indexOfLast { it <= currentPosMicros }
diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt
index 37e2c4a..2734baf 100644
--- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt
+++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt
@@ -1,9 +1,11 @@
package mg.dot.feufaro.viewmodel
import cafe.adriel.voyager.core.model.ScreenModel
+import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.solfa.Solfa
class SolfaScreenModel(
+ val fileRepository: FileRepository,
private val solfa: Solfa
) : ScreenModel {
init {}
diff --git a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/di/DesktopFileRepository.kt b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/di/DesktopFileRepository.kt
index 9717243..95569e2 100644
--- a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/di/DesktopFileRepository.kt
+++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/di/DesktopFileRepository.kt
@@ -4,6 +4,8 @@ import feufaro.composeapp.generated.resources.Res
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mg.dot.feufaro.FileRepository
+import java.awt.FileDialog
+import java.awt.Frame
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -46,9 +48,29 @@ class DesktopFileRepository : FileRepository { // IMPORTS AND IMPLEMENTS THE com
val file = File("$userHome/$filePath")
file.writeBytes(data) // Extension Kotlin très efficace
}
+ override suspend fun saveLocalFile(filePath: String, data: ByteArray) {
+ val file = if (filePath.startsWith("/")) {
+ File(filePath) // chemin absolu → utilisé directement
+ } else {
+ File(System.getProperty("user.home"), filePath) // chemin relatif → préfixé
+ }
+ file.parentFile?.mkdirs() // crée les dossiers parents si nécessaire
+ file.writeBytes(data) // Extension Kotlin très efficace
+ }
override fun getFileName(shortName: String): String {
val userHome = System.getProperty("user.home")
return "$userHome/$shortName"
}
+
+ override fun pickSavePath(defaultName: String): String? {
+ val dialog = FileDialog(null as Frame?, "Exporter en PDF", FileDialog.SAVE)
+ dialog.directory = System.getProperty("user.home")
+ dialog.file = defaultName
+ dialog.isVisible = true
+
+ return if (dialog.directory != null && dialog.file != null) {
+ File(dialog.directory, dialog.file).absolutePath
+ } else null
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt
new file mode 100644
index 0000000..5ded061
--- /dev/null
+++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/pdf/PdfExport.kt
@@ -0,0 +1,1752 @@
+package mg.dot.feufaro.pdf
+
+import feufaro.composeapp.generated.resources.Res
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import mg.dot.feufaro.FileRepository
+import mg.dot.feufaro.data.GridTUOData
+import mg.dot.feufaro.solfa.TimeUnitObject
+import org.apache.fontbox.ttf.TTFParser
+import org.apache.fontbox.ttf.TrueTypeFont
+import org.apache.pdfbox.io.RandomAccessReadBuffer
+import org.apache.pdfbox.pdmodel.PDDocument
+import org.apache.pdfbox.pdmodel.PDPage
+import org.apache.pdfbox.pdmodel.PDPageContentStream
+import org.apache.pdfbox.pdmodel.common.PDRectangle
+import org.apache.pdfbox.pdmodel.font.PDType0Font
+import org.apache.pdfbox.util.Matrix
+import java.awt.Color
+import java.awt.geom.GeneralPath
+import java.awt.geom.PathIterator
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.File as JFile
+
+
+private fun String.sanitize(): String = this
+ .replace('\u2015', '—')
+ .replace('\u2014', '—')
+ .replace('\u2013', '—')
+ .replace('―', '-') // ← tiret sol-fa → tiret court
+// .replace('\u2022', '.') // ← bullet → point
+ .replace('\u00A0', ' ')
+// .replace('₁', '1').replace('₂', '2').replace('₃', '3')
+// .replace('¹', '\'').replace('²', '"').replace('³', '"')
+ .trim()
+ .filter { it.code in 32..126 || it.code > 160 }
+
+/** Mesure la largeur d'un texte en points PDFBox */
+private fun textWidth(font: PDType0Font, fontSize: Float, text: String): Float {
+ return try {
+ font.getStringWidth(text.sanitize()) / 1000f * fontSize
+ } catch (e: Exception) { fontSize * text.length * 0.6f }
+}
+
+private val REGEX_PREFIX_DIGITS = Regex("(\\d+)\\.")
+private val REGEX_CLEAN = Regex("^(\\d+\\.)+")
+private val REGEX_HAS_LYRIC = Regex("[a-zA-Z0-9]")
+// On enlève "chiffre." n'importe où dans le texte
+private val REGEX_CLEAN_GLOBAL = Regex("\\d+\\.")
+
+// Fonction de nettoyage robuste
+private fun cleanSyllable(raw: String): String {
+ return raw.replace(REGEX_CLEAN_GLOBAL, "") // Enlève "4."
+ .replace(Regex("\\s+"), " ") // Remplace les espaces multiples par un seul
+ .trim() // Enlève les espaces aux extrémités
+}
+// si le préfixe contient UNIQUEMENT le chiffre "1"
+private fun prefixIsOnlyOne(rawSyl: String): Boolean {
+ val digits = REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1] }.toList()
+ return digits.size == 1 && digits[0] == "1"
+}
+
+private fun bestColWidthPt(
+ tuoList: List,
+ font: PDType0Font,
+ noteFontSize: Float,
+ lyricFont: PDType0Font,
+ lyricFontSize: Float
+): Float {
+ var maxNoteW = 0f
+ var maxLyrW = 0f
+
+ tuoList.forEach { tuo ->
+ tuo.noteAsMultiString()
+ .split("\n")
+ .forEach { line ->
+ val w = measureNoteWidth(font, noteFontSize, line)
+ if (w > maxNoteW) {
+ maxNoteW = w
+ // println("Not: [$line] = [$maxNoteW]")
+ }
+ }
+ //lyrics
+ (1..2).forEach { index ->
+ tuo.lyricsAsMultiString(index)
+ .split("\n")
+ .forEach { line ->
+ val trimmedLine = cleanSyllable(line)
+ val x = measureNoteWidth(font, lyricFontSize, trimmedLine)
+ if (x > maxLyrW) {
+ maxLyrW = x
+ // println("Lyr: [$trimmedLine] = [$maxLyrW]")
+ }
+ }
+ }
+ }
+ //La colonne fait la taille de la note + marge,
+ val minRequiredByLyrics = maxLyrW * 0.65f
+ // println("La taille est = [${maxOf(maxNoteW + 2f, minRequiredByLyrics)}]")
+ return maxOf(maxNoteW + 2f, minRequiredByLyrics)
+}
+
+/** Mesure la largeur exacte comme drawNoteWithSubscript */
+private fun measureNoteWidth(font: PDType0Font, fontSize: Float, text: String): Float {
+ var totalW = 0f
+ text.forEach { ch ->
+ totalW += try {
+ font.getStringWidth(ch.toString().sanitize()) / 1000f * fontSize
+ } catch (e: Exception) { 0f }
+ }
+ return totalW
+}
+/** Equivalent de makeSpaceBetweenSyllables pour PDFBox */
+private fun spacedSyllable(
+ currentSyl: String,
+ allTemps: List>,
+ sIdx: Int,
+ lineIdx: Int,
+ colWidthPt: Float,
+ font: PDType0Font,
+ fontSize: Float
+): Pair { // Pair
+ /*
+ ANCIENNEMENT BASÉE SUR LARGEUR LYRICS LES BESTCOLWIDTH
+ if (currentSyl.isEmpty()) return "" to true
+ val spaceW = textWidth(font, fontSize, " ").coerceAtLeast(1f)
+ val currentW = textWidth(font, fontSize, currentSyl)
+ val nextSyl = allTemps.getOrNull(sIdx + 1)?.getOrNull(lineIdx) ?: ""
+ val prevSyl = if (sIdx > 0) allTemps.getOrNull(sIdx - 1)?.getOrNull(lineIdx) ?: "" else ""
+ val excessPt = currentW - colWidthPt
+ val isExcess = excessPt > 0
+ val neededSpaces = if (isExcess) ((excessPt / spaceW).toInt() + 1).coerceAtMost(20) else 0
+ val totalSpacesPossible = (colWidthPt / spaceW).toInt()
+ val paddingNeeded = totalSpacesPossible - (currentSyl.length + 4).coerceIn(0, 20)
+
+ return when {
+ !isExcess && paddingNeeded > 0 ->
+ (currentSyl + " ".repeat(paddingNeeded)) to true
+ isExcess && nextSyl.length < prevSyl.length ->
+ (" ".repeat(neededSpaces) + currentSyl) to false
+ isExcess && nextSyl.length >= prevSyl.length ->
+ (currentSyl + " ".repeat(neededSpaces)) to true
+ else -> currentSyl to true
+ }*/
+ /*val clean = currentSyl.sanitize()
+ if (clean.isEmpty()) return "" to true
+
+ val currentW = textWidth(font, fontSize, clean)
+
+ // Si la syllabe est plus large que la colonne
+ if (currentW > colWidthPt) {
+ // On vérifie la syllabe suivante pour voir si on a de la place à droite
+ val nextSyl = allTemps.getOrNull(sIdx + 1)?.getOrNull(lineIdx) ?: ""
+
+ // Si la suivante est vide, on peut déborder à droite sans crainte
+ if (nextSyl.isEmpty()) return clean to true
+
+ // Sinon, on centre la syllabe sur la colonne pour équilibrer le débordement
+ // à gauche et à droite (ce qui réduit les collisions)
+ return clean to false // false ici signifiera "Centrer" dans notre logique de dessin
+ }
+
+ return clean to true*/
+
+ val clean = currentSyl.sanitize()
+ if (clean.isEmpty()) return "" to 0f
+
+ val currentW = textWidth(font, fontSize, clean)
+
+ // Si ça rentre, on ne décale pas (aligné à gauche sur la note)
+ if (currentW <= colWidthPt) return clean to 0f
+
+ val nextSyl = allTemps.getOrNull(sIdx + 1)?.getOrNull(lineIdx) ?: ""
+ val prevSyl = if (sIdx > 0) allTemps.getOrNull(sIdx - 1)?.getOrNull(lineIdx) ?: "" else ""
+
+ val nextEmpty = nextSyl.sanitize().trim().isEmpty()
+ val prevEmpty = prevSyl.sanitize().trim().isEmpty()
+
+ return when {
+ // Si place libre à droite : on reste à gauche, on déborde à droite
+ nextEmpty -> clean to 0f
+
+ // Si place libre à gauche : on se colle à droite, on déborde à gauche
+ prevEmpty -> clean to (colWidthPt - currentW)
+
+ // Si bloqué ou libre des deux côtés : on centre pour équilibrer
+ else -> clean to (colWidthPt - currentW) / 2f
+ }
+}
+
+
+
+private fun drawEmmentalerGlyphByName(
+ cs: PDPageContentStream,
+ ttf: TrueTypeFont,
+ glyphName: String,
+ x: Float,
+ y: Float,
+ size: Float = 14f
+) {
+ try {
+ val glyph = ttf.getGlyph()
+ val glyphTable = ttf.glyph ?: return
+ val nameTable = ttf.naming
+
+ // Cherche l'index du glyphe par nom
+ val glyphOrder = ttf.postScript?.glyphNames ?: return
+ val gid = glyphOrder.indexOfFirst { it == glyphName }
+ if (gid < 0) {
+ println("⚠️ Glyphe '$glyphName' non trouvé dans la font")
+ return
+ }
+
+ val glyphData = glyphTable.getGlyph(gid) ?: run {
+ println("⚠️ GlyphData null pour GID=$gid ('$glyphName')")
+ return
+ }
+
+ val path: GeneralPath = glyphData.getPath()
+
+ // 3. Récupère les métriques pour la mise à l'échelle
+ val unitsPerEm = ttf.unitsPerEm.toFloat()
+ val scale = size / unitsPerEm
+
+ // 4. Dessine le path dans le PDF
+ cs.saveGraphicsState()
+ cs.transform(Matrix(scale, 0f, 0f, -scale, x, y))
+ cs.setNonStrokingColor(0f, 0f, 0f)
+
+ val pi = path.getPathIterator(null)
+ val coords = FloatArray(6)
+ while (!pi.isDone) {
+ when (pi.currentSegment(coords)) {
+ PathIterator.SEG_MOVETO -> cs.moveTo(coords[0], coords[1])
+ PathIterator.SEG_LINETO -> cs.lineTo(coords[0], coords[1])
+ PathIterator.SEG_CUBICTO -> cs.curveTo(
+ coords[0], coords[1],
+ coords[2], coords[3],
+ coords[4], coords[5]
+ )
+ PathIterator.SEG_QUADTO -> {
+ // Quadratique → cubique (PDF ne supporte que cubique)
+ val cpX = coords[0]; val cpY = coords[1]
+ val endX = coords[2]; val endY = coords[3]
+ // Point courant inconnu ici → approximation directe
+ cs.curveTo(cpX, cpY, cpX, cpY, endX, endY)
+ }
+ PathIterator.SEG_CLOSE -> cs.closePath()
+ }
+ pi.next()
+ }
+ cs.fill()
+ cs.restoreGraphicsState()
+
+ } catch (e: Exception) {
+ println("⚠️ Erreur glyphe '$glyphName': ${e.message}")
+ }
+}
+
+fun getVirtualLineIndex(realLi: Int, spacings: List): Int {
+ var sum = realLi // La ligne de la note elle-même
+ for (i in 0 until realLi) {
+ sum += spacings[i] // On ajoute les espaces des voix au-dessus
+ }
+ return sum
+}
+
+
+private fun drawHairPinForTUO(
+ cs: PDPageContentStream,
+ tuo: TimeUnitObject,
+ gridColumnCount: Int,
+ colX: Float, colWidth: Float,
+ markerY: Float, // Y de la ligne des marqueurs (y dans ton code)
+ noteLineH: Float
+) {
+ val hairPinSymbol = tuo.hasHairPin() ?: return
+
+ if (hairPinSymbol != '=' && TimeUnitObject.lastHairPinSymbol == null) {
+ TimeUnitObject.startHairPin(hairPinSymbol, tuo.numBlock)
+ return
+ }
+
+ if (hairPinSymbol == '=' && TimeUnitObject.lastHairPinSymbol != null) {
+ val hairPinStart = TimeUnitObject.lastHairPinStart
+ val lastHairPinSymbol = TimeUnitObject.lastHairPinSymbol!!
+
+ // Même calcul que Compose (ligne de départ/arrivée)
+ val hairPinStartLine = (hairPinStart - 1) / gridColumnCount
+ val hairPinEndLine = (tuo.numBlock - 1) / gridColumnCount
+
+ // Optionnel : limite à une ligne (comme ton code commenté en Compose)
+ if (hairPinStartLine != hairPinEndLine) {
+ TimeUnitObject.endHairPin()
+ return
+ }
+
+ // X calculés comme en Compose (colsDelta = nb colonnes traversées)
+ val colsDelta = (tuo.numBlock - hairPinStart).toFloat()
+ val xStart = if (lastHairPinSymbol == '>') {
+ colX - colWidth * colsDelta // decrescendo : gauche → centre
+ } else {
+ colX + colWidth / 2f // crescendo : centre → gauche
+ }
+ val xEnd = if (lastHairPinSymbol == '>') {
+ colX + colWidth / 2f
+ } else {
+ colX - colWidth * colsDelta
+ }
+
+ // Y : au milieu de la zone marqueurs (haut + bas)
+ val markerMidY = markerY + noteLineH * 0.4f
+ val lineWidth = 1f
+ cs.setStrokingColor(0.2f, 0.2f, 0.2f)
+ cs.setLineWidth(lineWidth)
+
+ // Branche haute (oblique vers le centre)
+ cs.moveTo(xStart, markerMidY + noteLineH * 0.6f)
+ cs.lineTo(xEnd, markerMidY)
+ cs.stroke()
+
+ // Branche basse
+ cs.moveTo(xStart, markerMidY - noteLineH * 0.4f)
+ cs.lineTo(xEnd, markerMidY)
+ cs.stroke()
+
+ TimeUnitObject.endHairPin()
+ }
+}
+
+
+private fun drawTUOUnderlines(
+ cs: PDPageContentStream,
+ tuo: TimeUnitObject,
+ font: PDType0Font,
+ fontSize: Float,
+ colX: Float, // X de début de colonne (avant séparateur, comme le Box dans Compose)
+ y: Float, // Y baseline de la voix 1
+ noteLineH: Float,
+ colWidth: Float,
+ customSpacing: List
+) {
+ // noteAsMultiString() contient le préfixe séparateur (":", "|") pour ":" et "!"
+ val multiLineText = tuo.noteAsMultiString()
+ val noteLines = multiLineText.split("\n")
+
+ // Identique à Compose : separatorLength compense le char ":" ou "|" en début de ligne
+ val separatorLength = if (tuo.sep0 in listOf(":", "!")) 1 else 0
+
+ // leftMarginUnderline équivalent Compose (en pts ≈ dp dans ce contexte)
+ val leftMarginUnderline = when (tuo.sep0) {
+ "!" -> 4f
+ ":" -> 3f
+ "|" -> 4f
+ "/" -> 4f
+ else -> 0f
+ }
+
+ tuo.annotations().forEach { ta ->
+ val voiceLineIndex = ta.voiceNumber - 1 // voiceNumber est 1-indexé comme dans Compose
+ // --- CORRECTION ICI ---
+ // On récupère l'index virtuel pour correspondre à la position réelle de la note
+ val vLi = getVirtualLineIndex(voiceLineIndex, customSpacing)
+
+ // On calcule le Y en fonction de cet index virtuel
+ // On descend de 2f ou 3f sous la baseline pour ne pas toucher la lettre
+ val underlineY = y - (vLi * noteLineH) - 3f
+
+ val lineText = noteLines.getOrNull(voiceLineIndex) ?: return@forEach
+
+ ta.underlineSpec.forEach { us ->
+ // ── xStart ── (équivalent getCursorRect(lineGlobalStartOffset + us.x).left)
+ var xStart = if (us.x > -1) {
+ colX + measureNoteWidth(font, fontSize, lineText.take(us.x))
+ } else {
+ colX
+ }
+ if (us.isNewParen) xStart += leftMarginUnderline
+
+ // ── xEnd ── (équivalent getCursorRect(lineGlobalStartOffset + us.y + separatorLength + 1).right)
+ val xEnd = if (us.y > -1) {
+ val endIdx = (us.y + separatorLength + 1).coerceAtMost(lineText.length)
+ colX + measureNoteWidth(font, fontSize, lineText.take(endIdx))
+ } else {
+ colX + colWidth // us.y == -1 → jusqu'au bord droit de la colonne
+ }
+
+ // ── Y ── identique à Compose : voiceNumber * totalH / nbNotes → bas de la ligne de voix
+ // En PDFBox Y croît vers le haut donc on descend de 2f sous la baseline
+// val underlineY = y - voiceLineIndex * noteLineH - 2f
+
+ if (xEnd > xStart) {
+ cs.setStrokingColor(0.3f, 0.3f, 0.3f)
+ cs.setLineWidth(0.9f)
+ cs.moveTo(xStart, underlineY)
+ cs.lineTo(xEnd, underlineY)
+ cs.stroke()
+ }
+ }
+ }
+}
+
+private fun drawNoteWithModulation(
+ cs: PDPageContentStream,
+ font: PDType0Font,
+ fontSize: Float,
+ text: String,
+ x: Float,
+ y: Float
+) {
+ val parts = text.split(">")
+ var currentX = x
+
+ parts.forEachIndexed { index, part ->
+ if (part.isEmpty()) return@forEachIndexed
+
+ // RÈGLE : Si c'est avant le dernier segment, c'est un exposant
+ // Dans ">t>m", 't' est à l'index 1 (exposant), 'm' est à l'index 2 (normal)
+ // ATTENTION : selon votre logique, si c'est ">t>m", t=exposant, m=normal ?
+ // Ou bien tout ce qui suit le premier ">" est spécial ?
+
+ val isExposant = index == 1 // Le premier élément après le '>'
+
+ val currentSize = if (isExposant) fontSize * 0.7f else fontSize
+ val currentY = if (isExposant) y + (fontSize * 0.45f) else y
+
+ cs.beginText()
+ cs.setTextMatrix(Matrix.getTranslateInstance(currentX, currentY))
+ cs.setFont(font, currentSize)
+ cs.showText(part)
+ cs.endText()
+
+ currentX += textWidth(font, currentSize, part) + 0.5f
+ }
+}
+
+private fun drawNoteWithSubscript(
+ cs: PDPageContentStream,
+ font: PDType0Font,
+ fontSize: Float,
+ text: String,
+ x: Float,
+ y: Float
+) {
+ var curX = x
+ var i = 0
+ while (i < text.length) {
+ val ch = text[i]
+ val str = ch.toString()
+ cs.beginText()
+ cs.setFont(font, fontSize)
+ cs.newLineAtOffset(curX, y)
+ cs.showText(str.sanitize())
+ cs.endText()
+ curX += font.getStringWidth(str.sanitize()) / 1000f * fontSize
+// }
+ i++
+ }
+}
+
+// ── Header
+private fun drawHeader(
+ cs: PDPageContentStream,
+ noteFont: PDType0Font,
+ lyricFont: PDType0Font?,
+ pageWidth: Float, pageHeight: Float, marginX: Float, usableWidth: Float,
+ songTitle: String, songAuthor: String, songRhythm: String,
+ songComposer: String, songKey: String, measure: String,
+ stanza: Int, nbStanza: Int
+) {
+ val titleFont = lyricFont ?: noteFont
+ val normalFont = lyricFont ?: noteFont
+ val smallFont = noteFont
+
+ fun tw(f: PDType0Font, sz: Float, t: String) =
+ try { f.getStringWidth(t.sanitize()) / 1000f * sz } catch (e: Exception) { 0f }
+
+ // Ligne 1 — Titre centré
+ val titleSize = 15f
+ val titleW = tw(titleFont, titleSize, songTitle)
+ cs.beginText(); cs.setFont(titleFont, titleSize)
+ cs.newLineAtOffset(marginX + (usableWidth - titleW) / 2f, pageHeight - 48f)
+ cs.showText(songTitle.sanitize());
+ cs.endText()
+
+ // Ligne 2 — Author | Composer (droite)
+ val line2Y = pageHeight - 64f; val sz2 = 9f
+ cs.beginText(); cs.setFont(normalFont, sz2)
+ cs.newLineAtOffset(marginX, line2Y); cs.showText(songAuthor.sanitize()); cs.endText()
+
+ val composerW = tw(normalFont, sz2, songComposer)
+ cs.beginText();
+ cs.setFont(normalFont, sz2)
+ cs.newLineAtOffset(marginX + usableWidth - composerW, line2Y)
+ cs.showText(songComposer.sanitize());
+ cs.endText()
+
+ // Ligne 3 — Key | Measure | Rythm Stanza (droite)
+ val line3Y = pageHeight - 76f; val sz3 = 8f
+ val keyTxt = "Dô dia ${songKey.sanitize()}"
+ cs.beginText();
+ cs.setFont(smallFont, sz3)
+ cs.newLineAtOffset(marginX, line3Y); cs.showText(keyTxt);
+ cs.endText()
+
+ val keyW = tw(smallFont, sz3, keyTxt)
+ val measureTxt = "${measure.sanitize()}"
+ cs.beginText();
+ cs.setFont(smallFont, sz3)
+ cs.newLineAtOffset(marginX + keyW + 10f, line3Y)
+ cs.showText("${measure.sanitize()}");
+ cs.endText()
+
+ val rhythmW = tw(normalFont, sz3, songRhythm.sanitize())
+ cs.beginText(); cs.setFont(normalFont, sz2)
+ cs.newLineAtOffset(marginX + (usableWidth - rhythmW) / 2f, line3Y)
+ cs.showText(songRhythm.sanitize()); cs.endText()
+
+
+ // Ligne de séparation
+ cs.setStrokingColor(0.6f, 0.6f, 0.6f); cs.setLineWidth(0.4f)
+ cs.moveTo(marginX, line3Y - 5f); cs.lineTo(marginX + usableWidth, line3Y - 5f); cs.stroke()
+}
+
+
+
+actual fun rememberPdfExportAction(
+ scope: CoroutineScope,
+ fileRepository: FileRepository,
+ gridData: GridTUOData,
+ songTitle: String,
+ measure: String,
+ stanza: Int,
+ nbStanza: Int,
+ songKey: String,
+ songAut: String,
+ songComp: String,
+ songRythm: String
+): () -> Unit {
+ return {
+ scope.launch(Dispatchers.IO) {
+ // 1. Générer le PDF en mémoire (ByteArray)
+ val (pdfBytes, computedFileName) = generatePdfToBytes(
+ gridData, songTitle, measure, stanza,
+ nbStanza, songKey, songAut, songComp, songRythm
+ )
+ val chosenPath = withContext(Dispatchers.Main) {
+ fileRepository.pickSavePath(computedFileName)
+ }
+ if (chosenPath != null) {
+ fileRepository.saveLocalFile(chosenPath, pdfBytes)
+ }
+ }
+ }
+}
+
+private suspend fun generatePdfToBytes(
+ gridData: GridTUOData,
+ songTitle: String,
+ measure: String,
+ stanza: Int,
+ nbStanza: Int,
+ songKey: String,
+ songAut: String,
+ songComp: String,
+ songRythm: String
+): Pair {
+ val tuoList = gridData.tuoList.drop(1)
+
+ // "2.4.Mai" -> [2, 4]
+ val REGEX_PREFIX_DIGITS = Regex("(\\d+)\\.")
+
+ fun getVoicesFromPrefix(syl: String): List {
+ return REGEX_PREFIX_DIGITS.findAll(syl)
+ .map { it.groupValues[1].toInt() }
+ .toList()
+ }
+
+ val markerHeight = 16f
+ val noteFontSize = 12f
+ val lyricFontSize = 12.5f
+ val noteLineH = 17f // espace entre 2 voix
+ val lyricLineH = 15f // espace entre lignes de lyrics
+ val extraSylLineH = 15f // espace entre syl0 et syl1+ (cas zero spacing)
+ val adjustedMargin = 14f // espace entre syl0 du bloc de notes
+ val interRowGap = 22f // espace entre deux portées
+ val marginX = 42.5f
+ val marginY = 42.5f
+ val headerH = 55f // hauteur réservée au header
+ val pageWidth = PDRectangle.A4.width
+ val pageHeight = PDRectangle.A4.height
+ val usableWidth = pageWidth - 2 * marginX
+
+ // ── Polices ────────────────────────────────────────────────────
+ val noteFontBytes = Res.readBytes("files/PTSerif-Regular.ttf")
+ val lyricFontBytes = Res.readBytes("files/LinLibertine_R.ttf")
+ val markerItalicBoldFontBytes = Res.readBytes("files/PT Serif Bold Italic.ttf")
+ val markerBoldFontBytes = Res.readBytes("files/PTSerif-Bold.ttf")
+ val emmentalerFontBytes = Res.readBytes("files/emmentaler-20.ttf")
+
+ PDDocument().use { doc ->
+
+ // Charger les polices UNE FOIS dans le document
+ val customFont = PDType0Font.load(doc, ByteArrayInputStream(noteFontBytes), false)
+ val lyricFont = PDType0Font.load(doc, ByteArrayInputStream(lyricFontBytes), false)
+ val markerItalicFont = PDType0Font.load(doc, ByteArrayInputStream(markerItalicBoldFontBytes), false)
+ val markerBoldFont = PDType0Font.load(doc, ByteArrayInputStream(markerBoldFontBytes), false)
+ val markerEmmetFont = PDType0Font.load(doc, ByteArrayInputStream(emmentalerFontBytes), false)
+
+ val emmentalerTTF: TrueTypeFont = TTFParser().parse(
+ RandomAccessReadBuffer(emmentalerFontBytes)
+ )
+ // ── Calcul de la largeur optimale de colonne (= bestTUOWidth) ──
+ val bestColW = bestColWidthPt(
+ /*tuoList, customFont,
+ if (lyricFont is PDType0Font) lyricFont else customFont,
+ noteFontSize, lyricFontSize, stanza*/
+ tuoList,
+ customFont,
+ noteFontSize,
+ lyricFont,
+ lyricFontSize
+ )
+ val colCount = ((usableWidth / bestColW).toInt()).coerceAtLeast(1)
+ val colWidth = bestColW
+
+
+ val maxSylsSize = tuoList.maxOfOrNull { tuo -> // lyrics Alt
+ (1..nbStanza).maxOfOrNull { sz ->
+ tuo.getSingleSyllable(sz).size
+ } ?: 1
+ } ?: 1
+
+ // ── Fonctions de dessin ────────────────────────────────────
+ fun newPage(): PDPageContentStream {
+ val page = PDPage(PDRectangle.A4)
+ doc.addPage(page)
+ val cs = PDPageContentStream(doc, page)
+
+ return cs
+ }
+
+ fun drawCurlyBrace(cs: PDPageContentStream, x: Float, top: Float, bottom: Float) {
+ val height = top - bottom
+ val braceW = (height * 0.12f).coerceIn(8f, 22f)
+
+ cs.saveGraphicsState()
+ cs.transform(Matrix(
+ braceW / 10f, // scaleX
+ 0f,
+ 0f,
+ -(height / 90f), // scaleY (négatif = flip Y)
+ x - braceW, // translateX
+ top // translateY
+ ))
+ cs.setNonStrokingColor(0f, 0f, 0f)
+
+ cs.moveTo(2.5255237f, 42.511266f)
+ cs.curveTo(2.9018235f, 41.543703f, 2.9183988f, 40.479268f, 2.9295801f, 39.441167f)
+ cs.curveTo(3.0257633f, 30.51126f, 3.0823959f, 21.580072f, 2.947325f, 12.650669f)
+ cs.curveTo(2.9231886f, 11.055039f, 2.8933167f, 9.4523308f, 3.1035398f, 7.8704257f)
+ cs.curveTo(3.3137629f, 6.2885207f, 3.7758163f, 4.7150177f, 4.6625942f, 3.3882765f)
+ cs.curveTo(5.8680949f, 1.5846823f, 7.8548731f, 0.32344155f, 10f, 0f)
+ cs.curveTo(9.1651831f, 0.77722338f, 8.4802709f, 1.7148791f, 7.9937845f, 2.746541f)
+ cs.curveTo(6.9576584f, 4.9437899f, 6.8533308f, 7.4514513f, 6.8235522f, 9.8805609f)
+ cs.curveTo(6.7206706f, 18.272857f, 7.2905092f, 26.672179f, 6.8823909f, 35.055177f)
+ cs.curveTo(6.8167718f, 36.403033f, 6.7250316f, 37.755886f, 6.4343209f, 39.073653f)
+ cs.curveTo(6.1436102f, 40.39142f, 5.6454801f, 41.680731f, 4.8313656f, 42.756947f)
+ cs.curveTo(4.0971435f, 43.727549f, 3.1128448f, 44.507326f, 2f, 45f)
+ cs.curveTo(3.2050792f, 45.603993f, 4.2555169f, 46.513477f, 5.0257355f, 47.619726f)
+ cs.curveTo(5.9928965f, 49.008695f, 6.5043972f, 50.673120f, 6.7431543f, 52.348661f)
+ cs.curveTo(6.9819115f, 54.024202f, 6.9612618f, 55.724436f, 6.9478472f, 57.416849f)
+ cs.curveTo(6.8820492f, 65.718083f, 7.0018422f, 74.020174f, 6.9072398f, 82.321127f)
+ cs.curveTo(6.8879888f, 84.004806f, 6.8714598f, 85.749672f, 7.5929830f, 87.289708f)
+ cs.curveTo(8.0528414f, 88.422141f, 8.9242492f, 89.387018f, 10f, 90f)
+ cs.curveTo(8.1813551f, 89.702562f, 6.4820251f, 88.725349f, 5.3102118f, 87.3031f)
+ cs.curveTo(4.2259102f, 85.987066f, 3.606374f, 84.337657f, 3.2912749f, 82.661838f)
+ cs.curveTo(2.9761757f, 80.986019f, 2.9488582f, 79.270938f, 2.9359838f, 77.565801f)
+ cs.curveTo(2.869984f, 68.824508f, 3.1582519f, 60.082204f, 3.0067424f, 51.341975f)
+ cs.curveTo(2.9840763f, 50.034421f, 2.9431715f, 48.687654f, 2.4144109f, 47.491567f)
+ cs.curveTo(1.9369295f, 46.411476f, 1.0645415f, 45.51121f, 0f, 45f)
+ cs.curveTo(1.1412417f, 44.575325f, 2.0841488f, 43.646153f, 2.5255237f, 42.511266f)
+ cs.closePath()
+ cs.fill()
+
+ cs.restoreGraphicsState()
+ }
+
+
+ fun drawFermata(
+ cs: PDPageContentStream,
+ x: Float,
+ y: Float,
+ width: Float = 10f,
+ height: Float = 12f
+ ) {
+ cs.setStrokingColor(0f, 0f, 0f)
+ cs.setNonStrokingColor(0f, 0f, 0f)
+
+ // 1. Cercle (point en bas) - utiliser un cercle avec moveTo et curveTo
+ val circleRadius = width * 0.22f
+ val circleX = x + width / 2
+ val circleY = y + height * 0.25f
+
+ // Dessiner un cercle avec 4 courbes de Bézier
+ val k = 0.5522847498f // Constante pour approximer un cercle
+ val rx = circleRadius
+ val ry = circleRadius
+
+ cs.moveTo(circleX + rx, circleY)
+ cs.curveTo(circleX + rx, circleY + ry * k, circleX + rx * k, circleY + ry, circleX, circleY + ry)
+ cs.curveTo(circleX - rx * k, circleY + ry, circleX - rx, circleY + ry * k, circleX - rx, circleY)
+ cs.curveTo(circleX - rx, circleY - ry * k, circleX - rx * k, circleY - ry, circleX, circleY - ry)
+ cs.curveTo(circleX + rx * k, circleY - ry, circleX + rx, circleY - ry * k, circleX + rx, circleY)
+ cs.fill()
+
+ // 2. Arc supérieur (forme de sourcil)
+ cs.setLineWidth(1.5f)
+
+ val arcStartX = x + width * 0.2f
+ val arcStartY = y + height * 0.55f
+
+ val arcPeakX = x + width / 2
+ val arcPeakY = y + height * 0.85f
+
+ val arcEndX = x + width * 0.8f
+ val arcEndY = y + height * 0.55f
+
+ // Dessiner l'arc avec curveTo (courbe de Bézier cubique)
+ cs.moveTo(arcStartX, arcStartY)
+ cs.curveTo(
+ arcStartX + width * 0.15f, arcPeakY,
+ arcPeakX - width * 0.15f, arcPeakY,
+ arcPeakX, arcPeakY
+ )
+ cs.curveTo(
+ arcPeakX + width * 0.15f, arcPeakY,
+ arcEndX - width * 0.15f, arcStartY,
+ arcEndX, arcStartY
+ )
+ cs.stroke()
+ }
+
+ fun drawMarkers(
+ cs: PDPageContentStream,
+ tuo: TimeUnitObject,
+ colX: Float,
+ colWidth: Float,
+ y: Float,
+ boldFont: PDType0Font,
+ italicBoldFont: PDType0Font,
+ emmentalerFont: PDType0Font,
+ fontSize: Float,
+ emmentalerTTF: TrueTypeFont
+ ) {
+ val markerText = tuo.pTemplate.markerToString()
+ val hairPinSymbol = tuo.hasHairPin()
+ val isTriolet = tuo.isTriolet()
+
+ // Position Y pour les marqueurs (au-dessus des notes)
+ val textMarkerOffset = 6f
+ val markerY = y - markerHeight /2 + 4f + textMarkerOffset
+
+ // Point d'orgue
+ if (markerText.contains("𝄐") || tuo.pTemplate.template.contains("𝄐")) {
+ drawEmmentalerGlyphByName(
+ cs = cs,
+ ttf = emmentalerTTF,
+ glyphName = "scripts.dfermata",
+ x = colX + colWidth / 2 - 7f,
+ y = markerY - 2f,
+ size = 20f
+ )
+ }
+
+ // marqueur
+ if (markerText.isNotBlank() && !markerText.contains("𝄐")) {
+ val dsRegex = Regex("""D\.?S\.?""")
+ val dcRegex = Regex("""D\.?C\.?""")
+
+ cs.beginText()
+ if(markerText.matches(Regex("^\\s*(ppp|pp|p|mp|mf|f|ff|fff)\\s*$"))) {
+ cs.setFont(emmentalerFont, fontSize+10f)
+ }
+ else if (markerText.contains(dsRegex) ||
+ markerText.contains(dcRegex) ||
+ markerText.contains("$") ||
+ markerText.contains("Do dia", false)) {
+ cs.setFont(boldFont, fontSize)
+ }
+ else {
+ cs.setFont(italicBoldFont, fontSize)
+ }
+ cs.newLineAtOffset(colX + 2f, markerY)
+ cs.showText(markerText)
+ cs.endText()
+ }
+
+ // 2. Dessiner le triolet (arc)
+ if (isTriolet) {
+ val arcX = colX + colWidth * 0.125f
+ val arcWidth = colWidth * 0.75f
+ val arcHeight = 7f
+ cs.setLineWidth(1.2f)
+
+ // Dessiner un arc (approximation avec curveTo)
+ cs.moveTo(arcX, markerY + 2f)
+ cs.curveTo(
+ arcX + arcWidth * 0.25f, markerY + arcHeight,
+ arcX + arcWidth * 0.75f, markerY + arcHeight,
+ arcX + arcWidth, markerY + arcHeight
+ )
+ cs.stroke()
+
+ // Ajouter le "3" du triolet
+ cs.beginText()
+ cs.setFont(boldFont, fontSize * 0.7f)
+ cs.newLineAtOffset(arcX + arcWidth * 0.45f, markerY + arcHeight * 0.85f)
+ cs.showText("3")
+ cs.endText()
+ }
+ }
+
+
+
+
+
+ // ── Génération des pages ───────────────────────────────────
+ var cs = newPage()
+ var y = pageHeight - marginY - headerH
+
+ drawHeader(
+ cs, customFont, lyricFont as? PDType0Font,
+ pageWidth, pageHeight, marginX, usableWidth,
+ songTitle = songTitle,
+ songAuthor = songAut,
+ songRhythm = songRythm,
+ songComposer = songComp,
+ songKey = songKey,
+ measure = measure,
+ stanza = stanza,
+ nbStanza = nbStanza
+ )
+
+ val rows = tuoList.chunked(colCount)
+
+ rows.forEachIndexed { rowIdx, rowTuos ->
+ val maxNoteLines = (rowTuos.maxOfOrNull { it.noteAsMultiString().split("\n").size } ?: 1) + 1
+ val maxLyricLines = rowTuos.maxOfOrNull { it.getSingleSyllable(stanza).size } ?: 0
+
+ val rowHasMarkers = rowTuos.any { tuo ->
+ tuo.pTemplate.markerToString().isNotBlank() ||
+ tuo.isTriolet() ||
+ tuo.hasHairPin() != null
+ }
+
+ val noteBlockH = maxNoteLines * noteLineH
+ val nbStanzasToShow = if (maxSylsSize >= 2) 1 else if (nbStanza >= 2) 2 else 1
+ val lyricBlockH = maxLyricLines * lyricLineH * nbStanzasToShow
+ val rowH = (if (rowHasMarkers) markerHeight else 0f) + noteBlockH + lyricBlockH + 10f * nbStanzasToShow + 8f // esp entre parole et note suivante
+
+ // Vérification de la place sur la page
+ if (y - rowH < marginY) {
+ cs.close()
+ cs = newPage()
+ y = pageHeight - marginY/* - headerH*/
+ }
+
+ // Point de départ des notes (en tenant compte des marqueurs)
+ val notesStartY = if (rowHasMarkers) y - markerHeight else y
+
+ // On regarde sur toute la ligne quelles voix (0,1,2,3) ont des notes
+ val pitchPattern = "\\b(d|di|r|ri|m|mi|f|fi|s|si|l|la|t|ta)\\b".toRegex()
+ fun singingVoiceIndices(tuo: TimeUnitObject): List {
+ return tuo.noteAsMultiString().split("\n").mapIndexedNotNull { li, line ->
+ val clean = line.trim()
+ // On considère que ça "chante" si ce n'est pas vide,
+ // pas un simple tiret de prolongation, et que ça contient une note (do, re, mi...)
+ val sings = clean.isNotBlank()
+ && !clean.all { it == '-' || it.isWhitespace() }
+ && clean != "—" && clean != "–"
+ && pitchPattern.containsMatchIn(clean)
+ if (sings) li else null
+ }
+ }
+
+
+ val spacings = MutableList(4) { 0 }
+
+ rowTuos.forEach { tuo ->
+ val singing = singingVoiceIndices(tuo)
+ val rawSyls = tuo.getSingleSyllable(stanza)
+
+ // 1. Analyse du Soprano (syl0)
+ val rawSyl0 = rawSyls.getOrNull(0) ?: ""
+ val cleanSyl0 = rawSyl0.replace(REGEX_CLEAN, "").trim()
+ val syl0HasRealText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) &&
+ !cleanSyl0.all { it == '―' || it == '-' || it == '—' }
+ val syl0EstVraimentVide = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
+ cleanSyl0.all { it == '―' || it == '-' || it == '—' }
+
+ // NOUVEAU : Est-ce qu'une autre ligne (syl1, syl2...) contient un préfixe "1." ?
+ val uneAutreVoixPrendLeSoprano = (1..4).any { vIdx ->
+ val s = rawSyls.getOrNull(vIdx) ?: ""
+ s.contains("1.") && REGEX_HAS_LYRIC.containsMatchIn(s.replace(REGEX_CLEAN, ""))
+ }
+
+ // La condition finale pour savoir si on considère qu'il y a du texte au niveau "Soprano"
+ val aDuTexteAuNiveauSoprano = !syl0EstVraimentVide || uneAutreVoixPrendLeSoprano
+
+
+ (1..4).forEach { vIdx ->
+ val rawSyl = rawSyls.getOrNull(vIdx) ?: ""
+
+ // --- NOUVEAU FILTRE : ON IGNORE LES TIRETS MÊME AVEC PRÉFIXE ---
+ // val cleanContent = rawSyl.replace(REGEX_CLEAN, "").trim()
+ val cleanContent = cleanSyllable(rawSyl)
+ val hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanContent) &&
+ !cleanContent.all { it == '―' || it == '-' || it == '—' }
+
+ if (rawSyl.isNotBlank() && hasActualText) {
+ val prefixInts = REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList()
+
+ if (prefixInts.isNotEmpty()) {
+ val hasTrioATB = prefixInts.size == 3 && prefixInts.containsAll(listOf(2, 3, 4))
+ val hasDuoSA = prefixInts.size == 2 && prefixInts.containsAll(listOf(1, 2))
+ val hasDuoAT = prefixInts.size == 2 && prefixInts.containsAll(listOf(2, 3))
+ val hasDuoTB = prefixInts.size == 2 && prefixInts.containsAll(listOf(3, 4))
+ val isUnisson = prefixInts.containsAll(listOf(1, 2, 3, 4))
+
+ // Logique simplifiée pour le calcul des spacings (Duo consécutif)
+ if (prefixInts.contains(2) && prefixInts.contains(3)) {
+ // C'est un duo Alto-Ténor -> On ouvre l'espace APRES le Ténor
+ spacings[2] = 1
+ spacings[1] = 0 // On force l'Alto à 0 pour éviter les doublons
+ }
+ if (isUnisson) {
+ // Unisson : Rien
+ }
+ else if (hasTrioATB) {
+ spacings[3] = 1
+ }
+ else if (hasDuoSA) {
+ spacings[1] = 1
+ }
+ /*else if (hasDuoAT) {
+ spacings[1] = 0
+ spacings[2] = 1
+ }*/
+ else if (hasDuoTB) {
+ if (syl0HasRealText) { spacings[1] = 1 }
+ spacings[3] = 1
+ }
+ else {
+ // Cas Standard (Individuel)
+ prefixInts.forEach { voiceNum ->
+ when (voiceNum) {
+ 1 -> if (syl0HasRealText) spacings[0] = 1
+ 2 -> if (!prefixInts.contains(1)) spacings[1] = 1
+ 3 -> if (!prefixInts.contains(2)) spacings[2] = 1
+ 4 -> if (!prefixInts.contains(3)) spacings[3] = 1
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val customSpacings = spacings.toList()
+
+
+ /*if (rowIdx == 7 || (rowIdx == 8)) {
+ println("╠══════════════════════════════════════════════════════════════╣")
+ println("║ customSpacings calculés : $customSpacings ")
+ println("╚══════════════════════════════════════════════════════════════╝")
+
+ rowTuos.forEachIndexed { colIdx, tuo ->
+ val rawSyls = tuo.getSingleSyllable(stanza)
+ val singing = singingVoiceIndices(tuo)
+
+ println("\n➔ COLONNE [$colIdx] | Notes actives: $singing")
+
+ rawSyls.forEachIndexed { lineIdx, raw ->
+ if (raw.isNotBlank()) {
+ val clean = cleanSyllable(raw)
+ val prefixes = REGEX_PREFIX_DIGITS.findAll(raw).map { it.groupValues[1].toInt() }.toList()
+ val hasText = REGEX_HAS_LYRIC.containsMatchIn(clean) && !clean.all { it == '―' || it == '-' }
+
+ print(" [Syl $lineIdx] Brute: \"$raw\"")
+ if (lineIdx > 0) {
+ print(" | Clean: \"$clean\" | Prefixes: $prefixes")
+
+ if (prefixes.isEmpty()) {
+ print(" -> ⚠️ IGNORÉ (Pas de préfixe)")
+ } else if (!hasText) {
+ print(" -> ⚠️ IGNORÉ (Que des tirets/vides)")
+ } else {
+ // Simulation du placement
+ val targets = prefixes.map { it - 1 }
+ val validTargets = targets.filter { customSpacings.getOrElse(it) { 0 } == 1 }
+ if (validTargets.isEmpty()) {
+ print(" -> ❌ REJETÉ (Aucun espace ouvert dans customSpacings pour $targets)")
+ } else {
+ print(" -> ✅ PLACÉ dans slots: $validTargets")
+ }
+ }
+ } else {
+ print(" -> (Soprano Standard)")
+ }
+ println()
+ }
+ }
+ }
+ println("\n" + "═".repeat(64))
+ }*/
+
+ // println("\n[FIN SCAN ROW $rowIdx]")
+ // println(" → Voix uniques détectées (ordre d'apparition) : $originalList")
+ // println(" → Séquence conservée : $voicesToSpace")
+ // println(" → CustomSpacings final pour ce Row : $customSpacings")
+ // println("[DEBUG] Original: $originalList | Sequence: $voicesToSpace | Result: $customSpacings")
+
+
+ // ---- 1. Marqueurs (si présents) ----
+ if (rowHasMarkers) {
+ rowTuos.forEachIndexed { colIdx, tuo ->
+ val x = marginX + colIdx * colWidth
+ drawMarkers(cs, tuo, x, colWidth, y, markerBoldFont, markerItalicFont, markerEmmetFont, noteFontSize, emmentalerTTF)
+
+ // ➕ AJOUT ICI : hairpins (même boucle, mêmes coords !)
+ drawHairPinForTUO(
+ cs = cs,
+ tuo = tuo,
+ gridColumnCount = colCount, // tu as déjà colCount
+ colX = x,
+ colWidth = colWidth,
+ markerY = y, // ton y actuel
+ noteLineH = noteLineH
+ )
+ }
+ }
+
+ // val customSpacings = listOf(1, 1, 1, 0) // 1 ligne vide après chaque voix
+
+ // ---- 2. Accolade et étiquettes SATB ----
+ val braceTop = notesStartY + noteLineH * 0.85f
+ // val braceBottom = notesStartY - (maxNoteLines - 1) * noteLineH
+
+ val lastVoiceIdx = (maxNoteLines - 1).coerceIn(0, 3)
+ val totalVirtualSlots = getVirtualLineIndex(lastVoiceIdx, customSpacings) + 1
+ val braceBottom = notesStartY - (totalVirtualSlots - 1) * noteLineH
+ drawCurlyBrace(cs, marginX, braceTop, braceBottom)
+
+ val lineBottom = braceBottom
+
+ val voiceLabels = listOf("S", "A", "T", "B")
+ val labelFont = if (lyricFont is PDType0Font) lyricFont else customFont
+ val labelSize = noteFontSize * 0.85f
+ // Largeur réelle de l'accolade (même formule que dans drawCurlyBrace)
+ val braceW = ((braceTop - braceBottom) * 0.12f).coerceIn(8f, 22f)
+ val labelX = marginX - braceW - 3f
+
+ voiceLabels.take(maxNoteLines).forEachIndexed { li, label ->
+ val vLi = getVirtualLineIndex(li, customSpacings)
+ val labelY = notesStartY - (vLi * noteLineH)
+ // val labelY = notesStartY - li * noteLineH
+ val labelColor = when (label) {
+ "S" -> Color(180, 0, 0)
+ "A" -> Color(0, 120, 0)
+ "T" -> Color(0, 0, 180)
+ "B" -> Color(0, 100, 120)
+ else -> Color.BLACK
+
+ }
+ cs.saveGraphicsState()
+ cs.beginText()
+ cs.setFont(labelFont, labelSize)
+ cs.setNonStrokingColor(labelColor)
+ cs.newLineAtOffset(labelX - textWidth(labelFont, labelSize, label), labelY)
+ cs.showText(label)
+ cs.endText()
+ cs.restoreGraphicsState()
+ }
+
+ // ---- 3. Notes ----
+ // Calcul des positions des barres verticales (communes à toute la rangée)
+ val lineTop = notesStartY + noteLineH * 0.85f
+ // val lineBottom = notesStartY - (totalVirtualSlots - 1) * noteLineH
+
+ rowTuos.forEachIndexed { colIdx, tuo ->
+ val x = marginX + colIdx * colWidth
+
+ val sepW = textWidth(customFont, noteFontSize, "|")
+ val noteX = when (tuo.sep0) {
+ "/", "|" -> {
+ cs.setStrokingColor(0f, 0f, 0f); cs.setLineWidth(0.8f)
+ cs.moveTo(x + sepW * 0.2f, lineTop)
+ cs.lineTo(x + sepW * 0.2f, lineBottom); cs.stroke()
+ if (tuo.sep0 == "/") {
+ cs.moveTo(x + sepW * 0.7f, lineTop)
+ cs.lineTo(x + sepW * 0.7f, lineBottom); cs.stroke()
+ }
+ x + sepW + 2f
+ }
+ else -> x
+ }
+
+
+ val noteLines = tuo.noteAsMultiString().split("\n")
+
+ // println("\n=== ROW0 col[$colIdx] sep0='${tuo.sep0}' noteX=$noteX ===")
+ // println(" noteAsMultiString raw = ${tuo.noteAsMultiString().replace("\n", "\\n")}")
+ // println(" noteLines.size = ${noteLines.size}")
+ noteLines.forEachIndexed { li, line ->
+ val isSilence = line.isBlank() ||
+ !pitchPattern.containsMatchIn(line)
+ // println(" line[$li] = '$line' isSilence=$isSilence y=${notesStartY - li * noteLineH}")
+ }
+ val syls = tuo.getSingleSyllable(stanza)
+ // println(" getSingleSyllable($stanza) = $syls")
+ // println(" notesStartY=$notesStartY noteLineH=$noteLineH")
+
+ val nonSilencePositions = mutableListOf()
+ noteLines.forEachIndexed { li, line ->
+ val vLi = getVirtualLineIndex(li, customSpacings)
+ val currentY = notesStartY - (vLi * noteLineH)
+
+ if (line.isNotBlank() && pitchPattern.containsMatchIn(line)) {
+ nonSilencePositions.add(li)
+ }
+ if (line.isNotBlank()) {
+ // ICI : Utilisation de la nouvelle fonction pour gérer les ">"
+ if (line.contains(">")) {
+ drawNoteWithModulation(cs, customFont, noteFontSize, line, noteX, currentY)
+ } else {
+ drawNoteWithSubscript(cs, customFont, noteFontSize, line, noteX, currentY)
+ }
+ }
+ // Pour écrire dans l'espace vide JUSTE en dessous de cette voix :
+ val spaceCount = customSpacings.getOrElse(li) { 0 }
+ for (s in 1..spaceCount) {
+ val emptySlotY = notesStartY - ((vLi + s) * noteLineH)
+ // C'est ici que vous pourrez insérer vos textes additionnels plus tard
+ }
+
+ // if (line.isBlank()) return@forEachIndexed
+ // drawNoteWithSubscript(cs, customFont, noteFontSize, line, noteX, notesStartY - li * noteLineH)
+ }
+ // println(" nonSilencePositions = $nonSilencePositions")
+
+ drawTUOUnderlines(
+ cs = cs,
+ tuo = tuo,
+ font = customFont,
+ fontSize = noteFontSize,
+ colX = x,
+ y = notesStartY,
+ noteLineH = noteLineH,
+ colWidth = colWidth,
+ customSpacing = customSpacings
+ )
+ }
+
+ // ---- 4. Paroles ----
+ // Calcul du vLi de la toute dernière ligne de basse + son espace vide associé
+ val lastVoiceIndex = (maxNoteLines - 1).coerceIn(0, 3)
+ val lastVirtualIndex = getVirtualLineIndex(lastVoiceIndex, customSpacings)
+ // Si vous voulez que les paroles soient SOUS l'espace vide de la basse :
+ val totalAreaSlots = lastVirtualIndex + customSpacings[lastVoiceIndex]
+ // La position Y réelle du bas de l'accolade
+ val finalBraceBottom = notesStartY - (totalAreaSlots * noteLineH)
+
+ val stanzasToShow = if (maxSylsSize >= 2) listOf(1) else if (nbStanza >= 2) listOf(1, 2) else listOf(1)
+
+ val verticalMargin = 25f // Espace de sécurité entre l'accolade et les paroles
+ // val finalBraceBottom = braceBottom // On l'a calculé plus haut
+ // On calcule le point le plus bas pour la prochaine ligne
+ var lowestYOfThisRow = finalBraceBottom
+
+ stanzasToShow.forEachIndexed { stanzaIdx, currentStanza ->
+
+ // val yLyric = finalBraceBottom - verticalMargin - stanzaIdx * (lyricLineH * (maxLyricLines + 0.3f))//+espace
+ val nbOverrideLines = customSpacings.sum()
+ //val adjustedMargin = 13.5f // On réduit la marge de base (25f) pour rapprocher syl0 du bloc de notes
+ val yLyric = finalBraceBottom - adjustedMargin - (nbOverrideLines * noteLineH * 0.2f) // espace entre syl0
+
+ // Décalage vertical pour le 2ème couplet s'il existe
+ val yLyricOffset = stanzaIdx * (lyricLineH * 1.1f)
+ val currentYLyric = yLyric - yLyricOffset
+
+ val allTemps: List> = rowTuos.map { it.getSingleSyllable(currentStanza) }
+ val maxLinesThisStanza = allTemps.maxOfOrNull { it.size } ?: 1
+
+ // ── DEBUG ─────────────────────────────────────────────────────
+// println("\n╔══ ROW[$rowIdx] STANZA[$currentStanza] ══════════════════════════")
+// println("║ currentYLyric = $currentYLyric")
+// println("║ maxLines = $maxLinesThisStanza")
+// println("║ lyricLineH = $lyricLineH")
+
+ // Numéro de stanza
+ val stanzaNumTxt = "$currentStanza."
+ val stanzaNumSize = lyricFontSize * 0.85f
+ val stanzaNumFont = if (lyricFont is PDType0Font) lyricFont else customFont
+ val stanzaNumW = try {
+ stanzaNumFont.getStringWidth(stanzaNumTxt) / 1000f * stanzaNumSize
+ } catch (e: Exception) { 0f }
+
+ cs.beginText()
+ cs.setFont(stanzaNumFont, stanzaNumSize)
+ cs.setNonStrokingColor(Color.BLACK)
+ cs.newLineAtOffset(marginX - stanzaNumW - 4f, currentYLyric)
+ cs.showText(stanzaNumTxt)
+ cs.endText()
+
+ // Syllabes
+ /*allTemps.forEachIndexed { sIdx, syllables ->
+ val x = marginX + sIdx * colWidth
+ val noteX = if (rowTuos.getOrNull(sIdx)?.sep0 in listOf("/", "|")) x + 3f else x
+
+ syllables.forEachIndexed { lineIdx, syl ->
+ val (spacedSyl, alignLeft) = spacedSyllable(
+ syl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize
+ )
+ val lyricX = if (alignLeft) noteX
+ else noteX + colWidth - textWidth(customFont, lyricFontSize, spacedSyl)
+
+ cs.beginText()
+ cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize)
+ cs.newLineAtOffset(lyricX, yLyric - lineIdx * lyricLineH)
+ cs.showText(spacedSyl.sanitize())
+ cs.endText()
+ }
+ }*/
+ fun drawInOverrideSlot(
+ cs: PDPageContentStream,
+ targetIdx: Int, // 0=S, 1=A, 2=T, 3=B, ou -1 pour le remplacement direct de syl0
+ cleanSyl: String,
+ sIdx: Int,
+ lineIdx: Int,
+ allTemps: List>,
+ noteX: Float,
+ colWidth: Float,
+ customFont: PDType0Font,
+ lyricFontSize: Float,
+ notesStartY: Float, // Le point de référence (soit notesStartY pour SATB, soit yLyric pour le S)
+ noteLineH: Float,
+ customSpacings: List,
+ isDirectY: Boolean = false // Si true, on ignore le calcul de vLi et on utilise notesStartY direct
+ ) {
+ val lyricColor = when (targetIdx) {
+ -1, 0 -> Color(180, 0, 0) // Soprano (Remplacement ou standard)
+ 1 -> Color(0, 120, 0) // Alto
+ 2 -> Color(0, 0, 180) // Ténor
+ 3 -> Color(0, 100, 120) // Basse
+ else -> Color.BLACK // Par défaut (sécurité)
+ }
+ // 1. Calcul du Y final
+ val finalY = if (isDirectY) {
+ notesStartY // On se pose directement sur yLyric
+ } else {
+ // Calcul standard pour les espaces vides entre les notes
+ val vLi = getVirtualLineIndex(targetIdx, customSpacings)
+ notesStartY - ((vLi + 1) * noteLineH) + 2f
+ }
+
+ // 2. Calcul de l'alignement horizontal
+ val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
+ val lyricX = noteX + offsetX /*if (alignLeft) {
+ noteX // Alignement gauche sur la note
+ } else {
+ // CENTRAGE : On calcule le milieu de la colonne et on retire la moitié de la largeur du texte
+ val txtW = textWidth(customFont, lyricFontSize, spacedSyl)
+ noteX + (colWidth / 2f) - (txtW / 2f)
+ }*/
+ // val lyricX = if (alignLeft) noteX else noteX + colWidth - textWidth(customFont, lyricFontSize, spacedSyl)
+
+ // 3. Dessin
+ cs.saveGraphicsState()
+ cs.beginText()
+ cs.setFont(customFont, lyricFontSize * 0.9f)
+ cs.setNonStrokingColor(lyricColor) // Définit la couleur de remplissage du texte
+ cs.newLineAtOffset(lyricX, finalY)
+ cs.showText(spacedSyl.sanitize())
+ cs.endText()
+ cs.restoreGraphicsState()
+ }
+
+
+ allTemps.forEachIndexed { sIdx, syllables ->
+ val x = marginX + sIdx * colWidth
+ val noteX = if (rowTuos.getOrNull(sIdx)?.sep0 in listOf("/", "|")) x + 3f else x
+
+ // On suit quels slots (0,1,2,3) ont déjà reçu du texte pour CETTE colonne
+ val filledSlots = mutableSetOf()
+
+ val isStandardLayout = customSpacings.all { it == 0 }
+
+ syllables.forEachIndexed { lineIdx, rawSyl ->
+ val expectedY = currentYLyric - lineIdx * lyricLineH
+// println("║ col[$sIdx] syl[$lineIdx] '$rawSyl' → Y=$expectedY")
+
+ if (lineIdx == 0) {
+ // SYL0 : On dessine normalement en bas (comportement standard)
+ val (spacedSyl, offsetX) = spacedSyllable(rawSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
+ // val lyricX = if (alignLeft) noteX else noteX + colWidth - textWidth(customFont, lyricFontSize, spacedSyl)
+ val lyricX = noteX + offsetX /*if (alignLeft) noteX else noteX + (colWidth / 2f) - (textWidth(customFont, lyricFontSize, spacedSyl) / 2f)*/
+
+ cs.beginText()
+ cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize)
+ cs.newLineAtOffset(lyricX, currentYLyric)
+ cs.showText(spacedSyl.sanitize())
+ cs.endText()
+ } else {
+ // 1. D'abord, on vérifie si c'est un Solo Soprano "1." même si spacing est à 0
+ val prefixes = REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList()
+ val rawSyl0 = syllables.getOrNull(0) ?: ""
+ val cleanSyl0 = cleanSyllable(rawSyl0)
+ val cleanSyl = cleanSyllable(rawSyl)
+
+ val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
+ cleanSyl0.all { it == '―' || it == '-' || it == '—' }
+
+ val isAllZeroSpacing = customSpacings.all { it == 0 }
+
+ if (prefixes.contains(1) && isAllZeroSpacing && lignePrincipaleEstLibre) {
+ // FORCE LE DESSIN SUR LA LIGNE PRINCIPALE (SYL0)
+ val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
+ val lyricX = noteX + offsetX /*if (alignLeft) noteX else noteX + (colWidth / 2f) - (textWidth(customFont, lyricFontSize, spacedSyl) / 2f)*/
+
+ cs.beginText()
+ cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize)
+ cs.newLineAtOffset(lyricX, currentYLyric)
+ cs.showText(spacedSyl.sanitize())
+ cs.endText()
+
+ } else if (isAllZeroSpacing) {
+ // ── CAS SIMPLE : pas d'espace entre notes → syl1+ descend sous syl0 ──
+ val cleanSyl = cleanSyllable(rawSyl)
+ if (cleanSyl.isNotBlank()) {
+ val thisLineY = currentYLyric - lineIdx * extraSylLineH
+
+ val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
+ val lyricX = noteX + offsetX /*if (alignLeft) noteX else noteX + (colWidth / 2f) - (textWidth(customFont, lyricFontSize, spacedSyl) / 2f)*/
+
+ cs.beginText()
+ cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize)
+ cs.setNonStrokingColor(Color.BLACK)
+ cs.newLineAtOffset(lyricX, thisLineY) // thisLineY = currentYLyric - lineIdx * lyricLineH
+ cs.showText(spacedSyl.sanitize())
+ cs.endText()
+ }
+ } else { // --- OVERRIDES (SYL 1, 2, 3...) ---
+ val prefixes =
+ REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList()
+ val cleanSyl = cleanSyllable(rawSyl)
+
+ val rawSyl0 = syllables.getOrNull(0) ?: ""
+ val cleanSyl0 = cleanSyllable(rawSyl0)
+ val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
+ cleanSyl0.all { it == '―' || it == '-' || it == '—' }
+
+
+ val hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl) &&
+ !cleanSyl.all { it == '―' || it == '-' }
+ val syl0EstVraimentVide = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl) ||
+ cleanSyl.all { it == '―' || it == '-' || it == '—' }
+
+ if (prefixes.isNotEmpty() && hasActualText) {
+ prefixes.forEach { voiceNum ->
+ val targetIdx = voiceNum - 1 // 0=S, 1=A, 2=T, 3=B
+
+ // --- CAS SPÉCIAL : LE PRÉFIXE "1." (SOPRANO) ---
+ if (voiceNum == 1) {
+ // A. PRIORITÉ : Si la ligne principale est libre, on s'installe dessus
+ if (lignePrincipaleEstLibre && !filledSlots.contains(-1)) {
+ drawInOverrideSlot(
+ cs, -1, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth,
+ customFont, lyricFontSize, currentYLyric, noteLineH, customSpacings,
+ isDirectY = true // Pose sur yLyric
+ )
+ filledSlots.add(-1)
+ }
+ // B. REPLI : Si la ligne 0 est occupée, on regarde si un espace (1) est ouvert dans cS
+ else if (customSpacings.getOrElse(0) { 0 } == 1 && !filledSlots.contains(0)) {
+ drawInOverrideSlot(
+ cs,
+ 0,
+ cleanSyl,
+ sIdx,
+ lineIdx,
+ allTemps,
+ noteX,
+ colWidth,
+ customFont,
+ lyricFontSize,
+ notesStartY - yLyricOffset,
+ noteLineH,
+ customSpacings
+ )
+ filledSlots.add(0)
+ }
+ } else {
+ // --- CAS GÉNÉRAL (Alto 2, Ténor 3, Basse 4) ---
+ if (customSpacings.getOrElse(targetIdx) { 0 } == 1 && !filledSlots.contains(
+ targetIdx
+ )) {
+ drawInOverrideSlot(
+ cs, targetIdx, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth,
+ customFont, lyricFontSize, notesStartY, noteLineH, customSpacings
+ )
+ filledSlots.add(targetIdx)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+// println("╚══════════════════════════════════════════════════════════")
+
+ val isAllZeroSpacing = customSpacings.all { it == 0 }
+ val maxExtraLines = if (isAllZeroSpacing) {
+ allTemps.maxOfOrNull { sylList ->
+ sylList.count { rawSyl ->
+ val clean = cleanSyllable(rawSyl)
+ REGEX_HAS_LYRIC.containsMatchIn(clean) &&
+ !clean.all { it == '―' || it == '-' || it == '—' }
+ }.coerceAtLeast(1)
+ } ?: 1
+ } else {
+ 1
+ }
+ // Le vrai bas = currentYLyric - (toutes les lignes sauf syl0) * lyricLineH
+ val bottomOfCurrentStanza = currentYLyric - ((maxExtraLines - 1) * extraSylLineH) - 4f
+
+
+ // MAJ du point le plus bas : on retire la hauteur de la ligne de texte
+// val bottomOfCurrentStanza = currentYLyric - (lyricLineH * 0.5f)
+ if (bottomOfCurrentStanza < lowestYOfThisRow) {
+ lowestYOfThisRow = bottomOfCurrentStanza
+ }
+ // val bottomOfStanza = yLyric - (maxLyricLines * lyricLineH)
+ // if (bottomOfStanza < lowestYOfThisRow) lowestYOfThisRow = bottomOfStanza
+ }
+ y = lowestYOfThisRow - interRowGap
+ }
+
+ // ── Stanzas restantes en texte __ 1.et2. seulement
+ val stanzasDisplayed = if (maxSylsSize >= 2) {
+ listOf(1)
+ } else {
+ if (nbStanza >= 2) listOf(1, 2) else listOf(1)
+ }
+ val stanzasRemaining = (1..nbStanza).filter { it !in stanzasDisplayed }
+
+ if (stanzasRemaining.isNotEmpty()) {
+ val textFont = if (lyricFont is PDType0Font) lyricFont else customFont
+ val textSize = lyricFontSize
+ val lineH = lyricLineH
+ val stanzaGapY = 12f
+ val numColsMax = 3 // max colonnes
+
+ // Ligne séparatrice
+ y -= 7f
+ if (y < marginY) { cs.close(); cs = newPage(); y = pageHeight - marginY/* - headerH*/ }
+ cs.setStrokingColor(0.5f, 0.5f, 0.5f); cs.setLineWidth(0.3f)
+ cs.moveTo(marginX, y + 5f); cs.lineTo(marginX + usableWidth, y + 5f); cs.stroke()
+ y -= 6f
+ // y -= 14f
+
+ // ── 1. Reconstruit lignes de texte d'une stanza ───────────────────
+ fun buildStanzaText(sz: Int): List {
+ // println("\n══ buildStanzaText(stanza=$sz) ══")
+
+ val normalLyrics = mutableListOf>()
+ val allDcLyrics = mutableListOf>()
+
+ // 1. Collecte TOUS
+ tuoList.forEachIndexed { tuoIdx, tuo ->
+ val syls = tuo.getSingleSyllable(sz)
+ val normal = syls.getOrNull(0)/*?.trim()*/ ?: ""
+ val dcPart = syls.drop(1).joinToString(" ") { it.trim() }
+
+ if (normal.isNotEmpty()) {
+ normalLyrics.add(tuoIdx to normal)
+ // println(" NORMAL[$tuoIdx]: '$normal'")
+ }
+ if (dcPart.isNotEmpty()) {
+ allDcLyrics.add(tuoIdx to dcPart)
+ // println(" DC[$tuoIdx]: '$dcPart'")
+ }
+ if (tuo.pTemplate.markerToString().contains("DC")) {
+ // println(" 🎯 DC MARQUEUR[$tuoIdx]")
+ }
+ }
+
+ val result = StringBuilder()
+ var dcIndex = 0
+ var inDcBlock = false
+
+ // 2. PARCOURS TOUS les TUO (pas seulement normalLyrics)
+ tuoList.forEachIndexed { tuoIdx, tuo ->
+ val syls = tuo.getSingleSyllable(sz)
+ val normal = syls.getOrNull(0)?.trim() ?: ""
+ val dcPart = syls.drop(1).joinToString(" ") { it.trim() }
+
+
+ // ✅ 2e : NORMAL (si existe)
+ if (normal.isNotEmpty()) {
+ result.append(normal).append(" ")
+ // println(" → AJOUT NORMAL[$tuoIdx]: '$normal'")
+ }
+ // ✅ 1er : CROCHET DC (TOUJOURS, même si normal vide)
+ if (dcPart.isNotEmpty()) {
+ result.append("[$dcPart] ")
+ // println(" 🟦 CROCHET DC[$tuoIdx]: '[$dcPart]'")
+ }
+
+ // 3. DC MARQUEUR (parenthèses)
+ val marker = tuo.pTemplate.markerToString()
+ if (marker.contains("DC") && !inDcBlock && allDcLyrics.isNotEmpty()) {
+ result.append("(")
+ // println(" 🎯 OUVERTURE ( à [$tuoIdx]")
+
+ while (dcIndex < allDcLyrics.size) {
+ val (dcIdx, dcText) = allDcLyrics[dcIndex]
+ if (dcIdx > tuoIdx) break
+ result.append(dcText).append(" ")
+ // println(" → AJOUT DC[$dcIdx] DANS (")
+ dcIndex++
+ }
+
+ result.append(")")
+ // println(" 🎯 FERMETURE )")
+ inDcBlock = false
+ }
+ }
+
+ val fullText = result.toString().trim()
+ // println(" → FINAL: '$fullText'")
+ return listOf(fullText)
+ }
+
+ // ── 2. Wrap une ligne selon largeur max ───────────────────────────
+ fun wrapLine(line: String, maxW: Float): List {
+ if (line.isBlank()) return listOf(line)
+
+ val totalW = try { textFont.getStringWidth(line.sanitize()) / 1000f * textSize }
+ catch (e: Exception) { 0f }
+
+ if (totalW <= maxW) return listOf(line)
+
+ // On coupe par bloc de mot + ses espaces suivants
+ // La regex (\s+) capture les espaces pour les garder dans le résultat du split
+ val parts = mutableListOf()
+ val matcher = java.util.regex.Pattern.compile("\\S+\\s*").matcher(line)
+ while (matcher.find()) {
+ parts.add(matcher.group())
+ }
+
+ val result = mutableListOf()
+ var current = ""
+
+ parts.forEach { part ->
+ val candidate = current + part
+ val w = try { textFont.getStringWidth(candidate.sanitize()) / 1000f * textSize }
+ catch (e: Exception) { 0f }
+
+ if (w <= maxW || current.isEmpty()) {
+ current = candidate
+ } else {
+ result.add(current.replace(Regex("\\s+$"), "")) // On retire l'espace de fin de ligne
+ current = part
+ }
+ }
+ if (current.isNotEmpty()) result.add(current)
+ return result
+ }
+
+ // ── 3. Calcule la largeur naturelle max d'une stanza ─────────────
+ fun naturalWidth(lines: List): Float =
+ lines.maxOfOrNull { line ->
+ try { textFont.getStringWidth(line.sanitize()) / 1000f * textSize }
+ catch (e: Exception) { 0f }
+ } ?: 0f
+
+ // ── 4. Calcule la hauteur d'une stanza (avec header "N.") ────────
+ fun stanzaHeight(wrappedLines: List): Float =
+ lineH + wrappedLines.size * lineH + stanzaGapY
+
+ // ── 5. Détermine le nombre optimal de colonnes ───────────────────
+ // Logique : trouve le min de colonnes pour tenir sur 1 page
+ val availH = y - marginY
+
+ val allStanzaRaw = stanzasRemaining.map { sz -> sz to buildStanzaText(sz) }
+
+ // Largeur naturelle max → détermine colWidth candidat
+ val maxNatW = allStanzaRaw.maxOfOrNull { (_, lines) -> naturalWidth(lines) } ?: (usableWidth / 2)
+
+ // Essaie ncols de 1 à numColsMax, prend le plus grand qui tient
+ fun fitsInOnePage(nCols: Int): Boolean {
+ val cW = usableWidth / nCols
+ val innerW = cW - 10f
+ // Wrap toutes les stanzas
+ val wrapped = allStanzaRaw.map { (sz, lines) ->
+ sz to lines.flatMap { wrapLine(it, innerW) }
+ }
+ // Répartit équitablement : remplissage colonne par colonne
+ // puis vérifie que chaque colonne tient dans availH
+ val colHeights = Array(nCols) { 0f }
+ wrapped.forEachIndexed { idx, (_, wLines) ->
+ val col = idx % nCols
+ colHeights[col] += stanzaHeight(wLines)
+ }
+ return colHeights.all { it <= availH }
+ }
+
+ // ── 5. Colonnes selon nb de stanzas restantes ────────────────────────
+ val optimalCols: Int
+ val colW: Float
+ val innerW: Float
+
+ when (stanzasRemaining.size) {
+ 1 -> {
+ // Pleine largeur, pas d'optimisation
+ optimalCols = 1
+ colW = usableWidth
+ innerW = colW - 10f
+ }
+ 2 -> {
+ // Moitié chacune, pas d'optimisation
+ optimalCols = 2
+ colW = usableWidth / 2f
+ innerW = colW - 10f
+ }
+ else -> {
+ // 3+ : cherche le meilleur nb de colonnes (max 3) pour tenir sur 1 page
+ val best = (1..3).lastOrNull { nCols ->
+ val cWTest = usableWidth / nCols
+ val innerWTest = cWTest - 10f
+ val wrapped = allStanzaRaw.map { (sz, lines) ->
+ sz to lines.flatMap { wrapLine(it, innerWTest) }
+ }
+ val colHeights = Array(nCols) { 0f }
+ wrapped.forEachIndexed { idx, (_, wLines) ->
+ colHeights[idx % nCols] += stanzaHeight(wLines)
+ }
+ colHeights.all { it <= availH }
+ } ?: 3
+
+ optimalCols = best
+ colW = usableWidth / optimalCols
+ innerW = colW - 10f
+ }
+ }
+
+ // ── 6. Wrap final avec colW optimal ──────────────────────────────
+ val stanzaWrapped = allStanzaRaw.map { (sz, lines) ->
+ sz to lines.flatMap { wrapLine(it, innerW) }
+ }
+
+ // ── 7. Répartition équilibrée dans les colonnes ──────────────────
+ // Algorithme : remplit colonne par colonne, change de col quand débordement
+ // ── 7. Répartition ROW par ROW (gauche→droite, ligne par ligne) ──
+ data class ColItem(val sz: Int, val lines: List)
+
+ // Découpe en rangées de optimalCols stanzas
+ val rows2 = stanzaWrapped.chunked(optimalCols)
+
+ // ── 8. Dessin row par row ─────────────────────────────────────────
+ var rowStartY = y
+
+ rows2.forEach { rowItems ->
+ // Hauteur max de cette rangée
+ val rowH2 = rowItems.maxOfOrNull { (_, wLines) -> stanzaHeight(wLines) } ?: 0f
+
+ // Nouvelle page si la rangée entière ne rentre pas
+ if (rowStartY - rowH2 < marginY) {
+ cs.close(); cs = newPage()
+ rowStartY = pageHeight - marginY/* - headerH*/
+ }
+
+ // Dessine chaque stanza de la rangée dans sa colonne
+ rowItems.forEachIndexed { colIdx, (sz, wrappedLines) ->
+ val colX = marginX + colIdx * colW
+ var colY = rowStartY
+
+ // Numéro de stanza
+ cs.beginText()
+ cs.setFont(textFont, textSize * 0.9f)
+ cs.newLineAtOffset(colX, colY)
+ cs.showText("$sz.")
+ cs.endText()
+ colY -= lineH
+
+ // Lignes
+ wrappedLines.forEach { line ->
+ cs.beginText()
+ cs.setFont(textFont, textSize)
+ cs.newLineAtOffset(colX + 10f, colY)
+ cs.showText(line.sanitize())
+ cs.endText()
+ colY -= lineH
+ }
+ }
+
+ // Avance y après la rangée la plus haute
+ rowStartY -= (rowH2 + stanzaGapY)
+ }
+ y = rowStartY
+ }
+ cs.close()
+
+ val outputDir = System.getProperty("user.home") + "/Documents"
+ JFile(outputDir).mkdirs()
+
+ val totalUsableHeight = pageHeight - (2 * marginY)
+ val remainingHeight = (y - marginY).coerceAtLeast(0f)
+ val percentageRemaining = (remainingHeight / totalUsableHeight) * 100
+ val percentageUsed = 100f - percentageRemaining
+ val rStr = String.format("%.2f", percentageRemaining).replace(",", ".")
+ val uStr = String.format("%.2f", percentageUsed).replace(",", ".")
+
+
+ val cleanTitle = songTitle.replace(" ", "_")
+ /*val fileName = "${cleanTitle}_R_${rStr}_U_${uStr}.pdf"//$fileName
+ val outputPath = "$outputDir/"
+
+ doc.save(outputPath)
+ doc.close()
+ val os = System.getProperty("os.name").lowercase()
+ when {
+ os.contains("linux") -> Runtime.getRuntime().exec(arrayOf("xdg-open", outputDir))
+ os.contains("mac") -> Runtime.getRuntime().exec(arrayOf("open", outputDir))
+ os.contains("win") -> Runtime.getRuntime().exec(arrayOf("explorer", outputDir))
+ }
+ emmentalerTTF.close()
+ println("✅ PDF : $outputPath")*/
+ val computedFileName = "${cleanTitle}_R_${rStr}_U_${uStr}.pdf"
+
+ // Sérialiser en mémoire
+ val outputStream = ByteArrayOutputStream()
+ doc.save(outputStream)
+ doc.close()
+ emmentalerTTF.close()
+ return Pair(outputStream.toByteArray(), computedFileName)
+ }
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 6cd9c2d..43013ed 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -13,6 +13,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven { url = uri("https://jitpack.io") }
}
}