Parcourir la source

fix(reader): preserve Latin word boundaries when wrapping

ViqUey il y a 1 mois
Parent
commit
d47ded0313

+ 62 - 26
app/src/main/java/io/legado/app/feature/reader/core/layout/ChineseLineBreaker.kt

@@ -29,7 +29,6 @@ class ChineseLineBreaker(
     private fun breakLines() {
         if (clusters.isEmpty()) return
         var lineWidth = 0f
-        var previousWidth = 0f
         var textLength = 0
         clusters.forEachIndexed { index, cluster ->
             val currentWidth = widthsPx[index]
@@ -40,6 +39,18 @@ class ChineseLineBreaker(
             var hungLine = false
             val currentWidthLimit = if (widths.isEmpty()) firstWidthLimit else widthLimit
             if (lineWidth > currentWidthLimit) {
+                val lineStart = clusterStarts.last()
+                fun carryFrom(candidate: Int) {
+                    val start = safeBreakStart(candidate, lineStart)
+                    val carriedRange = start..index
+                    carriedWidth = carriedRange.sumOf { widthsPx[it].toDouble() }.toFloat()
+                    carriedCharacters = carriedRange.sumOf { clusters[it].length }
+                    carriedClusters = carriedRange.count()
+                    addStart(
+                        character = textLength - carriedCharacters + cluster.length,
+                        cluster = start,
+                    )
+                }
                 // 旧 ZhLayout 把行尾标点的处置分成两类:可压缩的窄标点回退到更早的合法边界
                 // (BREAK_MORE_CHAR),全角标点则直接悬挂在本行右边界之外(CPS_1/2/3:
                 // `offset = 0f`,行宽允许超过 width)。下面三个判定与旧版逐条对应。
@@ -72,19 +83,14 @@ class ChineseLineBreaker(
                     else -> Mode.NORMAL
                 }
                 var rewindClusters = 0
-                var rewindCharacters = 0
                 // 可压缩标点会把收尾标点留到下一行行首,必须回退到更早的非标点边界
                 val needsRecheck = mode == Mode.PULL_PREVIOUS && (previousClosing || nextClosing)
                 if (needsRecheck && index > 2) {
                     val lineStart = if (widths.isEmpty()) indentCharacters else clusterStarts.last()
                     mode = Mode.NORMAL
                     for (candidate in index downTo lineStart + 1) {
-                        if (candidate == index) {
-                            previousWidth = 0f
-                        } else {
+                        if (candidate != index) {
                             rewindClusters++
-                            rewindCharacters += clusters[candidate].length
-                            previousWidth += widthsPx[candidate]
                         }
                         if (clusters[candidate] !in closing && clusters[candidate - 1] !in opening) {
                             mode = Mode.REWIND
@@ -93,18 +99,8 @@ class ChineseLineBreaker(
                     }
                 }
                 when (mode) {
-                    Mode.NORMAL -> {
-                        carriedWidth = currentWidth
-                        addStart(textLength, index)
-                        carriedCharacters = cluster.length
-                        carriedClusters = 1
-                    }
-                    Mode.PULL_PREVIOUS -> {
-                        carriedWidth = currentWidth + previousWidth
-                        addStart(textLength - clusters[index - 1].length, index - 1)
-                        carriedCharacters = clusters[index - 1].length + cluster.length
-                        carriedClusters = 2
-                    }
+                    Mode.NORMAL -> carryFrom(index)
+                    Mode.PULL_PREVIOUS -> carryFrom(index - 1)
                     Mode.HANG -> {
                         // 标点留在本行(行宽超出右边界,旧版正是靠这个避免标点落到下一行行首),
                         // 下一行从它之后重新开始,本行不向下一行携带任何宽度。
@@ -114,12 +110,7 @@ class ChineseLineBreaker(
                         carriedClusters = 0
                         hungLine = true
                     }
-                    Mode.REWIND -> {
-                        carriedWidth = currentWidth + previousWidth
-                        addStart(textLength - rewindCharacters, index - rewindClusters)
-                        carriedCharacters = rewindCharacters + cluster.length
-                        carriedClusters = rewindClusters + 1
-                    }
+                    Mode.REWIND -> carryFrom(index - rewindClusters)
                 }
                 widths += lineWidth - carriedWidth
                 lineWidth = carriedWidth
@@ -140,7 +131,6 @@ class ChineseLineBreaker(
                 }
             }
             textLength += cluster.length
-            previousWidth = currentWidth
         }
     }
 
@@ -149,6 +139,52 @@ class ChineseLineBreaker(
         clusterStarts += cluster
     }
 
+    /** Keeps a candidate line start from splitting a Latin word that fits on a fresh line. */
+    private fun safeBreakStart(candidate: Int, lineStart: Int): Int {
+        val wordStart = latinWordStartBefore(candidate)
+        return if (wordStart != null && wordStart > lineStart) wordStart else candidate
+    }
+
+    /**
+     * Returns the start of the Latin word containing [index], or null when the overflow
+     * is not inside one. A word that already starts on this visual line is deliberately not
+     * rewound by the caller, so an overlong word still falls back to character-level breaks.
+     */
+    private fun latinWordStartBefore(index: Int): Int? {
+        if (index == 0 ||
+            !clusters[index].isLatinWordPart() ||
+            !clusters[index - 1].isLatinWordPart()
+        ) return null
+
+        var start = index - 1
+        while (start > 0 && clusters[start - 1].isLatinWordPart()) start--
+        return start
+    }
+
+    private fun String.isLatinWordPart(): Boolean {
+        var hasLatinLetter = false
+        var offset = 0
+        while (offset < length) {
+            val codePoint = codePointAt(offset)
+            val type = Character.getType(codePoint)
+            when {
+                Character.isLetter(codePoint) -> {
+                    if (Character.UnicodeScript.of(codePoint) != Character.UnicodeScript.LATIN) {
+                        return false
+                    }
+                    hasLatinLetter = true
+                }
+                Character.isDigit(codePoint) ||
+                    type == Character.NON_SPACING_MARK.toInt() ||
+                    type == Character.COMBINING_SPACING_MARK.toInt() ||
+                    codePoint == '\''.code || codePoint == 0x2019 -> Unit
+                else -> return false
+            }
+            offset += Character.charCount(codePoint)
+        }
+        return hasLatinLetter || all { it.isDigit() || it == '\'' || it == '\u2019' }
+    }
+
     private enum class Mode { NORMAL, PULL_PREVIOUS, REWIND, HANG }
 
     companion object {

+ 86 - 0
app/src/test/java/io/legado/app/feature/reader/core/layout/ChineseLineBreakerTest.kt

@@ -49,4 +49,90 @@ class ChineseLineBreakerTest {
         assertArrayEquals(intArrayOf(0, 3, 6), result.lineStarts)
         assertArrayEquals(floatArrayOf(30f, 18f), result.lineWidthsPx, 0f)
     }
+
+    @Test
+    fun latinWordMovesIntactToNextLine() {
+        val result = breakText("one two".map(Char::toString), 55)
+
+        assertEquals(2, result.lineCount)
+        assertArrayEquals(intArrayOf(0, 4, 7), result.lineStarts)
+        assertArrayEquals(floatArrayOf(40f, 30f), result.lineWidthsPx, 0f)
+    }
+
+    @Test
+    fun overlongLatinWordFallsBackToCharacterBreaks() {
+        val result = breakText("abcdef".map(Char::toString), 25)
+
+        assertEquals(3, result.lineCount)
+        assertArrayEquals(intArrayOf(0, 2, 4, 6), result.lineStarts)
+        assertArrayEquals(floatArrayOf(20f, 20f, 20f), result.lineWidthsPx, 0f)
+    }
+
+    @Test
+    fun latinHandlingDoesNotInterceptCjkPunctuation() {
+        val result = breakText(listOf("我", "是", ",", "三"), 25)
+
+        assertEquals(3, result.lineCount)
+        assertArrayEquals(intArrayOf(0, 1, 3, 4), result.lineStarts)
+        assertArrayEquals(floatArrayOf(10f, 20f, 10f), result.lineWidthsPx, 0f)
+    }
+
+    @Test
+    fun latinWordBeforeCjkPeriodStaysIntact() {
+        val result = breakText("aa words。".map(Char::toString), 80)
+
+        assertArrayEquals(intArrayOf(0, 3, 9), result.lineStarts)
+        assertArrayEquals(floatArrayOf(30f, 60f), result.lineWidthsPx, 0f)
+    }
+
+    @Test
+    fun latinWordBeforeCjkCommaStaysIntact() {
+        val result = breakText("aa layout、".map(Char::toString), 90)
+
+        assertArrayEquals(intArrayOf(0, 3, 10), result.lineStarts)
+        assertArrayEquals(floatArrayOf(30f, 70f), result.lineWidthsPx, 0f)
+    }
+
+    @Test
+    fun latinWordBeforeAsciiPeriodStaysIntact() {
+        val result = breakText("aa lines.".map(Char::toString), 80)
+
+        assertArrayEquals(intArrayOf(0, 3, 9), result.lineStarts)
+        assertArrayEquals(floatArrayOf(30f, 60f), result.lineWidthsPx, 0f)
+    }
+
+    @Test
+    fun rewindDoesNotSplitLatinWordBeforeClosingPunctuation() {
+        val words = "aa word。”".map(Char::toString)
+        val result = ChineseLineBreaker(
+            words, List(7) { 10f } + listOf(4f, 4f), 0, 75, 10f, 0f,
+        )
+
+        assertArrayEquals(intArrayOf(0, 3, 9), result.lineStarts)
+        assertArrayEquals(floatArrayOf(30f, 48f), result.lineWidthsPx, 0f)
+    }
+
+    @Test
+    fun latinWordWithApostropheAndDigitsStaysIntactBeforePunctuation() {
+        val result = breakText("aa R2D2's.".map(Char::toString), 90)
+
+        assertArrayEquals(intArrayOf(0, 3, 10), result.lineStarts)
+        assertArrayEquals(floatArrayOf(30f, 70f), result.lineWidthsPx, 0f)
+    }
+
+    @Test
+    fun latinGraphemeClusterStaysWithItsWordBeforePunctuation() {
+        val result = breakText(listOf("a", "a", " ", "c", "a", "f", "e\u0301", "."), 70)
+
+        assertArrayEquals(intArrayOf(0, 3, 9), result.lineStarts)
+        assertArrayEquals(floatArrayOf(30f, 50f), result.lineWidthsPx, 0f)
+    }
+
+    @Test
+    fun overlongLatinWordBeforePunctuationStillUsesCharacterFallback() {
+        val result = breakText("abcdef.".map(Char::toString), 25)
+
+        assertArrayEquals(intArrayOf(0, 2, 4, 5, 7), result.lineStarts)
+        assertArrayEquals(floatArrayOf(20f, 20f, 10f, 20f), result.lineWidthsPx, 0f)
+    }
 }