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.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<TimeUnitObject>,
    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<List<String>>,
    sIdx: Int,
    lineIdx: Int,
    colWidthPt: Float,
    font: PDType0Font,
    fontSize: Float
): Pair<String, Float> { // Pair<texte, alignLeft>
    /*
    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>): 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<Int>
) {
    // 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
//            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()
        when(ch) {
            '₁', '₂', '₃', '¹', '²', '³' -> {
                cs.setFont(font, fontSize * 0.6f)
            } else -> {
                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?,
    bolderFont: PDType0Font?,
    emmentalerfFont: PDType0Font?,
    pageWidth: Float, pageHeight: Float, marginX: Float, marginY: 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
    val emmentalerFont = emmentalerfFont ?: noteFont
    val boldFont = bolderFont ?: noteFont

    fun tw(f: PDType0Font, sz: Float, t: String) =
        try { f.getStringWidth(t.sanitize()) / 1000f * sz } catch (e: Exception) { 0f }

    // ── Y calculés depuis marginY au lieu de valeurs fixes ────────────────────
    val titleY = pageHeight - marginY - 5f
    val line2Y = pageHeight - marginY - 21f
    val line3Y = pageHeight - marginY - 33f
    val sepY   = line3Y - 5f

    // 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, titleY)
    cs.showText(songTitle.sanitize())
    cs.endText()

    // Ligne 2 — Author | Composer (droite)
    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 | Rhythm
    val sz3 = 10f
    val keyTxt = "Dô dia "
    cs.beginText();
    cs.setFont(smallFont, sz3)
    cs.newLineAtOffset(marginX, line3Y);
    cs.showText(keyTxt);
    cs.endText()

    val keyW_2 = tw(smallFont, sz3, keyTxt)
    val keyTxt2 = "${songKey.sanitize()}"
    cs.beginText(); cs.setFont(boldFont, sz3+2f)
    cs.newLineAtOffset(marginX + keyW_2 + 3f, line3Y);
    cs.showText(keyTxt2);
    cs.endText()

    val keyW = tw(boldFont, sz3, keyTxt+keyTxt2) + 10f
    val sz32 = 14f
    val splitMeaRgx = """(\d+/\d+)\s+(.*)""".toRegex()
    val matchRes = splitMeaRgx.matchEntire(measure)
    var timeSignature = ""
    var titleSignature = ""
    if(matchRes != null) {
        timeSignature = matchRes.groupValues[1]
        titleSignature = matchRes.groupValues[2]
    }
    val timesig = timeSignature.toCharArray()

    var ksW = keyW
    timesig.forEach { c ->
        cs.beginText();
        if(c == '/') {
            cs.setFont(normalFont, sz32)
            cs.newLineAtOffset(marginX + ksW + 10f, line3Y)
            ksW += tw(normalFont, sz32, c.toString())
        } else {
            cs.setFont(emmentalerFont, sz32)
            cs.newLineAtOffset(marginX + ksW + 10f, line3Y)
            ksW += tw(emmentalerFont, sz32, c.toString())
        }
        cs.showText(c.toString());
        cs.endText()
    }
    cs.beginText();
    cs.setFont(smallFont, sz3)
    cs.newLineAtOffset(marginX + ksW + 15f, line3Y)
    cs.showText("${titleSignature}");
    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, sepY);
    cs.lineTo(marginX + usableWidth, sepY);
    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
): (PrintSettings) -> Unit {
    return { settings: PrintSettings ->
        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,
                settings
            )
            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,
    settings: PrintSettings = defaultPrintSettings()
): Pair<ByteArray, String> {
    val tuoList = gridData.tuoList.drop(1)

    // "2.4.Mai" -> [2, 4]
    val REGEX_PREFIX_DIGITS = Regex("(\\d+)\\.")

    fun getVoicesFromPrefix(syl: String): List<Int> {
        return REGEX_PREFIX_DIGITS.findAll(syl)
            .map { it.groupValues[1].toInt() }
            .toList()
    }

    val markerHeight   = settings. markerHeight
    val noteFontSize   = settings.noteFontSize
    val lyricFontSize  = settings.lyricFontSize
    val noteLineH      = settings.noteLineH
    val lyricLineH     = settings.lyricLineH
    val extraSylLineH  = settings.extraSylLineH
    val adjustedMargin = settings.adjustedMargin
    val interRowGap    = settings.interRowGap
    val marginX        = settings.marginX
    val marginY        = settings.marginY
    val headerH        = settings.headerH
    val pageWidth      = settings.pageWidth
    val pageHeight     = settings.pageHeight
    val usableWidth    = pageWidth - 2 * marginX
    val customNbGrid   = settings.nbGridOnMeasure

    // ── 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,
            noteFontSize,
            lyricFont,
            lyricFontSize
        )
        val colCount = if (customNbGrid == 0) {
            ((usableWidth / bestColW).toInt()).coerceAtLeast(1)
        } else {
            customNbGrid
        }
        val finalColWidth = usableWidth / colCount
        //println("ici Colc est $colCount")
        val colWidth = finalColWidth


        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(settings.pdRectangle)
            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, markerBoldFont, markerEmmetFont,
            pageWidth, pageHeight, marginX, marginY, usableWidth,
            songTitle      = songTitle,
            songAuthor     = songAut,
            songRhythm     = songRythm,
            songComposer   = songComp,
            songKey        = songKey,
            measure        = measure,
            stanza         = stanza,
            nbStanza       = nbStanza
        )

        val allRows = tuoList.chunked(colCount)
        // si dernier portée n'a qu'une séparateur pas de note on dessine sur l'avant dernier
        val hasTrailingBarlineRow = allRows.size > 1 &&
                allRows.last().size == 1 &&
                allRows.last().first().noteAsMultiString().trim().isEmpty() &&
                allRows.last().first().sep0 in listOf("|", "/")

        val rows = if (hasTrailingBarlineRow) allRows.dropLast(1) else allRows

        rows.forEachIndexed { rowIdx, rowTuos ->
//            println("Portée n°: $rowIdx: && rs ${rows.size}")
            val isLastVisualRow = rowIdx == rows.size - 1
            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

            val pitchPattern = "\\b(d|di|r|ri|m|mi|f|fi|s|si|l|la|t|ta)\\b".toRegex()
            fun singingVoiceIndices(tuo: TimeUnitObject): List<Int> {
                return 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)

                // 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 == '—' }

                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 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 hasTrioATB = prefixInts.size == 3 && prefixInts.containsAll(listOf(2, 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 (hasDuoTB) {
                                spacings[3] = 1
                            }
                            else {
                                // Cas Standard (Individuel)
                                prefixInts.forEach { voiceNum ->
                                    when (voiceNum) {
                                        1 -> if (syl0HasRealText && prefixInts.contains(1)) 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 == 2 || (rowIdx == 2)) {
                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[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,
                        colX = x,
                        colWidth = colWidth,
                        markerY = y,
                        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

                // on dessine  à droite
                if (hasTrailingBarlineRow && isLastVisualRow && colIdx == rowTuos.size - 1) {
                    val finalX = x + colWidth
                    val sepToDraw = allRows.last().first().sep0

                    cs.setStrokingColor(0f, 0f, 0f)
                    cs.setLineWidth(0.8f)

                    val sepW = textWidth(customFont, noteFontSize, "|")
                    cs.moveTo(finalX - sepW, lineTop)
                    cs.lineTo(finalX - sepW, lineBottom)
                    cs.stroke()

                    if (sepToDraw == "/") {
                        cs.moveTo(finalX - (sepW * 0.5f), lineTop)
                        cs.lineTo(finalX - (sepW * 0.5f), lineBottom)
                        cs.stroke()
                    }
                }


                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}' note=${noteLines[0]} ===")
    //                            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<Int>()
                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<List<String>> = 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
                var hasLyrics = false
                allTemps.forEachIndexed { sIdx, syllables ->
                    syllables.forEachIndexed { lineIdx, syl ->
                        if(syl.isNotEmpty()) hasLyrics=true
                    }
                }

//                println("hasL? = $hasLyrics")
                if(hasLyrics) {
                    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()

                    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<List<String>>,
                        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<Int>,
                        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<Int>()

                        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 syl0LyricsIsFree = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
                                        cleanSyl0.all { it == '―' || it == '-' || it == '—' }

                                val isAllZeroSpacing = customSpacings.all { it == 0 }

                                if (prefixes.contains(1) && isAllZeroSpacing && syl0LyricsIsFree) {
                                    println("DEBUG: Force SYL1 sur ligne principale (Rouge) -> $cleanSyl")

                                    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.saveGraphicsState()
                                    cs.beginText()
                                    cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize)
                                    cs.setNonStrokingColor(Color.RED)
                                    cs.newLineAtOffset(lyricX, currentYLyric)
                                    cs.showText(spacedSyl.sanitize())
                                    cs.endText()
                                    cs.restoreGraphicsState()

                                } 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 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) {
                                                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<String> {
    //                        println("\n══ buildStanzaText(stanza=$sz) ══")

                val normalLyrics = mutableListOf<Pair<Int, String>>()
                val allDcLyrics = mutableListOf<Pair<Int, String>>()

                // 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() }


                    // NORMAL (si existe)
                    if (normal.isNotEmpty()) {
                        result.append(normal).append(" ")
                    }
                    // CROCHET DC (TOUJOURS, même si normal vide)
                    if (dcPart.isNotEmpty()) {
                        // dcPart
                        result.append("[${cleanSyllable(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<String> {
                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<String>()
                val matcher = java.util.regex.Pattern.compile("\\S+\\s*").matcher(line)
                while (matcher.find()) {
                    parts.add(matcher.group())
                }

                val result = mutableListOf<String>()
                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<String>): 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<String>): 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<String>)

    // 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)
    }
}