Преглед на файлове

Merge pull request #2267 from Mupceet/feat/reader-exclude-action-images

feat(reader): 预置 excludeActionImages 排版能力——带动作脚本的图片(段评气泡)可在测量层整体剔除

冲突解析:main 已把 addStyledParagraph 改为返回 Unit(配合流式 onBlock 回调),移除本 PR 遗留的 return null,保留空白抑制语义;同时在 ReaderAndroidPaginationStyle 标注该标志尚未接线(生产路径恒为 false)。
HapeLee преди 1 месец
родител
ревизия
db06cc8138

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

@@ -77,6 +77,8 @@ data class ReaderChapterMeasureStyle(
     val bodyIndentText: String? = null,
     val imageLayoutMode: ReaderImageLayoutMode = ReaderImageLayoutMode.AUTO,
     val imageAvailableWidthPx: Float? = null,
+    /** true = 带 click 动作脚本的图片(段评气泡)不参与排版:不产出测量项,也不解析图片尺寸。 */
+    val excludeActionImages: Boolean = false,
 )
 
 sealed interface ReaderChapterMeasureResult {
@@ -159,6 +161,7 @@ class ReaderChapterBlockMeasurer(
             }?.let { it.chapterPosition + bodyIndentText.length }
             var emittedContent = false
             var hasStandaloneImage = false
+            var droppedActionImage = false
             val inline = mutableListOf<ReaderMeasuredInlineItem>()
             fun flushInline(skipBlank: Boolean = false) {
                 if (inline.isEmpty()) return
@@ -252,13 +255,19 @@ class ReaderChapterBlockMeasurer(
                         }
                     }
                     is ReaderChapterInlineSource.Image -> {
+                        // excludeActionImages 开启时:带动作脚本的行内图(段评气泡)整体
+                        // 不参与排版,且在图片尺寸解析之前跳过(不触发任何取图请求)
+                        val options = imageOptionsResolver.resolve(item.source)
+                        if (style.excludeActionImages && options?.action != null) {
+                            droppedActionImage = true
+                            return@forEach
+                        }
                         // A broken image must not make the entire chapter disappear. The bitmap
                         // loader already supplies an error image; reserve stable line geometry
                         // until real dimensions are available.
                         val placeholderExtent = (lineHeight ?: baseStyle.fontSizePx).coerceAtLeast(1f)
                         val originalSize = imageDimensionsResolver.resolve(item.source)
                             ?: ReaderImageDimensions(placeholderExtent, placeholderExtent)
-                        val options = imageOptionsResolver.resolve(item.source)
                         val requestedWidth = options?.requestedWidthFraction?.let { fraction ->
                             style.imageAvailableWidthPx?.times(fraction)
                         } ?: options?.requestedWidthPx
@@ -305,7 +314,9 @@ class ReaderChapterBlockMeasurer(
                     is ReaderChapterInlineSource.BlankLine -> Unit
                 }
             }
-            flushInline(skipBlank = hasStandaloneImage)
+            // 被剔除的段评图视同独立图参与空白抑制:整行图片段自带的缩进/空白
+            // 填充不再残留为空行(与「图不存在」的排版等价)
+            flushInline(skipBlank = hasStandaloneImage || droppedActionImage)
         }
         source.blocks.forEachIndexed { index, block ->
             when (block) {
@@ -326,11 +337,12 @@ class ReaderChapterBlockMeasurer(
                     }
                 }
                 is ReaderChapterSourceBlock.Image -> {
+                    val options = imageOptionsResolver.resolve(block.source)
+                    if (style.excludeActionImages && options?.action != null) return@forEachIndexed
                     val placeholderExtent = (style.bodyLineHeightPx ?: style.bodyStyle.fontSizePx)
                         .coerceAtLeast(1f)
                     val originalSize = imageDimensionsResolver.resolve(block.source)
                         ?: ReaderImageDimensions(placeholderExtent, placeholderExtent)
-                    val options = imageOptionsResolver.resolve(block.source)
                     val requestedWidth = options?.requestedWidthFraction?.let { fraction ->
                         style.imageAvailableWidthPx?.times(fraction)
                     } ?: options?.requestedWidthPx

+ 1 - 0
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt

@@ -223,6 +223,7 @@ object LegacyReaderChapterPaginator {
                 imagePageBreakAfter = singleImage,
                 titlePageBreakAfter = singleImage && content.textList.isNotEmpty(),
                 imageLayoutMode = imageLayoutMode,
+                excludeActionImages = paginationStyle.excludeActionImages,
                 imageAvailableWidthPx = (
                     viewportWidthPx / paginationStyle.columnCount(viewportWidthPx, viewportHeightPx) -
                         paginationStyle.paddingLeftPx - paginationStyle.paddingRightPx -

+ 7 - 0
app/src/main/java/io/legado/app/feature/reader/platform/ReaderAndroidPaginationStyle.kt

@@ -34,6 +34,13 @@ data class ReaderAndroidPaginationStyle(
     val textBottomJustify: Boolean = false,
     val pageUnderline: ReaderPageUnderline? = null,
     val emphasisUnderlineStyle: ReaderEmphasisUnderline? = null,
+    /**
+     * 带 click 动作脚本的图片(段评气泡)不参与排版:不产出测量项,也不解析图片尺寸。
+     *
+     * 注意:当前尚未接线。`LegacyReaderPaginationStyleFactory.create()` 还没有从阅读设置读取
+     * 该字段,生产路径恒为 `false`,因此默认行为零变化;接线前不要把它当作已生效能力。
+     */
+    val excludeActionImages: Boolean = false,
 ) {
     fun columnCount(widthPx: Int, heightPx: Int): Int =
         columnMode.columnCount(widthPx, heightPx, isTablet, isScroll)

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

@@ -1136,6 +1136,7 @@ class ReadBookController(
             append(',').append(contentPadding.right).append(',').append(contentPadding.bottom)
             append('|').append(resolvedPaginationStyle.columnCount(width, height))
             append('|').append(resolvedPaginationStyle.isScroll)
+            append('|').append(resolvedPaginationStyle.excludeActionImages)
             append('|').append(resolvedPaginationStyle.textBottomJustify)
             append('|').append(resolvedPaginationStyle.pageUnderline)
             append('|').append(resolvedPaginationStyle.emphasisUnderlineStyle)

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

@@ -377,4 +377,139 @@ class ReaderChapterBlockMeasurerTest {
         assertEquals(10f, items[0].style.fontSizePx, 0f)
         assertEquals(10f, items[1].style.fontSizePx, 0f)
     }
+
+    @Test
+    fun excludeActionImagesDropsInlineActionImagesAndKeepsPlainImages() = runBlocking {
+        var dimensionResolves = 0
+        val source = ReaderChapterSource(1, "", listOf(
+            ReaderChapterSourceBlock.Paragraph(listOf(
+                ReaderChapterInlineSource.Text("甲", 0),
+                ReaderChapterInlineSource.Image("badge.png,{\"click\":\"showCmt(1)\"}", 1),
+                ReaderChapterInlineSource.Text("乙", 2),
+                ReaderChapterInlineSource.Image("plain.png", 3),
+            ), 0),
+        ), 5)
+        val result = ReaderChapterBlockMeasurer(
+            bodyShaper = shaper,
+            titleShaper = shaper,
+            imageDimensionsResolver = {
+                dimensionResolves++; ReaderImageDimensions(12f, 12f)
+            },
+            imageOptionsResolver = { src ->
+                if ("\"click\"" in src) ReaderImageOptions(action = "showCmt(1)") else null
+            },
+        ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
+
+        val items = (result.blocks.single() as ReaderMeasuredBlock.InlineParagraph).items
+        val texts = items.filterIsInstance<ReaderMeasuredInlineItem.Text>()
+        assertEquals(listOf("甲", "乙"), texts.map { it.value })
+        assertEquals(listOf(0, 2), texts.map { it.chapterPosition })
+        assertEquals(listOf(3), items.filterIsInstance<ReaderMeasuredInlineItem.Image>()
+            .map { it.chapterPosition })
+        assertEquals(1, dimensionResolves)
+    }
+
+    @Test
+    fun excludeActionImagesFlagOffKeepsActionImages() = runBlocking {
+        val source = ReaderChapterSource(1, "", listOf(
+            ReaderChapterSourceBlock.Paragraph(listOf(
+                ReaderChapterInlineSource.Image("badge.png,{\"click\":\"js\"}", 0),
+            ), 0),
+        ), 2)
+        val result = ReaderChapterBlockMeasurer(
+            bodyShaper = shaper,
+            titleShaper = shaper,
+            imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
+            imageOptionsResolver = { ReaderImageOptions(action = "js") },
+        ).measure(source, style) as ReaderChapterMeasureResult.Success
+        assertEquals(
+            1,
+            (result.blocks.single() as ReaderMeasuredBlock.InlineParagraph)
+                .items.filterIsInstance<ReaderMeasuredInlineItem.Image>().size,
+        )
+    }
+
+    @Test
+    fun excludeActionImagesDropsStandaloneActionImageBlockAndKeepsPositions() = runBlocking {
+        var dimensionResolves = 0
+        val source = ReaderChapterSource(1, "", listOf(
+            ReaderChapterSourceBlock.Image("banner.png,{\"click\":\"js\"}", 0),
+            ReaderChapterSourceBlock.Text("正文", 1),
+        ), 4)
+        val result = ReaderChapterBlockMeasurer(
+            bodyShaper = shaper,
+            titleShaper = shaper,
+            imageDimensionsResolver = {
+                dimensionResolves++; ReaderImageDimensions(300f, 300f)
+            },
+            imageOptionsResolver = { ReaderImageOptions(action = "js") },
+        ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
+
+        assertTrue(result.blocks.none { it is ReaderMeasuredBlock.Image })
+        val body = result.blocks.filterIsInstance<ReaderMeasuredBlock.InlineParagraph>().single()
+        assertEquals(listOf(1, 2), body.items.map { it.chapterPosition })
+        assertEquals(0, dimensionResolves)
+    }
+
+    @Test
+    fun excludeActionImagesDropsBubbleOnlyParagraphEntirely() = runBlocking {
+        val source = ReaderChapterSource(1, "", listOf(
+            ReaderChapterSourceBlock.Paragraph(listOf(
+                ReaderChapterInlineSource.Image("badge.png,{\"click\":\"js\"}", 0),
+            ), 0),
+        ), 1)
+        val result = ReaderChapterBlockMeasurer(
+            bodyShaper = shaper,
+            titleShaper = shaper,
+            imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
+            imageOptionsResolver = { ReaderImageOptions(action = "js") },
+        ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
+
+        assertTrue(result.blocks.isEmpty())
+    }
+
+    @Test
+    fun excludeActionImagesSuppressesBlankPaddingLeftByDroppedImage() = runBlocking {
+        // 整行图片段自带缩进/空白填充(如「  <img>」独立成段):图被剔除后
+        // 残留的纯空白不得再排成一行空行——与「图不存在」的排版等价
+        val source = ReaderChapterSource(1, "", listOf(
+            ReaderChapterSourceBlock.Paragraph(listOf(
+                ReaderChapterInlineSource.Text("  ", 0),
+                ReaderChapterInlineSource.Image("banner.png,{\"click\":\"js\"}", 2),
+            ), 0),
+        ), 3)
+        val result = ReaderChapterBlockMeasurer(
+            bodyShaper = shaper,
+            titleShaper = shaper,
+            imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
+            imageOptionsResolver = { ReaderImageOptions(action = "js") },
+        ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
+
+        assertTrue(result.blocks.isEmpty())
+    }
+
+    @Test
+    fun excludeActionImagesKeepsRealTextInParagraphWithDroppedImage() = runBlocking {
+        // 抑制只针对「全空白 + 被剔除图」的段;同段有正文时正文照常保留
+        val source = ReaderChapterSource(1, "", listOf(
+            ReaderChapterSourceBlock.Paragraph(listOf(
+                ReaderChapterInlineSource.Text("  ", 0),
+                ReaderChapterInlineSource.Image("badge.png,{\"click\":\"js\"}", 2),
+                ReaderChapterInlineSource.Text("正文", 3),
+            ), 0),
+        ), 5)
+        val result = ReaderChapterBlockMeasurer(
+            bodyShaper = shaper,
+            titleShaper = shaper,
+            imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
+            imageOptionsResolver = { ReaderImageOptions(action = "js") },
+        ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
+
+        val paragraphs = result.blocks.filterIsInstance<ReaderMeasuredBlock.InlineParagraph>()
+        assertEquals(1, paragraphs.size)
+        val items = paragraphs.single().items
+        assertTrue(items.none { it is ReaderMeasuredInlineItem.Image })
+        assertEquals("  正文", items.filterIsInstance<ReaderMeasuredInlineItem.Text>()
+            .joinToString("") { it.value })
+    }
 }