Просмотр исходного кода

feat: 高亮规则修复与预设

HapeLee 1 месяц назад
Родитель
Сommit
fd9de43ca0

+ 124 - 0
app/src/main/java/io/legado/app/data/repository/HighlightRulePresets.kt

@@ -0,0 +1,124 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.entities.HighlightRule
+import io.legado.app.data.repository.HighlightRulePresets.PRESET_BG_COLOR
+
+/**
+ * 阅读页「高亮规则 → 预设规则」入口的可选清单。
+ *
+ * 只做一件事:给一份开箱可用的正则。样式统一为同一个半透明底纹
+ * ([PRESET_BG_COLOR])——预设是「加进来再自己调」的起点,不在这里预设
+ * 一堆颜色/下划线组合,避免和用户已有的样式审美打架。
+ *
+ * `enabled` 沿用示例里「常用 4 条开、其余关」的分布:一次加十几条全开会糊满正文,
+ * 关着加进来、用户按需打开更可控。
+ *
+ * id 与 [HighlightRuleRepository.createDefaultRules] 保持一致:已经存在同 id 规则时,
+ * 预设入口把它标为「已有」并默认不勾选,只补缺失的规则,不覆盖用户改过的样式。
+ */
+object HighlightRulePresets {
+
+    /** 统一的预设底色:20% 透明度的琥珀,日间/夜间压在正文上都不糊字。 */
+    private const val PRESET_BG_COLOR = 0x33FFC107.toInt()
+
+    val rules: List<HighlightRule> = listOf(
+        HighlightRule(
+            id = "dialog_default",
+            name = "对话高亮",
+            pattern = "“[^“”]{1,500}”|\"[^\"\\n]{1,500}\"|「[^「」]{1,500}」|『[^『』]{1,500}』",
+            sampleText = "她轻声说:“今晚就出发。”",
+            enabled = true,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "book_title_default",
+            name = "书名号高亮",
+            pattern = "《[^》\\n]{1,120}》",
+            sampleText = "最近在重读《百年孤独》,节奏依然很稳。",
+            enabled = true,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "bracket_note_default",
+            name = "括号标注高亮",
+            pattern = "([^()\\n]{1,120})|\\([^()\\n]{1,120}\\)|【[^】\\n]{1,120}】|\\[[^\\]\\n]{1,120}]",
+            sampleText = "他停了一下(像是忽然想起了什么)。",
+            enabled = true,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "title_emphasis_default",
+            name = "标题强调",
+            pattern = "(?m)^[ \\t ]{0,2}(?:第[0-9零〇一二两三四五六七八九十百千万IVXLCDMivxlcdm]{1,12}[章节卷回部篇集幕]|序章|楔子|引子|终章|尾声|后记|番外)[^\\n]{0,200}\$",
+            sampleText = "第一章 雨夜来客",
+            targetScope = HighlightRule.TARGET_TITLE,
+            enabled = true,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "thought_default",
+            name = "心理活动",
+            pattern = "([^)\\n]{0,40}(?:心想|暗道|心道|想到|寻思着|琢磨|嘀咕)[^)\\n]{0,40})",
+            sampleText = "她心中一紧(暗道不对,这里一定有问题)。",
+            enabled = false,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "narrator_default",
+            name = "旁白说明",
+            pattern = "(?m)^[ \\t ]{0,2}(?:(?:未完待续|待续|下文再表)[。…!!]{0,2}|(?:按|注)[::][^\\n]{0,40})[ \\t ]*\$|((?:注|旁白|作者有话说)[::][^)\\n]{0,40})",
+            sampleText = "(注:此处时间线与前文同步)",
+            enabled = false,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "emphasis_default",
+            name = "重点强调",
+            // 示例里那段裸 `[^\n*_]{1,40}` 会匹配任意一行正文,这里换成成对标记 + 连续感叹/问号。
+            pattern = "\\*\\*[^\\n*_]{1,40}\\*\\*|__[^\\n*_]{1,40}__|(?:!{3,}|!{2,}|!?|?!|\\?!|!\\?)[^\\n]{0,20}",
+            sampleText = "**这是重点内容**,需要特别注意。",
+            enabled = false,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "poetry_default",
+            name = "诗词引用",
+            pattern = "(?m)^[ \\t ]*(?!(?:第[零〇一二两三四五六七八九十百千万0-9]{1,12}[章节卷回部篇集幕]|序章|楔子|引子|终章|尾声|后记|番外))(?:[\\p{IsHan}]{5}|[\\p{IsHan}]{7})[,。!?;]?[ \\t ]*\$",
+            sampleText = "床前明月光,\n疑是地上霜。",
+            enabled = false,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "ellipsis_default",
+            name = "省略停顿",
+            pattern = "…{2,}|\\.{3,}|—{2,}|-{3,}",
+            sampleText = "他沉默了很久……最后还是点了头。",
+            enabled = false,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "number_default",
+            name = "数字金额",
+            pattern = "[¥¥][ \\t]?\\d+(?:\\.\\d+)?(?:元|块|万|千|百|亿)?|\\d+(?:\\.\\d+)?(?:元|块|万|千|百|亿|%|%)|[零〇一二两三四五六七八九十百千万亿]+(?:元|块|万|千|百|亿)",
+            sampleText = "原价100元,现在只要50元。",
+            enabled = false,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "english_default",
+            name = "英文单词",
+            pattern = "\\b[A-Za-z]{2,}[A-Za-z0-9'-]*\\b",
+            sampleText = "Hello World,你好世界。",
+            enabled = false,
+            bgColor = PRESET_BG_COLOR,
+        ),
+        HighlightRule(
+            id = "date_time_default",
+            name = "时间日期",
+            pattern = "(?:\\d{2,4}年(?:0?[1-9]|1[0-2])月(?:(?:0?[1-9]|[12]\\d|3[01])[日号]?)?|[零〇一二三四五六七八九十]{2,4}年(?:十一|十二|正|[一二三四五六七八九十冬腊])月(?:(?:初[一二三四五六七八九十]|十[一二三四五六七八九]?|二十[一二三四五六七八九]?|三十|廿[一二三四五六七八九]?|卅|[一二三四五六七八九])[日号]?)?)(?![0-9零〇一二三四五六七八九十初廿卅日号])|(?<!\\d)(?:[01]?\\d|2[0-3]):[0-5]\\d(?!\\d)|(?<!\\d)(?:[01]?\\d|2[0-3])点(?:[0-5]?\\d分?)?(?!\\d)",
+            sampleText = "2024年8月12日,上午10:30出发。",
+            enabled = false,
+            bgColor = PRESET_BG_COLOR,
+        ),
+    )
+}

