From 6d19b26b1ff03a8725f18c9a1b77c5358c4f89f6 Mon Sep 17 00:00:00 2001 From: aoi Date: Thu, 4 Jun 2026 23:56:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=94=AF=E6=8C=81regex=E5=85=A8=E6=96=87?= =?UTF-8?q?=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../read/page/provider/TextChapterLayout.kt | 151 ++++++++++++++---- 1 file changed, 120 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt index 23367e6d0..9b881b90c 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt @@ -134,6 +134,8 @@ class TextChapterLayout( var channel = Channel(Channel.UNLIMITED) + private var globalRegexResult: RegexMatchResult? = null + init { job = Coroutine.async( @@ -241,8 +243,8 @@ class TextChapterLayout( val isSingleImageStyle = imageStyle.equals(Book.imgStyleSingle, true) val isTextImageStyle = imageStyle.equals(Book.imgStyleText, true) - if (titleMode != 2 || bookChapter.isVolume || contents.isEmpty()) { - val allTitleSegments = displayTitle.splitNotBlank("\n").flatMap { rawTitle -> + val allTitleSegments = if (titleMode != 2 || bookChapter.isVolume || contents.isEmpty()) { + displayTitle.splitNotBlank("\n").flatMap { rawTitle -> TitleStyleParser.getSegments( rawTitle, titleSegType, @@ -251,7 +253,64 @@ class TextChapterLayout( titleSegScaling ) } + } else null + if (ReadBookConfig.regexColorRules.isNotEmpty()) { + val fullTextBuilder = StringBuilder() + allTitleSegments?.forEachIndexed { index, segment -> + val reviewImg = bookChapter.reviewImg + var reviewTxt = "" + if (index == allTitleSegments.lastIndex && reviewImg != null) { + reviewTxt = if (reviewImg.contains("TEXT")) reviewChar else srcReplaceChar + } + fullTextBuilder.append(segment.text).append(reviewTxt).append("\n") + } + contents.forEach { content -> + if (adaptSpecialStyle) { + val t = content.trim() + if (t == "[newpage]" || t.startsWith("")) { + fullTextBuilder.append(content).append("\n") + return@forEach + } + } + val text = content.replace(srcReplaceCharC, srcReplaceCharD) + if (isTextImageStyle) { + val matcher = AppPattern.imgPattern.matcher(text) + val ssb = StringBuffer() + while (matcher.find()) { + matcher.appendReplacement(ssb, srcReplaceChar) + } + matcher.appendTail(ssb) + fullTextBuilder.append(ssb.toString()) + } else { + val matcher = AppPattern.imgPattern.matcher(text) + var start = 0 + while (matcher.find()) { + val imgSrc = matcher.group(1)!! + val iStyle = if (imgSrc.contains("TEXT")) "text" else imageStyle + if (start < matcher.start()) { + fullTextBuilder.append(text.substring(start, matcher.start())) + } + if (iStyle == "text" || iStyle == "TEXT") { + fullTextBuilder.append(if (iStyle == "TEXT") reviewChar else srcReplaceChar) + } else { + fullTextBuilder.append(" ") + } + start = matcher.end() + } + if (start < text.length) { + fullTextBuilder.append(text.substring(start)) + } + if (AppConfig.enableReview) fullTextBuilder.append(reviewChar) + } + fullTextBuilder.append("\n") + } + preApplyRegexColorRules(fullTextBuilder.toString()) + } + + var currentOffset = 0 + + if (allTitleSegments != null) { allTitleSegments.forEachIndexed { index, segment -> val currentPaint: TextPaint val currentHeight: Float @@ -277,9 +336,10 @@ class TextChapterLayout( reviewTxt = if (reviewImg.contains("TEXT")) reviewChar else srcReplaceChar } + val text = segment.text + reviewTxt setTypeText( book = book, - text = segment.text + reviewTxt, + text = text, textPaint = currentPaint, textHeight = currentHeight, fontMetrics = currentMetrics, @@ -287,8 +347,10 @@ class TextChapterLayout( srcList = srcList.ifEmpty { null }, isTitle = true, emptyContent = contents.isEmpty(), - isVolumeTitle = bookChapter.isVolume + isVolumeTitle = bookChapter.isVolume, + offset = currentOffset ) + currentOffset += text.length + 1 if (segment.scale != 1.0f) { val currentLines = pendingTextPage.lines @@ -319,9 +381,11 @@ class TextChapterLayout( val text = content.trim() if (text == "[newpage]") { prepareNextPageIfNeed() + currentOffset += content.length + 1 return@forEach } else if (text.startsWith("")) { setTypeHtml(imageStyle, book, text.substring(9, text.lastIndexOf("<"))) + currentOffset += content.length + 1 return@forEach } } @@ -347,8 +411,10 @@ class TextChapterLayout( contentPaintTextHeight, contentPaintFontMetrics, imageStyle, - srcList = srcList + srcList = srcList, + offset = currentOffset ) + currentOffset += text.length } else { if (isSingleImageStyle && isSetTypedImage) { isSetTypedImage = false @@ -402,10 +468,12 @@ class TextChapterLayout( } if (start < matcher.start()) { - sb.append(text.substring(start, matcher.start())) + val textPart = text.substring(start, matcher.start()) + sb.append(textPart) } if (iStyle == "text" || iStyle == "TEXT") { - sb.append(if (iStyle == "TEXT") reviewChar else srcReplaceChar) + val charPart = if (iStyle == "TEXT") reviewChar else srcReplaceChar + sb.append(charPart) srcList.add(imgSrc) clickList.add(click) } else { @@ -413,10 +481,12 @@ class TextChapterLayout( if (textBefore.isNotBlank()) { wordCount += textBefore.replace(noWordCountRegex,"").length setTypeText( - book, sb.toString(), contentPaint, contentPaintTextHeight, + book, textBefore, contentPaint, contentPaintTextHeight, contentPaintFontMetrics, "TEXT", isFirstLine = isFirstLine, - srcList = srcList, clickList = clickList + srcList = srcList, clickList = clickList, + offset = currentOffset ) + currentOffset += textBefore.length sb.setLength(0) isFirstLine = false } @@ -428,6 +498,7 @@ class TextChapterLayout( imgSize, click ) // 传递点击信息 + currentOffset += 1 isSetTypedImage = true } start = matcher.end() @@ -444,21 +515,25 @@ class TextChapterLayout( text = sb.toString() if (text.isNotBlank()) { wordCount += text.replace(noWordCountRegex,"").length + val textToType = if (AppConfig.enableReview) text + reviewChar else text setTypeText( book, - if (AppConfig.enableReview) text + reviewChar else text, + textToType, contentPaint, contentPaintTextHeight, contentPaintFontMetrics, "TEXT", isFirstLine = isFirstLine, srcList = srcList.ifEmpty { null }, - clickList = clickList.ifEmpty { null } + clickList = clickList.ifEmpty { null }, + offset = currentOffset ) + currentOffset += textToType.length } } pendingTextPage.lines.last().isParagraphEnd = true stringBuilder.append("\n") + currentOffset += 1 } val chapterWordCount = StringUtils.wordCountFormat(wordCount.toString()) bookChapter.wordCount = chapterWordCount @@ -868,11 +943,12 @@ class TextChapterLayout( emptyContent: Boolean = false, isVolumeTitle: Boolean = false, srcList: LinkedList? = null, - clickList: LinkedList? = null + clickList: LinkedList? = null, + offset: Int = -1 ) { val widthsArray = allocateFloatArray(text.length) textPaint.getTextWidthsCompat(text, widthsArray) - val colorMap = applyRegexColorRules(text) + val colorMap = applyRegexColorRules(text, offset) val layout = if (useZhLayout) { val (words, widths) = measureTextSplit(text, widthsArray) val indentSize = if (isFirstLine) paragraphIndent.length else 0 @@ -1310,28 +1386,19 @@ class TextChapterLayout( val fontPathArray: Array ) - private fun applyRegexColorRules(text: String): RegexMatchResult? { + private fun preApplyRegexColorRules(fullText: String) { val rules = ReadBookConfig.regexColorRules - if (rules.isEmpty()) return null + if (rules.isEmpty()) return + val colorArray = IntArray(fullText.length) { -1 } + val fontPathArray = arrayOfNulls(fullText.length) var hasMatch = false for (rule in rules) { try { - val regex = regexCache.getOrPut(rule.pattern) { Regex(rule.pattern) } - if (regex.containsMatchIn(text)) { - hasMatch = true - break + val regex = regexCache.getOrPut(rule.pattern) { + Regex(rule.pattern, RegexOption.DOT_MATCHES_ALL) } - } catch (_: Exception) { - } - } - if (!hasMatch) return null - val colorArray = IntArray(text.length) { -1 } - val fontPathArray = arrayOfNulls(text.length) - for (rule in rules) { - try { - val regex = regexCache.getOrPut(rule.pattern) { Regex(rule.pattern) } - val matches = regex.findAll(text) - for (match in matches) { + regex.findAll(fullText).forEach { match -> + hasMatch = true for (i in match.range) { colorArray[i] = rule.color if (rule.fontPath.isNotEmpty()) { @@ -1342,7 +1409,29 @@ class TextChapterLayout( } catch (_: Exception) { } } - return RegexMatchResult(colorArray, fontPathArray) + if (hasMatch) globalRegexResult = RegexMatchResult(colorArray, fontPathArray) + } + + private fun applyRegexColorRules(text: String, offset: Int): RegexMatchResult? { + val globalResult = globalRegexResult ?: return null + if (offset < 0) return null + val colorArray = IntArray(text.length) { -1 } + val fontPathArray = arrayOfNulls(text.length) + var hasMatch = false + for (i in text.indices) { + val globalIdx = offset + i + if (globalIdx >= 0 && globalIdx < globalResult.colorArray.size) { + if (globalResult.colorArray[globalIdx] != -1) { + colorArray[i] = globalResult.colorArray[globalIdx] + hasMatch = true + } + if (globalResult.fontPathArray[globalIdx] != null) { + fontPathArray[i] = globalResult.fontPathArray[globalIdx] + hasMatch = true + } + } + } + return if (hasMatch) RegexMatchResult(colorArray, fontPathArray) else null } private data class WordStyle( From cb5108933e0b665f4322ec9659aa7eb44ad93721 Mon Sep 17 00:00:00 2001 From: aoi Date: Fri, 5 Jun 2026 00:06:46 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../read/page/provider/TextChapterLayout.kt | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt index 9b881b90c..b5cab46b3 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt @@ -278,7 +278,9 @@ class TextChapterLayout( val matcher = AppPattern.imgPattern.matcher(text) val ssb = StringBuffer() while (matcher.find()) { - matcher.appendReplacement(ssb, srcReplaceChar) + if (matcher.group(1) != null) { + matcher.appendReplacement(ssb, srcReplaceChar) + } } matcher.appendTail(ssb) fullTextBuilder.append(ssb.toString()) @@ -1394,8 +1396,10 @@ class TextChapterLayout( var hasMatch = false for (rule in rules) { try { - val regex = regexCache.getOrPut(rule.pattern) { - Regex(rule.pattern, RegexOption.DOT_MATCHES_ALL) + val regex = synchronized(regexCache) { + regexCache.getOrPut(rule.pattern) { + Regex(rule.pattern, RegexOption.DOT_MATCHES_ALL) + } } regex.findAll(fullText).forEach { match -> hasMatch = true @@ -1415,23 +1419,29 @@ class TextChapterLayout( private fun applyRegexColorRules(text: String, offset: Int): RegexMatchResult? { val globalResult = globalRegexResult ?: return null if (offset < 0) return null + val endIdx = minOf(offset + text.length, globalResult.colorArray.size) + var hasMatch = false + for (globalIdx in offset until endIdx) { + if (globalResult.colorArray[globalIdx] != -1 || globalResult.fontPathArray[globalIdx] != null) { + hasMatch = true + break + } + } + if (!hasMatch) return null val colorArray = IntArray(text.length) { -1 } val fontPathArray = arrayOfNulls(text.length) - var hasMatch = false for (i in text.indices) { val globalIdx = offset + i if (globalIdx >= 0 && globalIdx < globalResult.colorArray.size) { if (globalResult.colorArray[globalIdx] != -1) { colorArray[i] = globalResult.colorArray[globalIdx] - hasMatch = true } if (globalResult.fontPathArray[globalIdx] != null) { fontPathArray[i] = globalResult.fontPathArray[globalIdx] - hasMatch = true } } } - return if (hasMatch) RegexMatchResult(colorArray, fontPathArray) else null + return RegexMatchResult(colorArray, fontPathArray) } private data class WordStyle( From 63daf5d8428b3b7ab52a8f8e7affe0eef8f0b28a Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:59:29 +0800 Subject: [PATCH 3/3] fix: use null-safe access for matcher.group(1) in fullTextBuilder Prevents potential NPE when imgPattern matches but group(1) is null. --- .../legado/app/ui/book/read/page/provider/TextChapterLayout.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt index b5cab46b3..bb59b687a 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt @@ -288,7 +288,7 @@ class TextChapterLayout( val matcher = AppPattern.imgPattern.matcher(text) var start = 0 while (matcher.find()) { - val imgSrc = matcher.group(1)!! + val imgSrc = matcher.group(1) ?: continue val iStyle = if (imgSrc.contains("TEXT")) "text" else imageStyle if (start < matcher.start()) { fullTextBuilder.append(text.substring(start, matcher.start()))