+ 23 - 3
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderPaginator.kt

@@ -649,6 +649,23 @@ internal class ReaderPaginationSession(private val config: ReaderPaginationConfi
         fun frameOf(index: Int, topBudgetPx: Float, bottomBudgetPx: Float) =
             itemFrame(index)?.fitIntoLineBudget(topBudgetPx, bottomBudgetPx)
 
+        /**
+         * 元素绘制时真正使用的背景图:九宫格按行预算收紧,拉伸/裁剪/平铺按原样。
+         *
+         * 行内连续放行标记必须用「绘制用实例」比较,不能只看 [frameOf](它只认 `fit == 3`)。
+         * 旧 View `TextLine.drawStyledBackgrounds` 对行内连续的同图段无条件合并,新实现多了
+         * 「几何相邻 < 1px」这条,靠分页期放行标记兜住字间距(默认 0.1em,远大于 1px)。
+         * 放行标记若只发给九宫格,fit≠3 的背景图就会逐字绘制成一条条断开的气泡。
+         */
+        fun drawnBackgroundOf(index: Int, topBudgetPx: Float, bottomBudgetPx: Float) =
+            (paragraph.items[index] as? ReaderMeasuredInlineItem.Text)
+                ?.style?.backgroundImage?.let { image ->
+                    if (image.fit == 3) image.fitIntoLineBudget(
+                        topBudgetPx,
+                        bottomBudgetPx
+                    ) else image
+                }
+
         fun backgroundInsetBefore(
             index: Int,
             lineStart: Int,
@@ -847,7 +864,8 @@ internal class ReaderPaginationSession(private val config: ReaderPaginationConfi
             val underlineElementStart = elements.size + indentItems
             lineItems.forEachIndexed { itemIndex, item ->
                 x += backgroundInsetBefore(itemIndex)
-                val itemBackground = frameOf(from + itemIndex, topBudgetPx, bottomBudgetPx)
+                val itemBackground =
+                    drawnBackgroundOf(from + itemIndex, topBudgetPx, bottomBudgetPx)
                 when (item) {
                     is ReaderMeasuredInlineItem.Text -> {
                         val expandedWordSpace = if (item.value == " ") wordSpaceExtra else 0f
@@ -867,10 +885,12 @@ internal class ReaderPaginationSession(private val config: ReaderPaginationConfi
                             markingId = item.markingId,
                             chapterPosition = item.chapterPosition,
                             paragraphIndex = paragraphIndex,
-                            // 富文本逐项样式:与前一项同背景图才视作同一 run 的延续
+                            // 富文本逐项样式:与前一项同背景图才视作同一 run 的延续。
+                            // 比较「绘制用实例」,非九宫格背景图同样要拿到放行标记,
+                            // 否则字间距会把它切成逐字绘制。
                             continuesBackgroundRun = itemBackground != null &&
                                     itemIndex > 0 &&
-                                    frameOf(
+                                    drawnBackgroundOf(
                                         from + itemIndex - 1,
                                         topBudgetPx,
                                         bottomBudgetPx

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -684,6 +684,9 @@ sealed interface ReadBookIntent {
         val rule: HighlightRule,
     ) : ReadBookIntent
     data object SaveImportedHighlightRules : ReadBookIntent
+
+    /** 打开内置预设规则清单(多选后按 [SaveImportedHighlightRules] 入库)。 */
+    data object ShowHighlightRulePresets : ReadBookIntent
     data object ExportHighlightRules : ReadBookIntent
     data object ExportHighlightRulesAsUrl : ReadBookIntent
     data class ExportHighlightRulesToFile(val uri: Uri) : ReadBookIntent

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -1251,6 +1251,7 @@ class ReadBookViewModel(
             is ReadBookIntent.UpdateHighlightRuleImportItem ->
                 highlightRuleDelegate.updateImportItem(intent.index, intent.rule)
             is ReadBookIntent.SaveImportedHighlightRules -> highlightRuleDelegate.saveImported()
+            is ReadBookIntent.ShowHighlightRulePresets -> highlightRuleDelegate.showPresets()
             is ReadBookIntent.ExportHighlightRules -> {
                 _effects.tryEmit(ReadBookEffect.OpenHighlightRuleExportPicker)
             }

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadHighlightRuleContract.kt

@@ -17,4 +17,6 @@ data class HighlightRuleConfigUiState(
     val showNewRule: Boolean = false,
     val deleteRule: HighlightRule? = null,
     val importState: BaseImportUiState<HighlightRule> = BaseImportUiState.Idle,
+    /** 当前批量弹层来自「预设规则」而非文件/URL 导入,只影响标题与默认勾选。 */
+    val presetImport: Boolean = false,
 )

+ 40 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadHighlightRuleDelegate.kt

@@ -6,6 +6,7 @@ import io.legado.app.R
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
 import io.legado.app.data.entities.HighlightRule
+import io.legado.app.data.repository.HighlightRulePresets
 import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.exception.NoStackTraceException
@@ -68,6 +69,7 @@ class ReadHighlightRuleDelegate(
                 showNewRule = false,
                 deleteRule = null,
                 importState = BaseImportUiState.Idle,
+                presetImport = false,
             )
         }
     }
@@ -80,6 +82,7 @@ class ReadHighlightRuleDelegate(
                 showNewRule = false,
                 deleteRule = null,
                 importState = BaseImportUiState.Idle,
+                presetImport = false,
             )
         }
     }
@@ -164,10 +167,45 @@ class ReadHighlightRuleDelegate(
         host.notifyRulesChanged()
     }
 
+    // --- 预设规则 ---
+
+    /**
+     * 打开内置预设清单,走和导入同一套批量选择弹层。
+     *
+     * 与导入不同的是默认勾选:只勾「缺失」的预设,已存在同 id 的规则标为已有且不勾选。
+     * 预设是给用户补规则的,不该把用户改过样式的旧规则悄悄覆盖掉——真要覆盖,用户自己勾。
+     */
+    fun showPresets() {
+        val oldRules = highlightRuleRepository.load(ReadBookConfig.durConfig.name)
+            .associateBy { it.id }
+        val items = HighlightRulePresets.rules.map { rule ->
+            val oldRule = oldRules[rule.id]
+            val status = when {
+                oldRule == null -> ImportStatus.New
+                oldRule != rule -> ImportStatus.Update
+                else -> ImportStatus.Existing
+            }
+            ImportItemWrapper(
+                data = rule,
+                oldData = oldRule,
+                status = status,
+                isSelected = status == ImportStatus.New,
+            )
+        }
+        _uiState.update {
+            it.copy(
+                presetImport = true,
+                importState = BaseImportUiState.Success(source = "", items = items),
+            )
+        }
+    }
+
     // --- 导入 ---
 
     fun importSource(text: String) {
-        _uiState.update { it.copy(importState = BaseImportUiState.Loading) }
+        _uiState.update {
+            it.copy(importState = BaseImportUiState.Loading, presetImport = false)
+        }
         Coroutine.async(scope, Dispatchers.IO) {
             val importedRules = importSourceAwait(text.trim())
                 .map(highlightRuleRepository::sanitizeRule)
@@ -257,7 +295,7 @@ class ReadHighlightRuleDelegate(
     }
 
     fun cancelImport() {
-        _uiState.update { it.copy(importState = BaseImportUiState.Idle) }
+        _uiState.update { it.copy(importState = BaseImportUiState.Idle, presetImport = false) }
     }
 
     fun toggleImportSelection(index: Int) {

+ 15 - 2
app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleConfigSheet.kt

@@ -34,8 +34,8 @@ import io.legado.app.ui.book.read.HighlightRuleConfigUiState
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.widget.components.TinySwitch
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
 import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
@@ -97,7 +97,13 @@ fun HighlightRuleConfigSheet(
         allowExtensions = arrayOf("json", "txt"),
     )
     BatchImportDialog(
-        title = stringResource(R.string.highlight_rule_config),
+        title = stringResource(
+            if (state.presetImport) {
+                R.string.highlight_rule_presets
+            } else {
+                R.string.highlight_rule_config
+            }
+        ),
         importState = state.importState,
         onDismissRequest = { onIntent(ReadBookIntent.CancelHighlightRuleImport) },
         onToggleItem = {
@@ -139,6 +145,13 @@ fun HighlightRuleConfigSheet(
                     expanded = expanded,
                     onDismissRequest = { expanded = false },
                 ) {
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.highlight_rule_presets),
+                        onClick = {
+                            expanded = false
+                            onIntent(ReadBookIntent.ShowHighlightRulePresets)
+                        },
+                    )
                     RoundDropdownMenuItem(
                         text = stringResource(R.string.import_action),
                         onClick = {

+ 1 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -856,6 +856,7 @@
     <string name="chapter_rule">章节正则</string>
     <string name="volume_rule">分卷正则</string>
     <string name="highlight_rule_config">高亮规则</string>
+    <string name="highlight_rule_presets">预设规则</string>
     <string name="new_rule">新增规则</string>
     <string name="edit_rule">编辑规则</string>
     <string name="rule_info">规则信息</string>

+ 1 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -1922,6 +1922,7 @@
     <string name="highlight_bg_image">背景圖片</string>
     <string name="highlight_bg_image_scale">圖片縮放</string>
     <string name="highlight_rule_config">高亮規則</string>
+    <string name="highlight_rule_presets">預設規則</string>
     <string name="highlight_tag_add_rule">添加規則</string>
     <string name="highlight_tag_config">標籤規則</string>
     <string name="highlight_tag_display_mode">顯示模式</string>

+ 1 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1625,6 +1625,7 @@
     <string name="show_password">顯示密碼</string>
     <string name="hide_password">隱藏密碼</string>
     <string name="highlight_rule_config">高亮規則</string>
+    <string name="highlight_rule_presets">預設規則</string>
     <string name="new_rule">新規則</string>
     <string name="edit_rule">編輯規則</string>
     <string name="rule_info">規則資訊</string>

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -887,6 +887,7 @@
     <string name="chapter_rule">Chapter Regex</string>
     <string name="volume_rule">Volume Regex</string>
     <string name="highlight_rule_config">Highlight Rules</string>
+    <string name="highlight_rule_presets">Preset Rules</string>
     <string name="new_rule">New Rule</string>
     <string name="edit_rule">Edit Rule</string>
     <string name="rule_info">Rule Info</string>

+ 67 - 0
app/src/test/java/io/legado/app/data/repository/HighlightRulePresetsTest.kt

@@ -0,0 +1,67 @@
+package io.legado.app.data.repository
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.regex.Pattern
+import java.util.regex.PatternSyntaxException
+
+class HighlightRulePresetsTest {
+
+    @Test
+    fun presetIdsAreUnique() {
+        val ids = HighlightRulePresets.rules.map { it.id }
+        assertEquals(ids.size, ids.distinct().size)
+    }
+
+    @Test
+    fun everyPresetPatternCompiles() {
+        HighlightRulePresets.rules.forEach { rule ->
+            try {
+                Pattern.compile(rule.pattern)
+            } catch (e: PatternSyntaxException) {
+                throw AssertionError("预设「${rule.name}」的正则无法编译:${e.description}")
+            }
+        }
+    }
+
+    /** 示例文本进编辑弹层就是预览样本,自己都匹配不上说明正则或示例写错了。 */
+    @Test
+    fun everyPresetMatchesItsOwnSample() {
+        HighlightRulePresets.rules.forEach { rule ->
+            assertTrue(
+                "预设「${rule.name}」匹配不到自己的示例:${rule.sampleText}",
+                Pattern.compile(rule.pattern).matcher(rule.sampleText).find(),
+            )
+        }
+    }
+
+    @Test
+    fun presetsShareOneBackgroundTint() {
+        val colors = HighlightRulePresets.rules.map { it.bgColor }
+        assertTrue(colors.all { it != null })
+        assertEquals(1, colors.distinct().size)
+    }
+
+    /**
+     * 「重点强调」的示例里带一段裸 `[^\n*_]{1,40}`,会匹配任意一行正文、把整页涂成高亮。
+     * 这里钉死:普通叙述句不命中,成对标记仍然命中。
+     */
+    @Test
+    fun emphasisPresetDoesNotPaintOrdinaryProse() {
+        val pattern = Pattern.compile(
+            HighlightRulePresets.rules.single { it.id == "emphasis_default" }.pattern
+        )
+        listOf(
+            "他停了一下,看了看窗外。",
+            "夜色很深,巷口的路灯忽明忽暗。",
+        ).forEach { line ->
+            assertFalse(
+                "「重点强调」不该匹配普通正文:$line",
+                pattern.matcher(line).find(),
+            )
+        }
+        assertTrue(pattern.matcher("**这是重点内容**,需要特别注意。").find())
+    }
+}

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

@@ -810,6 +810,39 @@ class ReaderPaginatorTest {
         assertEquals(34f, runs.single().contentBounds.right, 0f)
     }
 
+    /**
+     * 高亮规则命中的段落走富文本路径(逐 item 样式),背景图 fit=1(拉伸)/0(平铺)/2(裁剪)
+     * 不参与九宫格预算,但放行标记必须照发:默认字间距 0.1em 远大于 1px 的几何相邻阈值,
+     * 少发标记就会把一条连续气泡切成逐字绘制(issue #2286)。
+     */
+    @Test
+    fun stretchedBackgroundMergesAcrossLetterSpacingOnRichTextRow() {
+        val stretched = ReaderTextBackgroundImage("bubble.png", fit = 1, scale = 1f)
+        val stretchedStyle = style.copy(backgroundImage = stretched)
+        val page = ReaderPaginator.paginateBlocks(
+            listOf(
+                ReaderMeasuredBlock.InlineParagraph(
+                    items = (0 until 3).map { index ->
+                        ReaderMeasuredInlineItem.Text("字", 10f, stretchedStyle, index)
+                    },
+                    indentCharacters = 0,
+                    alignment = ReaderTextAlignment.START,
+                    lineHeightPx = 20f,
+                    baselineOffsetPx = 15f,
+                    baseTextSizePx = 10f,
+                )
+            ),
+            config.copy(viewportWidthPx = 100, viewportHeightPx = 100, letterSpacingPx = 5f),
+        ).single()
+
+        val glyphs = page.elements.filterIsInstance<ReaderElement.Text>()
+        // 字间距在每个字之间留下 5f 间隙,几何相邻判定必然失败。
+        assertEquals(listOf(0f, 15f, 30f), glyphs.map { it.bounds.left })
+        val run = page.textBackgroundRuns().single()
+        assertEquals(0f, run.contentBounds.left, 0f)
+        assertEquals(40f, run.contentBounds.right, 0f)
+    }
+
     /**
      * 流式会话(对照旧 View `TextChapterLayout.onPageCompleted()` 的 `channel.trySend`):
      * 逐 block 推送得到的页必须与整章批次入口完全一致,流出顺序也与最终列表一致。