ソースを参照

feat: 正文图片之

HapeLee 1 ヶ月 前
親
コミット
488a37506f

+ 2 - 2
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -1892,7 +1892,7 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageConte
         }
         }
 
 
         is ReaderElement.Image -> cachedImage(e)?.let { bitmap ->
         is ReaderElement.Image -> cachedImage(e)?.let { bitmap ->
-            ReaderImageDrawLayout.fitCenter(e.bounds, bitmap.width, bitmap.height)?.let { layout ->
+            ReaderImageDrawLayout.forElement(e, bitmap.width, bitmap.height)?.let { layout ->
                 drawImage(
                 drawImage(
                     image = bitmap.asImageBitmap(),
                     image = bitmap.asImageBitmap(),
                     dstOffset = IntOffset(layout.leftPx.roundToInt(), layout.topPx.roundToInt()),
                     dstOffset = IntOffset(layout.leftPx.roundToInt(), layout.topPx.roundToInt()),
@@ -2336,7 +2336,7 @@ private fun ReaderPageCanvas(
                 native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
                 native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
             }
             }
             is ReaderElement.Image -> images[e]?.let { bitmap ->
             is ReaderElement.Image -> images[e]?.let { bitmap ->
-                ReaderImageDrawLayout.fitCenter(e.bounds, bitmap.width, bitmap.height)?.let { layout ->
+                ReaderImageDrawLayout.forElement(e, bitmap.width, bitmap.height)?.let { layout ->
                     drawImage(
                     drawImage(
                         image = bitmap.asImageBitmap(),
                         image = bitmap.asImageBitmap(),
                         dstOffset = IntOffset(layout.leftPx.roundToInt(), layout.topPx.roundToInt()),
                         dstOffset = IntOffset(layout.leftPx.roundToInt(), layout.topPx.roundToInt()),

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

@@ -40,12 +40,36 @@ data class ReaderImageDimensions(val widthPx: Float, val heightPx: Float)
 
 
 enum class ReaderImageLayoutMode { AUTO, INLINE, STANDALONE, FULL_WIDTH, SINGLE_PAGE }
 enum class ReaderImageLayoutMode { AUTO, INLINE, STANDALONE, FULL_WIDTH, SINGLE_PAGE }
 
 
+/**
+ * 决定行内图**宽**的那一个字符:旧 View 的行内图占位宽不是字号,而是该字符在**本段 paint**
+ * 下的实际 advance。前两项是旧 `ChapterProvider` 插进正文的替换字;第三项是框架给 `ImageSpan`
+ * 插入的 `\uFFFC`,只用于旧 `setTypeHtml` 的行末特例。
+ */
+enum class ReaderInlineImagePlaceholder(val placeholderChar: String) {
+    /**
+     * 旧 `srcReplaceChar`(`袮`):书级 `imgStyleText`、单图 style 精确为 `"text"`,以及
+     * "小图自动嵌入"(旧 `iStyle = "text"`)。
+     */
+    SRC_REPLACE("袮"),
+
+    /** 旧 `reviewChar`(`꧁`):仅单图 style 精确为 `"TEXT"`(段评图标)。 */
+    REVIEW("꧁"),
+
+    /**
+     * 框架给 `ImageSpan` 插入的对象替换字符 `\uFFFC`。旧 `setTypeHtml` 在**行末**取不到下一字
+     * 横向位置时改用 `measureText("\uFFFC")`,因此它也是旧版行末行内图的绘制宽。
+     */
+    OBJECT_REPLACEMENT("\uFFFC"),
+}
+
 data class ReaderImageOptions(
 data class ReaderImageOptions(
     val layoutMode: ReaderImageLayoutMode? = null,
     val layoutMode: ReaderImageLayoutMode? = null,
     val requestedWidthPx: Float? = null,
     val requestedWidthPx: Float? = null,
     val requestedWidthFraction: Float? = null,
     val requestedWidthFraction: Float? = null,
     val horizontalAlignment: ReaderTextAlignment? = null,
     val horizontalAlignment: ReaderTextAlignment? = null,
     val action: String? = null,
     val action: String? = null,
+    /** 只在 [ReaderImageLayoutMode.INLINE] 下参与排版:用哪个占位字量行内图的宽。 */
+    val inlinePlaceholder: ReaderInlineImagePlaceholder = ReaderInlineImagePlaceholder.SRC_REPLACE,
 )
 )
 
 
 fun interface ReaderImageDimensionsResolver {
 fun interface ReaderImageDimensionsResolver {
@@ -53,7 +77,13 @@ fun interface ReaderImageDimensionsResolver {
 }
 }
 
 
 fun interface ReaderImageOptionsResolver {
 fun interface ReaderImageOptionsResolver {
-    fun resolve(source: String): ReaderImageOptions?
+    /**
+     * @param htmlParagraph 该图来自旧 `TextChapterLayout.setTypeHtml` 的 HTML 段落(`<usehtml>`)。
+     *   旧实现里两条路径对单图 style 的规则并不一致:HTML 用 `iStyle?.uppercase() == "TEXT"`
+     *   (不区分大小写),纯文本用字面量 `iStyle == "text" || iStyle == "TEXT"`。解析器需要知道
+     *   是替哪条路径解析,才能给出同样的结果。
+     */
+    fun resolve(source: String, htmlParagraph: Boolean): ReaderImageOptions?
 }
 }
 
 
 data class ReaderChapterMeasureStyle(
 data class ReaderChapterMeasureStyle(
@@ -77,6 +107,11 @@ data class ReaderChapterMeasureStyle(
     val letterSpacingEm: Float? = null,
     val letterSpacingEm: Float? = null,
     val bodyIndentText: String? = null,
     val bodyIndentText: String? = null,
     val imageLayoutMode: ReaderImageLayoutMode = ReaderImageLayoutMode.AUTO,
     val imageLayoutMode: ReaderImageLayoutMode = ReaderImageLayoutMode.AUTO,
+    /**
+     * 书级 `imageStyle` 为 `LEFT`/`RIGHT` 时的整图对齐。旧 `setTypeImage` 用「有效样式」
+     * (单图 style 优先)取对齐,单图 style 存在但不是 LEFT/RIGHT 时**不**回落到书级。
+     */
+    val imageAlignment: ReaderTextAlignment? = null,
     val imageAvailableWidthPx: Float? = null,
     val imageAvailableWidthPx: Float? = null,
     /** true = 带 click 动作脚本的图片(段评气泡)不参与排版:不产出测量项,也不解析图片尺寸。 */
     /** true = 带 click 动作脚本的图片(段评气泡)不参与排版:不产出测量项,也不解析图片尺寸。 */
     val excludeActionImages: Boolean = false,
     val excludeActionImages: Boolean = false,
@@ -129,7 +164,8 @@ class ReaderChapterBlockMeasurer(
     private val imageDimensionsResolver: ReaderImageDimensionsResolver,
     private val imageDimensionsResolver: ReaderImageDimensionsResolver,
     private val textShaperFactory: ReaderTextShaperFactory = ReaderTextShaperFactory { bodyShaper },
     private val textShaperFactory: ReaderTextShaperFactory = ReaderTextShaperFactory { bodyShaper },
     private val htmlSourceResolver: ReaderHtmlSourceResolver = ReaderHtmlSourceResolver { _, _ -> null },
     private val htmlSourceResolver: ReaderHtmlSourceResolver = ReaderHtmlSourceResolver { _, _ -> null },
-    private val imageOptionsResolver: ReaderImageOptionsResolver = ReaderImageOptionsResolver { null },
+    private val imageOptionsResolver: ReaderImageOptionsResolver =
+        ReaderImageOptionsResolver { _, _ -> null },
     private val metrics: ReaderChapterMeasureMetrics? = null,
     private val metrics: ReaderChapterMeasureMetrics? = null,
 ) {
 ) {
     /**
     /**
@@ -162,6 +198,19 @@ class ReaderChapterBlockMeasurer(
                 metrics?.shape(bodyShaper, bodyIndentText) ?: bodyShaper.shape(bodyIndentText)
                 metrics?.shape(bodyShaper, bodyIndentText) ?: bodyShaper.shape(bodyIndentText)
             shaped.widthsPx.sum() + (style.letterSpacingEm ?: 0f) * style.bodyStyle.fontSizePx * shaped.text.size
             shaped.widthsPx.sum() + (style.letterSpacingEm ?: 0f) * style.bodyStyle.fontSizePx * shaped.text.size
         }
         }
+        // 行内图的占位宽 = 旧 View 插入正文的占位字(`袮` / `꧁`)在**本段 paint** 下的 advance,
+        // 不是字号。同一样式只求一次,避免每张图都新建 TextPaint 再量一遍。
+        val inlinePlaceholderWidths = mutableMapOf<Pair<String, ReaderTextStyle>, Float>()
+        fun inlinePlaceholderWidth(
+            placeholder: ReaderInlineImagePlaceholder,
+            textStyle: ReaderTextStyle,
+        ): Float = inlinePlaceholderWidths.getOrPut(placeholder.placeholderChar to textStyle) {
+            val placeholderShaper = shaper(textStyle)
+            val shaped = metrics?.shape(placeholderShaper, placeholder.placeholderChar)
+                ?: placeholderShaper.shape(placeholder.placeholderChar)
+            // 字体缺字时可能量到 0 宽(旧版对应一个画不出来的零宽图),回落到字号保证至少一格。
+            shaped.widthsPx.firstOrNull()?.takeIf { it > 0f } ?: textStyle.fontSizePx
+        }
         suspend fun addStyledParagraph(
         suspend fun addStyledParagraph(
             items: List<ReaderChapterInlineSource>,
             items: List<ReaderChapterInlineSource>,
             isTitle: Boolean,
             isTitle: Boolean,
@@ -172,7 +221,12 @@ class ReaderChapterBlockMeasurer(
             restLineMarginPx: Float = 0f,
             restLineMarginPx: Float = 0f,
             alignmentOverride: ReaderTextAlignment? = null,
             alignmentOverride: ReaderTextAlignment? = null,
             decorations: List<ReaderParagraphDecoration> = emptyList(),
             decorations: List<ReaderParagraphDecoration> = emptyList(),
-            justifyAtWordBoundaries: Boolean = false,
+            /**
+             * 该段来自旧 `setTypeHtml` 的 HTML 块。它同时决定旧的整行补空格/附加行距
+             * ([ReaderMeasuredBlock.InlineParagraph.justifyAtWordBoundaries])与旧 HTML 路径
+             * 的图片规则(见 [resolveImageLayout])。
+             */
+            fromHtmlBlock: Boolean = false,
         ) {
         ) {
             val baseStyle = if (isTitle) style.titleStyle.copy(
             val baseStyle = if (isTitle) style.titleStyle.copy(
                 fontSizePx = style.titleStyle.fontSizePx * titleScale,
                 fontSizePx = style.titleStyle.fontSizePx * titleScale,
@@ -226,7 +280,8 @@ class ReaderChapterBlockMeasurer(
                     restLineIndentWidthPx = restLineMarginPx,
                     restLineIndentWidthPx = restLineMarginPx,
                     leadingIndentItems = leadingIndentItems,
                     leadingIndentItems = leadingIndentItems,
                     decorations = decorations,
                     decorations = decorations,
-                    justifyAtWordBoundaries = justifyAtWordBoundaries,
+                    // 旧 HTML 块才有整行补空格与附加行距。
+                    justifyAtWordBoundaries = fromHtmlBlock,
                     alignment = alignmentOverride ?: if (isTitle) style.titleAlignment else style.bodyAlignment,
                     alignment = alignmentOverride ?: if (isTitle) style.titleAlignment else style.bodyAlignment,
                     lineHeightPx = lineHeight ?: baseStyle.fontSizePx,
                     lineHeightPx = lineHeight ?: baseStyle.fontSizePx,
                     baselineOffsetPx = baselineOffset ?: baseStyle.fontSizePx,
                     baselineOffsetPx = baselineOffset ?: baseStyle.fontSizePx,
@@ -313,7 +368,7 @@ class ReaderChapterBlockMeasurer(
                     is ReaderChapterInlineSource.Image -> {
                     is ReaderChapterInlineSource.Image -> {
                         // excludeActionImages 开启时:带动作脚本的行内图(段评气泡)整体
                         // excludeActionImages 开启时:带动作脚本的行内图(段评气泡)整体
                         // 不参与排版,且在图片尺寸解析之前跳过(不触发任何取图请求)
                         // 不参与排版,且在图片尺寸解析之前跳过(不触发任何取图请求)
-                        val options = imageOptionsResolver.resolve(item.source)
+                        val options = imageOptionsResolver.resolve(item.source, fromHtmlBlock)
                         if (style.excludeActionImages && options?.action != null) {
                         if (style.excludeActionImages && options?.action != null) {
                             droppedActionImage = true
                             droppedActionImage = true
                             return@forEach
                             return@forEach
@@ -328,9 +383,8 @@ class ReaderChapterBlockMeasurer(
                             style.imageAvailableWidthPx?.times(fraction)
                             style.imageAvailableWidthPx?.times(fraction)
                         } ?: options?.requestedWidthPx
                         } ?: options?.requestedWidthPx
                         val size = originalSize.withWidth(requestedWidth)
                         val size = originalSize.withWidth(requestedWidth)
-                        val mode = options?.layoutMode ?: if (
-                            style.imagePageBreakBefore && style.imagePageBreakAfter
-                        ) ReaderImageLayoutMode.SINGLE_PAGE else style.imageLayoutMode
+                        val layout = style.resolveImageLayout(options, fromHtmlBlock)
+                        val mode = layout.mode
                         val standalone = mode != ReaderImageLayoutMode.INLINE && (
                         val standalone = mode != ReaderImageLayoutMode.INLINE && (
                             mode == ReaderImageLayoutMode.STANDALONE ||
                             mode == ReaderImageLayoutMode.STANDALONE ||
                             mode == ReaderImageLayoutMode.FULL_WIDTH ||
                             mode == ReaderImageLayoutMode.FULL_WIDTH ||
@@ -345,25 +399,39 @@ class ReaderChapterBlockMeasurer(
                                 intrinsicWidthPx = size.widthPx,
                                 intrinsicWidthPx = size.widthPx,
                                 intrinsicHeightPx = size.heightPx,
                                 intrinsicHeightPx = size.heightPx,
                                 chapterPosition = item.chapterPosition,
                                 chapterPosition = item.chapterPosition,
-                                action = options?.action,
-                                horizontalAlignment = options?.horizontalAlignment ?: ReaderTextAlignment.CENTER,
+                                // 旧 `setTypeImage` 建 ImageColumn 时根本没传 click:整图不带动作。
+                                action = null,
+                                horizontalAlignment = layout.alignment,
                                 scaleMode = mode.toScaleMode(),
                                 scaleMode = mode.toScaleMode(),
                                 pageBreakBefore = mode == ReaderImageLayoutMode.SINGLE_PAGE,
                                 pageBreakBefore = mode == ReaderImageLayoutMode.SINGLE_PAGE,
                                 pageBreakAfter = mode == ReaderImageLayoutMode.SINGLE_PAGE,
                                 pageBreakAfter = mode == ReaderImageLayoutMode.SINGLE_PAGE,
                             )
                             )
                         } else {
                         } else {
                             // 文字嵌入(行内图):对照旧 `TextChapterLayout` 的 ImageColumn —— 占位宽
                             // 文字嵌入(行内图):对照旧 `TextChapterLayout` 的 ImageColumn —— 占位宽
-                            // 恒为**一个字符格**(旧版就是替换字 袮/祢 的宽度),高按原图比例从该宽度
-                            // 换算,因此立图可以高于当前行;扁平宽图只占一格宽、很矮。
-                            val cellWidthPx = baseStyle.fontSizePx
-                            val aspect =
-                                size.heightPx.coerceAtLeast(1f) / size.widthPx.coerceAtLeast(1f)
+                            // 取占位字(书级文字嵌入固定 `袮`;单图 style 精确为 `"TEXT"` 时是 `꧁`)
+                            // 在**本段 paint** 下的实际 advance(不是字号),高按原图比例从该宽度换算,
+                            // 因此立图可以高于当前行;扁平宽图只占一格宽、很矮。
+                            // HTML 段落例外:旧 `setTypeHtml` 的占位宽/行盒高来自 ImageSpan 本身。
+                            val span = item.htmlSpanExtent
+                            val cellWidthPx = span?.widthPx
+                                ?: inlinePlaceholderWidth(layout.placeholder, baseStyle)
+                            val cellHeightPx = span?.heightPx ?: (
+                                    cellWidthPx * (size.heightPx.coerceAtLeast(1f) /
+                                            size.widthPx.coerceAtLeast(1f))
+                                    )
                             inline += ReaderMeasuredInlineItem.Image(
                             inline += ReaderMeasuredInlineItem.Image(
                                 source = item.source,
                                 source = item.source,
                                 widthPx = cellWidthPx,
                                 widthPx = cellWidthPx,
-                                heightPx = cellWidthPx * aspect,
+                                heightPx = cellHeightPx,
                                 chapterPosition = item.chapterPosition,
                                 chapterPosition = item.chapterPosition,
-                                action = options?.action,
+                                action = if (layout.preservesAction) options?.action else null,
+                                // 旧 `setTypeHtml` 行末图宽 = `measureText("\uFFFC")`。
+                                lineFinalWidthPx = span?.let {
+                                    inlinePlaceholderWidth(
+                                        ReaderInlineImagePlaceholder.OBJECT_REPLACEMENT,
+                                        baseStyle,
+                                    )
+                                },
                             )
                             )
                         }
                         }
                     }
                     }
@@ -393,8 +461,24 @@ class ReaderChapterBlockMeasurer(
                     }
                     }
                 }
                 }
                 is ReaderChapterSourceBlock.Image -> {
                 is ReaderChapterSourceBlock.Image -> {
-                    val options = imageOptionsResolver.resolve(block.source)
+                    // 独立图块是新 core 的概念,没有对应的旧分支,按纯文本路径规则处理。
+                    val options = imageOptionsResolver.resolve(block.source, htmlParagraph = false)
                     if (style.excludeActionImages && options?.action != null) return@forEachIndexed
                     if (style.excludeActionImages && options?.action != null) return@forEachIndexed
+                    val layout = style.resolveImageLayout(options, htmlParagraph = false)
+                    if (layout.mode == ReaderImageLayoutMode.INLINE) {
+                        // 书级文字嵌入:独立图块同样按占位字行内排版(旧版所有 <img> 一律行内)。
+                        addStyledParagraph(
+                            items = listOf(
+                                ReaderChapterInlineSource.Image(
+                                    block.source,
+                                    block.chapterPosition
+                                ),
+                            ),
+                            isTitle = false,
+                            applyBodyIndent = false,
+                        )
+                        return@forEachIndexed
+                    }
                     val placeholderExtent = (style.bodyLineHeightPx ?: style.bodyStyle.fontSizePx)
                     val placeholderExtent = (style.bodyLineHeightPx ?: style.bodyStyle.fontSizePx)
                         .coerceAtLeast(1f)
                         .coerceAtLeast(1f)
                     val originalSize = imageDimensionsResolver.resolve(block.source)
                     val originalSize = imageDimensionsResolver.resolve(block.source)
@@ -403,16 +487,15 @@ class ReaderChapterBlockMeasurer(
                         style.imageAvailableWidthPx?.times(fraction)
                         style.imageAvailableWidthPx?.times(fraction)
                     } ?: options?.requestedWidthPx
                     } ?: options?.requestedWidthPx
                     val size = originalSize.withWidth(requestedWidth)
                     val size = originalSize.withWidth(requestedWidth)
-                    val mode = options?.layoutMode ?: if (
-                        style.imagePageBreakBefore && style.imagePageBreakAfter
-                    ) ReaderImageLayoutMode.SINGLE_PAGE else style.imageLayoutMode
+                    val mode = layout.mode
                     blocks += ReaderMeasuredBlock.Image(
                     blocks += ReaderMeasuredBlock.Image(
                         source = block.source,
                         source = block.source,
                         intrinsicWidthPx = size.widthPx,
                         intrinsicWidthPx = size.widthPx,
                         intrinsicHeightPx = size.heightPx,
                         intrinsicHeightPx = size.heightPx,
                         chapterPosition = block.chapterPosition,
                         chapterPosition = block.chapterPosition,
-                        action = options?.action,
-                        horizontalAlignment = options?.horizontalAlignment ?: ReaderTextAlignment.CENTER,
+                        // 独立图块按纯文本路径走的是整图分支,旧 `setTypeImage` 不带 click。
+                        action = null,
+                        horizontalAlignment = layout.alignment,
                         scaleMode = mode.toScaleMode(),
                         scaleMode = mode.toScaleMode(),
                         pageBreakBefore = mode == ReaderImageLayoutMode.SINGLE_PAGE,
                         pageBreakBefore = mode == ReaderImageLayoutMode.SINGLE_PAGE,
                         pageBreakAfter = mode == ReaderImageLayoutMode.SINGLE_PAGE,
                         pageBreakAfter = mode == ReaderImageLayoutMode.SINGLE_PAGE,
@@ -439,7 +522,7 @@ class ReaderChapterBlockMeasurer(
                             restLineMarginPx = paragraph.restLineMarginPx,
                             restLineMarginPx = paragraph.restLineMarginPx,
                             alignmentOverride = paragraph.alignment,
                             alignmentOverride = paragraph.alignment,
                             decorations = paragraph.decorations,
                             decorations = paragraph.decorations,
-                            justifyAtWordBoundaries = true,
+                            fromHtmlBlock = true,
                         )
                         )
                     }
                     }
                 }
                 }
@@ -489,6 +572,70 @@ private fun fallbackHtmlParagraphs(
     return paragraphs
     return paragraphs
 }
 }
 
 
+/** 单张图的排版决策,行内源与独立图两个分支共用(见 [resolveImageLayout])。 */
+private data class ReaderResolvedImageLayout(
+    val mode: ReaderImageLayoutMode,
+    val placeholder: ReaderInlineImagePlaceholder,
+    val alignment: ReaderTextAlignment,
+    /**
+     * 旧版只有行内(文字嵌入)图带 click;整图走 `setTypeImage`,那里压根没用 click 参数。
+     * 另外纯文本的书级文字嵌入分支自己就没解析 JSON,连行内图也不带 click。
+     */
+    val preservesAction: Boolean,
+)
+
+/**
+ * 复刻旧 `TextChapterLayout` 的两条图片路径,它们的规则并不一致:
+ *
+ * **纯文本(`getTextChapter` 的 `else` 分支)**
+ * - 书级 `imgStyleText`([ReaderImageLayoutMode.INLINE])先整体短路:不解析单图 JSON,所有
+ *   `<img>` 一律用一个 `srcReplaceChar`(`袮`)占位行内排版。
+ * - 否则单图 style 才能覆盖书级;TEXT 只认字面量 `"text"`/`"TEXT"`,`"Text"` 走整图。
+ *
+ * **HTML 段落(`setTypeHtml`)**
+ * - 不受书级短路影响:书级 `imageStyle` 只是"单图 style 缺失"时的 fallback。
+ * - 单图 style 用 `iStyle?.uppercase() == "TEXT"`(不区分大小写)判内联。
+ * - src 里没有 `,{...}` 时直接 `setTypeImage(imageStyle)`:**不做 <80px 自动行内**,书级 TEXT
+ *   也会落到 `setTypeImage` 的 `else`(夹小整图)。
+ *
+ * `click` 也按旧版分支走:纯文本短路分支没有解析 JSON,连行内图都不带动作;整图在旧
+ * `setTypeImage` 里也没有 click 参数。HTML 段落里带 `,{...}` 的行内图才有动作。
+ */
+private fun ReaderChapterMeasureStyle.resolveImageLayout(
+    options: ReaderImageOptions?,
+    htmlParagraph: Boolean,
+): ReaderResolvedImageLayout {
+    if (!htmlParagraph && imageLayoutMode == ReaderImageLayoutMode.INLINE) {
+        return ReaderResolvedImageLayout(
+            mode = ReaderImageLayoutMode.INLINE,
+            placeholder = ReaderInlineImagePlaceholder.SRC_REPLACE,
+            alignment = ReaderTextAlignment.CENTER,
+            preservesAction = false,
+        )
+    }
+    if (htmlParagraph && options == null) {
+        return ReaderResolvedImageLayout(
+            mode = ReaderImageLayoutMode.STANDALONE,
+            placeholder = ReaderInlineImagePlaceholder.SRC_REPLACE,
+            // 旧 `setTypeImage(imageStyle)`:对齐只认书级样式,默认居中。
+            alignment = imageAlignment ?: ReaderTextAlignment.CENTER,
+            preservesAction = false,
+        )
+    }
+    val mode = options?.layoutMode ?: if (imagePageBreakBefore && imagePageBreakAfter) {
+        ReaderImageLayoutMode.SINGLE_PAGE
+    } else {
+        imageLayoutMode
+    }
+    return ReaderResolvedImageLayout(
+        mode = mode,
+        placeholder = options?.inlinePlaceholder ?: ReaderInlineImagePlaceholder.SRC_REPLACE,
+        // 旧版对齐取自"有效样式"(单图 style 优先,缺失才回落到书级),不是链式兜底。
+        alignment = options?.horizontalAlignment ?: imageAlignment ?: ReaderTextAlignment.CENTER,
+        preservesAction = true,
+    )
+}
+
 private fun ReaderImageDimensions.withWidth(requestedWidthPx: Float?): ReaderImageDimensions {
 private fun ReaderImageDimensions.withWidth(requestedWidthPx: Float?): ReaderImageDimensions {
     val width = requestedWidthPx?.takeIf { it > 0f && widthPx > 0f } ?: return this
     val width = requestedWidthPx?.takeIf { it > 0f && widthPx > 0f } ?: return this
     return ReaderImageDimensions(width, heightPx * width / widthPx)
     return ReaderImageDimensions(width, heightPx * width / widthPx)

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

@@ -220,6 +220,12 @@ sealed interface ReaderMeasuredInlineItem {
         val heightPx: Float,
         val heightPx: Float,
         override val chapterPosition: Int,
         override val chapterPosition: Int,
         val action: String? = null,
         val action: String? = null,
+        /**
+         * 旧 `setTypeHtml` 的**行末**特例:`charRight` 取不到下一字的横向位置时改用
+         * `measureText("\uFFFC")`,于是落在行末的行内图比行中更窄(旧版同时留下空档)。
+         * 非 `null` 时只影响绘制宽度,断行推进仍用 [widthPx](旧版布局推进用的是 span advance)。
+         */
+        val lineFinalWidthPx: Float? = null,
     ) : ReaderMeasuredInlineItem
     ) : ReaderMeasuredInlineItem
 }
 }
 
 
@@ -962,16 +968,23 @@ internal class ReaderPaginationSession(
 
 
                     is ReaderMeasuredInlineItem.Image -> {
                     is ReaderMeasuredInlineItem.Image -> {
                         val imageTop = y + (actualLineHeight - item.heightPx) / 2f
                         val imageTop = y + (actualLineHeight - item.heightPx) / 2f
+                        // 旧 `setTypeHtml` 行末图用 `measureText("\uFFFC")` 当宽度(行中才用 span
+                        // advance);断行推进仍按 item.widthPx,因此行末会像旧版一样留出空档。
+                        val drawnWidthPx = item.lineFinalWidthPx
+                            ?.takeIf { itemIndex == itemCount - 1 }
+                            ?: item.widthPx
                         elements += ReaderElement.Image(
                         elements += ReaderElement.Image(
                             bounds = ReaderRect(
                             bounds = ReaderRect(
                                 x,
                                 x,
                                 imageTop,
                                 imageTop,
-                                x + item.widthPx,
+                                x + drawnWidthPx,
                                 imageTop + item.heightPx
                                 imageTop + item.heightPx
                             ),
                             ),
                             source = item.source,
                             source = item.source,
                             action = item.action,
                             action = item.action,
                             chapterPosition = item.chapterPosition,
                             chapterPosition = item.chapterPosition,
+                            // 行内图必须带标记:绘制期按位图长宽比重新算几何(旧 ImageColumn)。
+                            inline = true,
                         )
                         )
                     }
                     }
                 }
                 }

+ 47 - 1
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderImageDrawLayout.kt

@@ -1,6 +1,13 @@
 package io.legado.app.feature.reader.core.model
 package io.legado.app.feature.reader.core.model
 
 
-/** Aspect-preserving destination geometry for reader images and error placeholders. */
+import io.legado.app.feature.reader.core.model.ReaderImageDrawLayout.Companion.fitCenter
+import io.legado.app.feature.reader.core.model.ReaderImageDrawLayout.Companion.inlineCell
+
+
+/**
+ * 阅读器图片与错误占位图的绘制几何。整图(standalone)按比例内接居中([fitCenter]);
+ * 文字嵌入的行内图按旧 `ImageColumn` 占满一个字符格宽、高随实际位图长宽比([inlineCell])。
+ */
 data class ReaderImageDrawLayout(
 data class ReaderImageDrawLayout(
     val leftPx: Float,
     val leftPx: Float,
     val topPx: Float,
     val topPx: Float,
@@ -30,5 +37,44 @@ data class ReaderImageDrawLayout(
                 heightPx = height,
                 heightPx = height,
             )
             )
         }
         }
+
+        /**
+         * 文字嵌入的行内图几何,对照旧 View `ImageColumn.draw`:
+         *
+         * ```kotlin
+         * val h = (end - start) / bitmap.width * bitmap.height // 宽 = 一个字符格
+         * val div = (height - h) / 2                         // height = 行盒高
+         * RectF(start, div, end, height - div)               // div < 0 时允许高于当前行
+         * ```
+         *
+         * 宽恒等于 [container](即一个字符格),高按**实际加载到的位图**长宽比换算,`div` 为负
+         * 时向上下对称溢出。绝不内接留白——旧实现里图片永远精确占满一个字符格宽,用
+         * [fitCenter] 代替会让它在测量期长宽比与位图不一致时被画小。
+         */
+        fun inlineCell(
+            container: ReaderRect,
+            imageWidthPx: Int,
+            imageHeightPx: Int,
+        ): ReaderImageDrawLayout? {
+            if (container.width <= 0f || imageWidthPx <= 0 || imageHeightPx <= 0) return null
+            val height = container.width * imageHeightPx / imageWidthPx
+            return ReaderImageDrawLayout(
+                leftPx = container.left,
+                topPx = container.top + (container.height - height) / 2f,
+                widthPx = container.width,
+                heightPx = height,
+            )
+        }
+
+        /** 行内图走旧 `ImageColumn` 的字符格几何;整图保持按比例内接居中。 */
+        fun forElement(
+            element: ReaderElement.Image,
+            imageWidthPx: Int,
+            imageHeightPx: Int,
+        ): ReaderImageDrawLayout? = if (element.inline) {
+            inlineCell(element.bounds, imageWidthPx, imageHeightPx)
+        } else {
+            fitCenter(element.bounds, imageWidthPx, imageHeightPx)
+        }
     }
     }
 }
 }

+ 9 - 0
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPage.kt

@@ -117,6 +117,15 @@ sealed interface ReaderElement {
         val source: String,
         val source: String,
         val action: String?,
         val action: String?,
         val chapterPosition: Int = 0,
         val chapterPosition: Int = 0,
+        /**
+         * 文字嵌入(行内图),对照旧 View `TextChapterLayout` 的 `ImageColumn`:**宽恒为一个
+         * 字符格**,高按实际加载到的位图长宽比换算,竖直居中于行盒且允许高于当前行。
+         *
+         * [bounds] 里的高是测量期由 `imageDimensionsResolver` 给出的长宽比,只用于行盒预留;
+         * 绘制期必须按位图重算(见 `ReaderImageDrawLayout.forElement`),否则测量期长宽比与
+         * 位图不一致时 `fitCenter` 会在格内留白、把图片画小。
+         */
+        val inline: Boolean = false,
     ) : ReaderElement
     ) : ReaderElement
 
 
     data class Review(
     data class Review(

+ 22 - 1
app/src/main/java/io/legado/app/feature/reader/core/source/ReaderChapterSource.kt

@@ -38,10 +38,31 @@ sealed interface ReaderChapterInlineSource {
         override val chapterPosition: Int,
         override val chapterPosition: Int,
         val style: ReaderInlineSourceStyle = ReaderInlineSourceStyle(),
         val style: ReaderInlineSourceStyle = ReaderInlineSourceStyle(),
     ) : ReaderChapterInlineSource
     ) : ReaderChapterInlineSource
-    data class Image(val source: String, override val chapterPosition: Int) : ReaderChapterInlineSource
+    data class Image(
+        val source: String,
+        override val chapterPosition: Int,
+        /**
+         * 只有 HTML 段落(旧 `setTypeHtml`)的图片有:ImageSpan 在 `StaticLayout` 里的占位尺寸。
+         * 旧实现的行内图**宽**取 span 的 advance、**行盒高**取 span 的 ascent/descent,都不是
+         * 占位字 advance;纯文本路径(`getTextChapter`)为 `null`,走占位字规则。
+         */
+        val htmlSpanExtent: ReaderHtmlImageSpanExtent? = null,
+    ) : ReaderChapterInlineSource
     data class BlankLine(override val chapterPosition: Int) : ReaderChapterInlineSource
     data class BlankLine(override val chapterPosition: Int) : ReaderChapterInlineSource
 }
 }
 
 
+/**
+ * 旧 `setTypeHtml` 里 `ImageSpan` 的占位尺寸。
+ *
+ * - [widthPx]:`getPrimaryHorizontal(i + 1) - getPrimaryHorizontal(i)`,即 span 的 advance
+ *   (没有 ImageGetter 时框架用系统 fallback 图标的固有宽)。
+ * - [heightPx]:该 span 让静态布局行盒至少长到的高度(`getSize` 里 `ascent = -bounds.bottom`)。
+ *
+ * 旧版行内图的绘制几何是「宽 = 该 advance,高 = 宽 ÷ 位图宽 × 位图高,在行盒内居中」,行盒由
+ * [heightPx] 撑开;新 core 用同样两个数就能复刻。
+ */
+data class ReaderHtmlImageSpanExtent(val widthPx: Float, val heightPx: Float)
+
 data class ReaderInlineSourceStyle(
 data class ReaderInlineSourceStyle(
     val colorArgb: Int? = null,
     val colorArgb: Int? = null,
     val backgroundArgb: Int? = null,
     val backgroundArgb: Int? = null,

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

@@ -127,12 +127,19 @@ object LegacyReaderChapterPaginator {
         val tracing = ReaderPerfTrace.isEnabled()
         val tracing = ReaderPerfTrace.isEnabled()
         val preparationStartNs = if (tracing) System.nanoTime() else 0L
         val preparationStartNs = if (tracing) System.nanoTime() else 0L
         val measureMetrics = if (tracing) ReaderChapterMeasureMetrics(System::nanoTime) else null
         val measureMetrics = if (tracing) ReaderChapterMeasureMetrics(System::nanoTime) else null
-        val imageLayoutMode = when (book.getImageStyle()?.uppercase()) {
+        val bookImageStyle = book.getImageStyle()?.uppercase()
+        val imageLayoutMode = when (bookImageStyle) {
             Book.imgStyleText -> ReaderImageLayoutMode.INLINE
             Book.imgStyleText -> ReaderImageLayoutMode.INLINE
             Book.imgStyleFull -> ReaderImageLayoutMode.FULL_WIDTH
             Book.imgStyleFull -> ReaderImageLayoutMode.FULL_WIDTH
             Book.imgStyleSingle -> ReaderImageLayoutMode.SINGLE_PAGE
             Book.imgStyleSingle -> ReaderImageLayoutMode.SINGLE_PAGE
             else -> ReaderImageLayoutMode.AUTO
             else -> ReaderImageLayoutMode.AUTO
         }
         }
+        // 旧 `setTypeImage` 的整图对齐也认书级 `LEFT`/`RIGHT`(单图 style 缺失时的回落)。
+        val imageAlignment = when (bookImageStyle) {
+            "LEFT" -> ReaderTextAlignment.START
+            "RIGHT" -> ReaderTextAlignment.END
+            else -> null
+        }
         val singleImage = imageLayoutMode == ReaderImageLayoutMode.SINGLE_PAGE
         val singleImage = imageLayoutMode == ReaderImageLayoutMode.SINGLE_PAGE
         val layoutSource = source.withTitleVisibility(
         val layoutSource = source.withTitleVisibility(
             ReadBookConfig.titleMode != 2 || chapter.isVolume || content.textList.isEmpty(),
             ReadBookConfig.titleMode != 2 || chapter.isVolume || content.textList.isEmpty(),
@@ -240,6 +247,7 @@ object LegacyReaderChapterPaginator {
                 imagePageBreakAfter = singleImage,
                 imagePageBreakAfter = singleImage,
                 titlePageBreakAfter = singleImage && content.textList.isNotEmpty(),
                 titlePageBreakAfter = singleImage && content.textList.isNotEmpty(),
                 imageLayoutMode = imageLayoutMode,
                 imageLayoutMode = imageLayoutMode,
+                imageAlignment = imageAlignment,
                 excludeActionImages = paginationStyle.excludeActionImages,
                 excludeActionImages = paginationStyle.excludeActionImages,
                 imageAvailableWidthPx = (
                 imageAvailableWidthPx = (
                     viewportWidthPx / paginationStyle.columnCount(viewportWidthPx, viewportHeightPx) -
                     viewportWidthPx / paginationStyle.columnCount(viewportWidthPx, viewportHeightPx) -

+ 33 - 8
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderImageOptionsResolver.kt

@@ -3,6 +3,7 @@ package io.legado.app.feature.reader.legacy
 import io.legado.app.feature.reader.core.layout.ReaderImageLayoutMode
 import io.legado.app.feature.reader.core.layout.ReaderImageLayoutMode
 import io.legado.app.feature.reader.core.layout.ReaderImageOptions
 import io.legado.app.feature.reader.core.layout.ReaderImageOptions
 import io.legado.app.feature.reader.core.layout.ReaderImageOptionsResolver
 import io.legado.app.feature.reader.core.layout.ReaderImageOptionsResolver
+import io.legado.app.feature.reader.core.layout.ReaderInlineImagePlaceholder
 import io.legado.app.feature.reader.core.layout.ReaderTextAlignment
 import io.legado.app.feature.reader.core.layout.ReaderTextAlignment
 import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
 import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
@@ -10,31 +11,55 @@ import io.legado.app.utils.fromJsonObject
 
 
 /** Keeps source-URL JSON parsing at the legacy input boundary; the Canvas core receives typed options. */
 /** Keeps source-URL JSON parsing at the legacy input boundary; the Canvas core receives typed options. */
 object LegacyReaderImageOptionsResolver : ReaderImageOptionsResolver {
 object LegacyReaderImageOptionsResolver : ReaderImageOptionsResolver {
-    override fun resolve(source: String): ReaderImageOptions? {
+    /** 与 `Book.imgStyleText` 一致;这里不引入数据层依赖,靠单测钉住取值。 */
+    private const val imgStyleText = "TEXT"
+    private const val imgStyleTextLowercase = "text"
+
+    override fun resolve(source: String, htmlParagraph: Boolean): ReaderImageOptions? {
         val separator = paramPattern.find(source) ?: return null
         val separator = paramPattern.find(source) ?: return null
         val values = GSON.fromJsonObject<Map<String, String>>(
         val values = GSON.fromJsonObject<Map<String, String>>(
             source.substring(separator.range.last + 1),
             source.substring(separator.range.last + 1),
         ).getOrNull() ?: return null
         ).getOrNull() ?: return null
-        val style = values["style"]?.uppercase()
+        val rawStyle = values["style"]
+        val style = rawStyle?.uppercase()
         val width = values["width"]
         val width = values["width"]
         return ReaderImageOptions(
         return ReaderImageOptions(
-            layoutMode = when (style) {
-                null -> null
-                "TEXT" -> ReaderImageLayoutMode.INLINE
-                "FULL" -> ReaderImageLayoutMode.FULL_WIDTH
-                "SINGLE" -> ReaderImageLayoutMode.SINGLE_PAGE
+            layoutMode = when {
+                rawStyle == null -> null
+                // 两条旧路径对"文字嵌入"的判据不同:
+                // - HTML(`setTypeHtml`)用 `iStyle?.uppercase() == "TEXT"`,不区分大小写;
+                // - 纯文本(`getTextChapter`)用字面量 `iStyle == "text" || iStyle == "TEXT"`,
+                //   "Text"、"TEXT " 等拼写会走 `setTypeImage` 按整图排版。
+                textIsInline(rawStyle, htmlParagraph) -> ReaderImageLayoutMode.INLINE
+                // FULL/SINGLE 两条路径都是 `imageStyle?.uppercase()` 比较,不区分大小写。
+                style == "FULL" -> ReaderImageLayoutMode.FULL_WIDTH
+                style == "SINGLE" -> ReaderImageLayoutMode.SINGLE_PAGE
                 else -> ReaderImageLayoutMode.STANDALONE
                 else -> ReaderImageLayoutMode.STANDALONE
             },
             },
             requestedWidthPx = width?.takeUnless { it.endsWith('%') }?.toFloatOrNull()
             requestedWidthPx = width?.takeUnless { it.endsWith('%') }?.toFloatOrNull()
                 ?.takeIf { it > 0f },
                 ?.takeIf { it > 0f },
             requestedWidthFraction = width?.takeIf { it.endsWith('%') }
             requestedWidthFraction = width?.takeIf { it.endsWith('%') }
                 ?.dropLast(1)?.toFloatOrNull()?.div(100f)?.takeIf { it > 0f },
                 ?.dropLast(1)?.toFloatOrNull()?.div(100f)?.takeIf { it > 0f },
+            // 旧 `setTypeImage` 的对齐取自"有效样式":单图 style 存在就只用它(不是 LEFT/RIGHT 即
+            // 居中,**不**回落到书级);缺失时才用书级样式。null = 未指定,由测量层回落书级。
             horizontalAlignment = when (style) {
             horizontalAlignment = when (style) {
                 "LEFT" -> ReaderTextAlignment.START
                 "LEFT" -> ReaderTextAlignment.START
                 "RIGHT" -> ReaderTextAlignment.END
                 "RIGHT" -> ReaderTextAlignment.END
-                else -> null
+                else -> if (rawStyle == null) null else ReaderTextAlignment.CENTER
             },
             },
             action = values["click"]?.takeIf(String::isNotBlank),
             action = values["click"]?.takeIf(String::isNotBlank),
+            // 占位字只是**纯文本**路径的概念:`"text"` 用 `srcReplaceChar`(袮)、`"TEXT"` 用
+            // `reviewChar`(꧁)。旧 HTML 路径的行内图宽来自 ImageSpan 的 advance(没有占位字),
+            // 这里取纯文本默认的袮。
+            inlinePlaceholder = if (!htmlParagraph && rawStyle == imgStyleText) {
+                ReaderInlineImagePlaceholder.REVIEW
+            } else {
+                ReaderInlineImagePlaceholder.SRC_REPLACE
+            },
         )
         )
     }
     }
+
+    private fun textIsInline(rawStyle: String, htmlParagraph: Boolean): Boolean =
+        if (htmlParagraph) rawStyle.uppercase() == imgStyleText
+        else rawStyle == imgStyleText || rawStyle == imgStyleTextLowercase
 }
 }

+ 32 - 14
app/src/main/java/io/legado/app/feature/reader/platform/AndroidReaderHtmlSourceResolver.kt

@@ -1,14 +1,11 @@
 package io.legado.app.feature.reader.platform
 package io.legado.app.feature.reader.platform
 
 
-import android.graphics.Color
 import android.graphics.Typeface
 import android.graphics.Typeface
-import android.graphics.drawable.ColorDrawable
 import android.os.Build
 import android.os.Build
-import android.text.Html
-import android.text.Spanned
 import android.text.Layout
 import android.text.Layout
-import android.text.style.AlignmentSpan
+import android.text.Spanned
 import android.text.style.AbsoluteSizeSpan
 import android.text.style.AbsoluteSizeSpan
+import android.text.style.AlignmentSpan
 import android.text.style.BackgroundColorSpan
 import android.text.style.BackgroundColorSpan
 import android.text.style.BulletSpan
 import android.text.style.BulletSpan
 import android.text.style.ForegroundColorSpan
 import android.text.style.ForegroundColorSpan
@@ -16,22 +13,23 @@ import android.text.style.ImageSpan
 import android.text.style.LeadingMarginSpan
 import android.text.style.LeadingMarginSpan
 import android.text.style.QuoteSpan
 import android.text.style.QuoteSpan
 import android.text.style.RelativeSizeSpan
 import android.text.style.RelativeSizeSpan
-import android.text.style.StyleSpan
 import android.text.style.StrikethroughSpan
 import android.text.style.StrikethroughSpan
+import android.text.style.StyleSpan
 import android.text.style.SubscriptSpan
 import android.text.style.SubscriptSpan
 import android.text.style.SuperscriptSpan
 import android.text.style.SuperscriptSpan
 import android.text.style.TypefaceSpan
 import android.text.style.TypefaceSpan
-import android.text.style.UnderlineSpan
 import android.text.style.URLSpan
 import android.text.style.URLSpan
+import android.text.style.UnderlineSpan
 import androidx.core.text.HtmlCompat
 import androidx.core.text.HtmlCompat
-import io.legado.app.feature.reader.core.layout.ReaderHtmlSourceResolver
 import io.legado.app.feature.reader.core.layout.ReaderHtmlParagraph
 import io.legado.app.feature.reader.core.layout.ReaderHtmlParagraph
-import io.legado.app.feature.reader.core.layout.ReaderTextAlignment
+import io.legado.app.feature.reader.core.layout.ReaderHtmlSourceResolver
 import io.legado.app.feature.reader.core.layout.ReaderParagraphDecoration
 import io.legado.app.feature.reader.core.layout.ReaderParagraphDecoration
 import io.legado.app.feature.reader.core.layout.ReaderParagraphDecorationKind
 import io.legado.app.feature.reader.core.layout.ReaderParagraphDecorationKind
+import io.legado.app.feature.reader.core.layout.ReaderTextAlignment
 import io.legado.app.feature.reader.core.source.ReaderChapterInlineSource
 import io.legado.app.feature.reader.core.source.ReaderChapterInlineSource
-import io.legado.app.feature.reader.core.source.ReaderInlineSourceStyle
+import io.legado.app.feature.reader.core.source.ReaderHtmlImageSpanExtent
 import io.legado.app.feature.reader.core.source.ReaderHtmlSemanticTextResolver
 import io.legado.app.feature.reader.core.source.ReaderHtmlSemanticTextResolver
+import io.legado.app.feature.reader.core.source.ReaderInlineSourceStyle
 
 
 object AndroidReaderHtmlSemanticTextResolver : ReaderHtmlSemanticTextResolver {
 object AndroidReaderHtmlSemanticTextResolver : ReaderHtmlSemanticTextResolver {
     override fun resolve(html: String): String = parseReaderHtml(html).toString()
     override fun resolve(html: String): String = parseReaderHtml(html).toString()
@@ -113,7 +111,13 @@ class AndroidReaderHtmlSourceResolver(
             }
             }
             val image = text.getSpans(index, index + 1, ImageSpan::class.java).firstOrNull()
             val image = text.getSpans(index, index + 1, ImageSpan::class.java).firstOrNull()
             if (image != null) {
             if (image != null) {
-                image.source?.let { paragraph += ReaderChapterInlineSource.Image(it, position) }
+                image.source?.let {
+                    paragraph += ReaderChapterInlineSource.Image(
+                        source = it,
+                        chapterPosition = position,
+                        htmlSpanExtent = image.htmlSpanExtent(),
+                    )
+                }
                 index++
                 index++
                 position++
                 position++
                 continue
                 continue
@@ -171,8 +175,22 @@ private fun Layout.Alignment.toReaderAlignment(): ReaderTextAlignment = when (th
 private fun parseReaderHtml(html: String): Spanned = HtmlCompat.fromHtml(
 private fun parseReaderHtml(html: String): Spanned = HtmlCompat.fromHtml(
     html,
     html,
     HtmlCompat.FROM_HTML_MODE_COMPACT,
     HtmlCompat.FROM_HTML_MODE_COMPACT,
-    Html.ImageGetter {
-        ColorDrawable(Color.TRANSPARENT).apply { setBounds(0, 0, 1, 1) }
-    },
+    // 与旧 `setTypeHtml` 的 `parseAsHtml(FROM_HTML_MODE_COMPACT)` 完全一致:不给 ImageGetter,
+    // 让框架用系统 fallback 图标的固有尺寸生成 ImageSpan——旧版行内图的占位宽/行盒高就取自它。
+    null,
     null,
     null,
 )
 )
+
+/**
+ * 旧 `setTypeHtml` 的图片占位尺寸:span 的宽(advance)与它给静态布局行盒带来的高。
+ * 框架给 fallback drawable 设的就是固有边界,因此这里读 bounds 与旧版 StaticLayout 量到的一致。
+ * 资源解析失败(如宿主无系统 drawable)时返回 null,回落占位字规则。
+ */
+private fun ImageSpan.htmlSpanExtent(): ReaderHtmlImageSpanExtent? {
+    val bounds = runCatching { drawable.bounds }.getOrNull() ?: return null
+    if (bounds.width() <= 0 || bounds.height() <= 0) return null
+    return ReaderHtmlImageSpanExtent(
+        widthPx = bounds.width().toFloat(),
+        heightPx = bounds.height().toFloat(),
+    )
+}

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

@@ -5,6 +5,7 @@ import io.legado.app.feature.reader.core.source.ReaderChapterInlineSource
 import io.legado.app.feature.reader.core.source.ReaderChapterSource
 import io.legado.app.feature.reader.core.source.ReaderChapterSource
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceBlock
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceBlock
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
+import io.legado.app.feature.reader.core.source.ReaderHtmlImageSpanExtent
 import io.legado.app.feature.reader.core.source.ReaderInlineSourceStyle
 import io.legado.app.feature.reader.core.source.ReaderInlineSourceStyle
 import io.legado.app.feature.reader.core.source.ReaderTitleSegmentation
 import io.legado.app.feature.reader.core.source.ReaderTitleSegmentation
 import io.legado.app.feature.reader.core.style.ReaderCharacterStyle
 import io.legado.app.feature.reader.core.style.ReaderCharacterStyle
@@ -104,6 +105,23 @@ class ReaderChapterBlockMeasurerTest {
         titleAlignment = ReaderTextAlignment.CENTER,
         titleAlignment = ReaderTextAlignment.CENTER,
     )
     )
 
 
+    /** 旧 `setTypeHtml` 路径的最小脚手架:一个 HTML 块 + 假 htmlSourceResolver。 */
+    private suspend fun measureHtml(
+        items: List<ReaderChapterInlineSource>,
+        style: ReaderChapterMeasureStyle,
+        options: ReaderImageOptions?,
+        dimensions: ReaderImageDimensions,
+    ): ReaderChapterMeasureResult.Success = ReaderChapterBlockMeasurer(
+        bodyShaper = shaper,
+        titleShaper = shaper,
+        imageDimensionsResolver = { dimensions },
+        imageOptionsResolver = { _, _ -> options },
+        htmlSourceResolver = ReaderHtmlSourceResolver { _, _ -> listOf(ReaderHtmlParagraph(items)) },
+    ).measure(
+        ReaderChapterSource(1, "", listOf(ReaderChapterSourceBlock.Html("<img>", 5)), 6),
+        style,
+    ) as ReaderChapterMeasureResult.Success
+
     @Test
     @Test
     fun measuresTextAndImagesWithoutLegacyLayoutModels() = runBlocking {
     fun measuresTextAndImagesWithoutLegacyLayoutModels() = runBlocking {
         val source = ReaderChapterSource(1, "章", listOf(
         val source = ReaderChapterSource(1, "章", listOf(
@@ -238,6 +256,98 @@ class ReaderChapterBlockMeasurerTest {
         assertEquals(28f, image.intrinsicHeightPx, 0f)
         assertEquals(28f, image.intrinsicHeightPx, 0f)
     }
     }
 
 
+    /**
+     * 旧 `TextChapterLayout` 的书级文字嵌入分支不解析单图 JSON:独立图块与行内图一样按占位字
+     * 排版,单图 `"style":"FULL"` 也不能把它升级成整图。
+     */
+    @Test
+    fun `book level text mode lays standalone image blocks out inline`() = runBlocking {
+        val source = ReaderChapterSource(
+            1, "", listOf(
+                ReaderChapterSourceBlock.Image("banner", 0),
+            ), 1
+        )
+        val result = ReaderChapterBlockMeasurer(
+            bodyShaper = shaper,
+            titleShaper = shaper,
+            imageDimensionsResolver = { ReaderImageDimensions(100f, 50f) },
+            imageOptionsResolver = { _, _ -> ReaderImageOptions(ReaderImageLayoutMode.FULL_WIDTH) },
+        ).measure(
+            source,
+            style.copy(imageLayoutMode = ReaderImageLayoutMode.INLINE),
+        ) as ReaderChapterMeasureResult.Success
+
+        val paragraph = result.blocks.single() as ReaderMeasuredBlock.InlineParagraph
+        val image = paragraph.items.single() as ReaderMeasuredInlineItem.Image
+        // 占位宽 = 袮(本测试 shaper 统一给 10f),高按原图比例 100×50 → 5f。
+        assertEquals(10f, image.widthPx, 0f)
+        assertEquals(5f, image.heightPx, 0f)
+    }
+
+    /**
+     * 旧 `setTypeHtml` 不受书级 `imgStyleText` 短路:书级样式只是"单图 style 缺失"时的 fallback,
+     * 单图 `"style":"FULL"` 仍能把 HTML 段落里的图抬成整图。
+     */
+    @Test
+    fun `html block image overrides the book level text mode`() = runBlocking {
+        val result = measureHtml(
+            items = listOf(ReaderChapterInlineSource.Image("banner,{\"style\":\"full\"}", 5)),
+            style = style.copy(imageLayoutMode = ReaderImageLayoutMode.INLINE),
+            options = ReaderImageOptions(ReaderImageLayoutMode.FULL_WIDTH),
+            dimensions = ReaderImageDimensions(100f, 50f),
+        )
+
+        val image = result.blocks.single() as ReaderMeasuredBlock.Image
+        assertEquals(ReaderImageScaleMode.FIT_WIDTH, image.scaleMode)
+        assertEquals(100f, image.intrinsicWidthPx, 0f)
+    }
+
+    /**
+     * 旧 `setTypeHtml` 的行内图占位宽/行盒高取自 ImageSpan 本身(`getPrimaryHorizontal` 差值与
+     * span 的 ascent),不是占位字 advance;带 `",{...}"` 且 style 为 text 时按它排版。
+     */
+    @Test
+    fun `html block inline image uses the image span extent`() = runBlocking {
+        val result = measureHtml(
+            items = listOf(
+                ReaderChapterInlineSource.Image(
+                    source = "icon,{\"style\":\"text\"}",
+                    chapterPosition = 5,
+                    htmlSpanExtent = ReaderHtmlImageSpanExtent(widthPx = 40f, heightPx = 24f),
+                ),
+            ),
+            style = style,
+            options = ReaderImageOptions(ReaderImageLayoutMode.INLINE),
+            dimensions = ReaderImageDimensions(100f, 50f),
+        )
+
+        val image = (result.blocks.single() as ReaderMeasuredBlock.InlineParagraph)
+            .items.single() as ReaderMeasuredInlineItem.Image
+        // 宽 = span advance(40f,不是占位字 10f);高 = span 撑开的行盒高(24f)。
+        assertEquals(40f, image.widthPx, 0f)
+        assertEquals(24f, image.heightPx, 0f)
+        // 旧 `setTypeHtml` 行末图宽 = `measureText("\uFFFC")`(本测试 shaper 统一给 10f)。
+        assertEquals(10f, image.lineFinalWidthPx!!, 0f)
+    }
+
+    /**
+     * 旧 `setTypeHtml` 对**没有 `,{...}`** 的图直接 `setTypeImage(imageStyle)`:不做 <80px 自动
+     * 行内,小图也按整图排版(书级 TEXT 也落到 `setTypeImage` 的 else 分支)。
+     */
+    @Test
+    fun `html block image without options never auto inlines as one character`() = runBlocking {
+        val result = measureHtml(
+            items = listOf(ReaderChapterInlineSource.Image("plain.png", 5)),
+            style = style.copy(imageLayoutMode = ReaderImageLayoutMode.INLINE),
+            options = null,
+            dimensions = ReaderImageDimensions(12f, 12f),
+        )
+
+        val image = result.blocks.single() as ReaderMeasuredBlock.Image
+        assertEquals(12f, image.intrinsicWidthPx, 0f)
+        assertEquals(ReaderImageScaleMode.CONTAIN_NO_UPSCALE, image.scaleMode)
+    }
+
     @Test
     @Test
     fun convertsResolvedHtmlStylesIntoCanvasTextStyles() = runBlocking {
     fun convertsResolvedHtmlStylesIntoCanvasTextStyles() = runBlocking {
         val source = ReaderChapterSource(1, "", listOf(ReaderChapterSourceBlock.Html("<b>x</b>", 5)), 7)
         val source = ReaderChapterSource(1, "", listOf(ReaderChapterSourceBlock.Html("<b>x</b>", 5)), 7)
@@ -417,7 +527,7 @@ class ReaderChapterBlockMeasurerTest {
             imageDimensionsResolver = {
             imageDimensionsResolver = {
                 dimensionResolves++; ReaderImageDimensions(12f, 12f)
                 dimensionResolves++; ReaderImageDimensions(12f, 12f)
             },
             },
-            imageOptionsResolver = { src ->
+            imageOptionsResolver = { src, _ ->
                 if ("\"click\"" in src) ReaderImageOptions(action = "showCmt(1)") else null
                 if ("\"click\"" in src) ReaderImageOptions(action = "showCmt(1)") else null
             },
             },
         ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
         ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
@@ -442,7 +552,7 @@ class ReaderChapterBlockMeasurerTest {
             bodyShaper = shaper,
             bodyShaper = shaper,
             titleShaper = shaper,
             titleShaper = shaper,
             imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
             imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
-            imageOptionsResolver = { ReaderImageOptions(action = "js") },
+            imageOptionsResolver = { _, _ -> ReaderImageOptions(action = "js") },
         ).measure(source, style) as ReaderChapterMeasureResult.Success
         ).measure(source, style) as ReaderChapterMeasureResult.Success
         assertEquals(
         assertEquals(
             1,
             1,
@@ -464,7 +574,7 @@ class ReaderChapterBlockMeasurerTest {
             imageDimensionsResolver = {
             imageDimensionsResolver = {
                 dimensionResolves++; ReaderImageDimensions(300f, 300f)
                 dimensionResolves++; ReaderImageDimensions(300f, 300f)
             },
             },
-            imageOptionsResolver = { ReaderImageOptions(action = "js") },
+            imageOptionsResolver = { _, _ -> ReaderImageOptions(action = "js") },
         ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
         ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
 
 
         assertTrue(result.blocks.none { it is ReaderMeasuredBlock.Image })
         assertTrue(result.blocks.none { it is ReaderMeasuredBlock.Image })
@@ -484,7 +594,7 @@ class ReaderChapterBlockMeasurerTest {
             bodyShaper = shaper,
             bodyShaper = shaper,
             titleShaper = shaper,
             titleShaper = shaper,
             imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
             imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
-            imageOptionsResolver = { ReaderImageOptions(action = "js") },
+            imageOptionsResolver = { _, _ -> ReaderImageOptions(action = "js") },
         ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
         ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
 
 
         assertTrue(result.blocks.isEmpty())
         assertTrue(result.blocks.isEmpty())
@@ -504,7 +614,7 @@ class ReaderChapterBlockMeasurerTest {
             bodyShaper = shaper,
             bodyShaper = shaper,
             titleShaper = shaper,
             titleShaper = shaper,
             imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
             imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
-            imageOptionsResolver = { ReaderImageOptions(action = "js") },
+            imageOptionsResolver = { _, _ -> ReaderImageOptions(action = "js") },
         ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
         ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
 
 
         assertTrue(result.blocks.isEmpty())
         assertTrue(result.blocks.isEmpty())
@@ -524,7 +634,7 @@ class ReaderChapterBlockMeasurerTest {
             bodyShaper = shaper,
             bodyShaper = shaper,
             titleShaper = shaper,
             titleShaper = shaper,
             imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
             imageDimensionsResolver = { ReaderImageDimensions(12f, 12f) },
-            imageOptionsResolver = { ReaderImageOptions(action = "js") },
+            imageOptionsResolver = { _, _ -> ReaderImageOptions(action = "js") },
         ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
         ).measure(source, style.copy(excludeActionImages = true)) as ReaderChapterMeasureResult.Success
 
 
         val paragraphs = result.blocks.filterIsInstance<ReaderMeasuredBlock.InlineParagraph>()
         val paragraphs = result.blocks.filterIsInstance<ReaderMeasuredBlock.InlineParagraph>()

+ 132 - 9
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderImageOptionsTest.kt

@@ -5,6 +5,8 @@ import io.legado.app.feature.reader.core.model.ReaderTextStyle
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
 import org.junit.Assert.assertTrue
 import org.junit.Assert.assertTrue
 import org.junit.Test
 import org.junit.Test
 
 
@@ -28,7 +30,7 @@ class ReaderImageOptionsTest {
             0, "", listOf("<img src=\"image\">"), false, false,
             0, "", listOf("<img src=\"image\">"), false, false,
         )
         )
         return ReaderChapterBlockMeasurer(
         return ReaderChapterBlockMeasurer(
-            shaper, shaper, { dimensions }, imageOptionsResolver = { options },
+            shaper, shaper, { dimensions }, imageOptionsResolver = { _, _ -> options },
         ).measure(source, style) as ReaderChapterMeasureResult.Success
         ).measure(source, style) as ReaderChapterMeasureResult.Success
     }
     }
 
 
@@ -49,10 +51,19 @@ class ReaderImageOptionsTest {
         )
         )
         val paragraph = result.blocks.single() as ReaderMeasuredBlock.InlineParagraph
         val paragraph = result.blocks.single() as ReaderMeasuredBlock.InlineParagraph
         val image = paragraph.items.single() as ReaderMeasuredInlineItem.Image
         val image = paragraph.items.single() as ReaderMeasuredInlineItem.Image
-        // 对照旧 `ImageColumn`:占位宽 = 一个字符格(字号 10f),高按原图比例换算(100×50 → 5f)。
+        // 对照旧 `ImageColumn`:占位宽 = 占位字(袮)在本段 paint 下的 advance——本测试的 shaper
+        // 给所有字 10f;高按原图比例换算(100×50 → 5f)。
         assertEquals(10f, image.widthPx, 0f)
         assertEquals(10f, image.widthPx, 0f)
         assertEquals(5f, image.heightPx, 0f)
         assertEquals(5f, image.heightPx, 0f)
-        assertEquals("run()", image.action)
+        // 书级文字嵌入分支旧版不解析 JSON,连行内图的 click 也没有。
+        assertNull(image.action)
+
+        val element = ReaderPaginator.paginateBlocks(result.blocks, config)
+            .single().elements.single() as ReaderElement.Image
+        // 行内图必须带标记,绘制期才会按位图长宽比重算几何(旧 ImageColumn 语义)。
+        assertTrue(element.inline)
+        assertEquals(10f, element.bounds.width, 0f)
+        assertEquals(5f, element.bounds.height, 0f)
     }
     }
 
 
     @Test
     @Test
@@ -60,26 +71,116 @@ class ReaderImageOptionsTest {
         val result = measure(ReaderImageOptions(requestedWidthFraction = .5f))
         val result = measure(ReaderImageOptions(requestedWidthFraction = .5f))
         val paragraph = result.blocks.single() as ReaderMeasuredBlock.InlineParagraph
         val paragraph = result.blocks.single() as ReaderMeasuredBlock.InlineParagraph
         val image = paragraph.items.single() as ReaderMeasuredInlineItem.Image
         val image = paragraph.items.single() as ReaderMeasuredInlineItem.Image
-        // 旧版行内图不使用 width 参数(占位宽恒为一个字符格)。
+        // 旧版行内图不使用 width 参数(占位宽只看占位字的 advance)。
         assertEquals(10f, image.widthPx, 0f)
         assertEquals(10f, image.widthPx, 0f)
         assertEquals(5f, image.heightPx, 0f)
         assertEquals(5f, image.heightPx, 0f)
     }
     }
 
 
+    /** 占位字就是旧 `ChapterProvider` 的两个替换字,朗读剔除与书签标记都依赖它们。 */
+    @Test
+    fun placeholderGlyphsMatchTheLegacyReplacementCharacters() {
+        assertEquals("袮", ReaderInlineImagePlaceholder.SRC_REPLACE.placeholderChar)
+        assertEquals("꧁", ReaderInlineImagePlaceholder.REVIEW.placeholderChar)
+    }
+
+    /**
+     * 行内图的宽 = 旧 View 插入正文的占位字(`袮` / `꧁`)在本段 paint 下的 advance,不是字号;
+     * 高按原图比例从该宽度换算(100×50 → 一半)。
+     */
+    @Test
+    fun inlineImageWidthFollowsTheLegacyPlaceholderGlyphAdvance() = runBlocking {
+        val reviewChar = ReaderInlineImagePlaceholder.REVIEW.placeholderChar
+        val placeholderShaper = ReaderTextShaper { text ->
+            val advance = if (text == reviewChar) 5f else 14f
+            GlyphClusters(text.map(Char::toString), List(text.length) { advance })
+        }
+        val source = ReaderChapterSourceParser.parse(
+            0, "", listOf("<img src=\"image\">"), false, false,
+        )
+
+        suspend fun inlineImage(
+            placeholder: ReaderInlineImagePlaceholder,
+        ): ReaderMeasuredInlineItem.Image {
+            val result = ReaderChapterBlockMeasurer(
+                placeholderShaper, placeholderShaper, { ReaderImageDimensions(100f, 50f) },
+                imageOptionsResolver = { _, _ ->
+                    ReaderImageOptions(
+                        ReaderImageLayoutMode.INLINE,
+                        inlinePlaceholder = placeholder,
+                    )
+                },
+            ).measure(source, baseStyle) as ReaderChapterMeasureResult.Success
+            return (result.blocks.single() as ReaderMeasuredBlock.InlineParagraph)
+                .items.single() as ReaderMeasuredInlineItem.Image
+        }
+
+        val srcReplace = inlineImage(ReaderInlineImagePlaceholder.SRC_REPLACE)
+        assertEquals(14f, srcReplace.widthPx, 0f)
+        assertEquals(7f, srcReplace.heightPx, 0f)
+
+        val review = inlineImage(ReaderInlineImagePlaceholder.REVIEW)
+        assertEquals(5f, review.widthPx, 0f)
+        assertEquals(2.5f, review.heightPx, 0f)
+    }
+
+    /**
+     * 旧 `TextChapterLayout` 的书级文字嵌入分支**完全不解析单图 JSON**:单图 FULL/对齐/宽度都不
+     * 覆盖书级设置,占位字也固定是 `袮`(`"style":"TEXT"` 的 `꧁` 不生效)。
+     */
     @Test
     @Test
-    fun perImageFullStyleOverridesGlobalInlineMode() = runBlocking {
+    fun bookLevelTextModeShortCircuitsPerImageOverrides() = runBlocking {
+        val reviewChar = ReaderInlineImagePlaceholder.REVIEW.placeholderChar
+        val placeholderShaper = ReaderTextShaper { text ->
+            val advance = if (text == reviewChar) 5f else 14f
+            GlyphClusters(text.map(Char::toString), List(text.length) { advance })
+        }
+        val source = ReaderChapterSourceParser.parse(
+            0, "", listOf("<img src=\"image\">"), false, false,
+        )
+        val result = ReaderChapterBlockMeasurer(
+            placeholderShaper, placeholderShaper, { ReaderImageDimensions(100f, 50f) },
+            imageOptionsResolver = { _, _ ->
+                ReaderImageOptions(
+                    ReaderImageLayoutMode.FULL_WIDTH,
+                    horizontalAlignment = ReaderTextAlignment.END,
+                    requestedWidthFraction = .5f,
+                    action = "run()",
+                    inlinePlaceholder = ReaderInlineImagePlaceholder.REVIEW,
+                )
+            },
+        ).measure(source, baseStyle.copy(imageLayoutMode = ReaderImageLayoutMode.INLINE))
+                as ReaderChapterMeasureResult.Success
+
+        // FULL 不能升级成整图,占位字仍是袮(advance 14f)而不是单图 TEXT 的 ꧁(5f)。
+        val image = (result.blocks.single() as ReaderMeasuredBlock.InlineParagraph)
+            .items.single() as ReaderMeasuredInlineItem.Image
+        assertEquals(14f, image.widthPx, 0f)
+        assertEquals(7f, image.heightPx, 0f)
+        // 旧分支连 click 也没解析:书级文字嵌入下段评气泡不可点。
+        assertNull(image.action)
+    }
+
+    /**
+     * 书级不是文字嵌入时,单图 style 才能覆盖书级设置:20×10 的小图本会因低于
+     * `standaloneImageThresholdPx` 被自动行内,单图 FULL 让它按铺满排版。
+     */
+    @Test
+    fun perImageFullStyleOverridesTheGlobalDefaultMode() = runBlocking {
         val result = measure(
         val result = measure(
             ReaderImageOptions(ReaderImageLayoutMode.FULL_WIDTH, action = "run()"),
             ReaderImageOptions(ReaderImageLayoutMode.FULL_WIDTH, action = "run()"),
-            baseStyle.copy(imageLayoutMode = ReaderImageLayoutMode.INLINE),
+            baseStyle,
             ReaderImageDimensions(20f, 10f),
             ReaderImageDimensions(20f, 10f),
         )
         )
         val block = result.blocks.single() as ReaderMeasuredBlock.Image
         val block = result.blocks.single() as ReaderMeasuredBlock.Image
         assertEquals(ReaderImageScaleMode.FIT_WIDTH, block.scaleMode)
         assertEquals(ReaderImageScaleMode.FIT_WIDTH, block.scaleMode)
-        assertEquals("run()", block.action)
+        // 整图在旧 `setTypeImage` 里根本没传 click。
+        assertNull(block.action)
         val image = ReaderPaginator.paginateBlocks(result.blocks, config)
         val image = ReaderPaginator.paginateBlocks(result.blocks, config)
             .single().elements.single() as ReaderElement.Image
             .single().elements.single() as ReaderElement.Image
         assertEquals(100f, image.bounds.width, 0f)
         assertEquals(100f, image.bounds.width, 0f)
         assertEquals(50f, image.bounds.height, 0f)
         assertEquals(50f, image.bounds.height, 0f)
-        assertEquals("run()", image.action)
+        assertNull(image.action)
+        assertFalse(image.inline)
     }
     }
 
 
     @Test fun rightAlignmentAndSinglePageOverridesReachPaginator() = runBlocking {
     @Test fun rightAlignmentAndSinglePageOverridesReachPaginator() = runBlocking {
@@ -93,10 +194,32 @@ class ReaderImageOptionsTest {
 
 
         val single = measure(
         val single = measure(
             ReaderImageOptions(ReaderImageLayoutMode.SINGLE_PAGE),
             ReaderImageOptions(ReaderImageLayoutMode.SINGLE_PAGE),
-            baseStyle.copy(imageLayoutMode = ReaderImageLayoutMode.INLINE),
+            baseStyle,
         ).blocks.single() as ReaderMeasuredBlock.Image
         ).blocks.single() as ReaderMeasuredBlock.Image
         assertTrue(single.pageBreakBefore)
         assertTrue(single.pageBreakBefore)
         assertTrue(single.pageBreakAfter)
         assertTrue(single.pageBreakAfter)
         assertEquals(ReaderImageScaleMode.FIT_PAGE, single.scaleMode)
         assertEquals(ReaderImageScaleMode.FIT_PAGE, single.scaleMode)
     }
     }
+
+    /**
+     * 旧 `setTypeImage` 的整图对齐取自"有效样式":单图 style 缺失才回落到书级(`LEFT`/`RIGHT`);
+     * 单图 style 存在但不是 LEFT/RIGHT 时只居中,不吃书级对齐。
+     */
+    @Test
+    fun bookLevelAlignmentAppliesOnlyWhenTheImageStyleIsAbsent() = runBlocking {
+        val fromBook = measure(
+            ReaderImageOptions(),
+            baseStyle.copy(imageAlignment = ReaderTextAlignment.END),
+        ).blocks.single() as ReaderMeasuredBlock.Image
+        assertEquals(ReaderTextAlignment.END, fromBook.horizontalAlignment)
+
+        val fromOptions = measure(
+            ReaderImageOptions(
+                ReaderImageLayoutMode.FULL_WIDTH,
+                horizontalAlignment = ReaderTextAlignment.CENTER,
+            ),
+            baseStyle.copy(imageAlignment = ReaderTextAlignment.END),
+        ).blocks.single() as ReaderMeasuredBlock.Image
+        assertEquals(ReaderTextAlignment.CENTER, fromOptions.horizontalAlignment)
+    }
 }
 }

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

@@ -323,6 +323,41 @@ class ReaderPaginatorTest {
         assertEquals("甲\uFFFC乙丙", page.text)
         assertEquals("甲\uFFFC乙丙", page.text)
     }
     }
 
 
+    /**
+     * 旧 `setTypeHtml` 的行末特例:图片是本行最后一项时,绘制宽改用 `measureText("\uFFFC")`
+     * ([ReaderMeasuredInlineItem.Image.lineFinalWidthPx]),断行推进仍按 span advance——行末会像
+     * 旧版一样留出空档;行中的图则用 span advance。
+     */
+    @Test
+    fun htmlInlineImageUsesTheObjectReplacementWidthOnlyAtTheLineEnd() {
+        fun drawnWidth(items: List<ReaderMeasuredInlineItem>): Float =
+            ReaderPaginator.paginateBlocks(
+                listOf(
+                    ReaderMeasuredBlock.InlineParagraph(
+                        items = items,
+                        indentCharacters = 0,
+                        alignment = ReaderTextAlignment.START,
+                        lineHeightPx = 20f,
+                        baselineOffsetPx = 15f,
+                        baseTextSizePx = 10f,
+                    )
+                ),
+                config.copy(viewportWidthPx = 100, viewportHeightPx = 100),
+            ).single().elements.filterIsInstance<ReaderElement.Image>().single().bounds.width
+
+        val htmlImage = ReaderMeasuredInlineItem.Image("icon", 40f, 24f, 0, lineFinalWidthPx = 10f)
+        assertEquals(
+            10f,
+            drawnWidth(listOf(ReaderMeasuredInlineItem.Text("甲", 10f, style, 0), htmlImage)),
+            0.01f,
+        )
+        assertEquals(
+            40f,
+            drawnWidth(listOf(htmlImage, ReaderMeasuredInlineItem.Text("甲", 10f, style, 1))),
+            0.01f,
+        )
+    }
+
     @Test
     @Test
     fun largerInlineFontExpandsLineAndBaseline() {
     fun largerInlineFontExpandsLineAndBaseline() {
         val large = style.copy(fontSizePx = 20f)
         val large = style.copy(fontSizePx = 20f)

+ 64 - 0
app/src/test/java/io/legado/app/feature/reader/core/model/ReaderImageDrawLayoutTest.kt

@@ -32,4 +32,68 @@ class ReaderImageDrawLayoutTest {
         assertNull(ReaderImageDrawLayout.fitCenter(container, 0, 100))
         assertNull(ReaderImageDrawLayout.fitCenter(container, 0, 100))
         assertNull(ReaderImageDrawLayout.fitCenter(ReaderRect(0f, 0f, 0f, 10f), 10, 10))
         assertNull(ReaderImageDrawLayout.fitCenter(ReaderRect(0f, 0f, 0f, 10f), 10, 10))
     }
     }
+
+    /**
+     * 旧 `ImageColumn.draw`:宽恒为一个字符格,高 = 格宽 / 位图宽 * 位图高,竖直居中于行盒。
+     * 位图 40×10、格宽 100f → 高 25f,中心仍是容器中心 120f。
+     */
+    @Test
+    fun `inline cell fills the character cell width and follows the bitmap aspect`() {
+        val result = ReaderImageDrawLayout.inlineCell(container, 40, 10)!!
+
+        assertEquals(10f, result.leftPx, 0f)
+        assertEquals(107.5f, result.topPx, 0f)
+        assertEquals(100f, result.widthPx, 0f)
+        assertEquals(25f, result.heightPx, 0f)
+    }
+
+    /** `div` 为负时允许高于当前行(旧注释「允许高度比字符更高」)。 */
+    @Test
+    fun `tall inline cell overflows the line box symmetrically`() {
+        val result = ReaderImageDrawLayout.inlineCell(container, 10, 40)!!
+
+        assertEquals(-80f, result.topPx, 0f)
+        assertEquals(100f, result.widthPx, 0f)
+        assertEquals(400f, result.heightPx, 0f)
+    }
+
+    /**
+     * 测量期长宽比与位图不一致(例如测量时文件还不可解码、回落到错误占位图的方形尺寸)时,
+     * 旧几何仍占满一个字符格宽;`fitCenter` 会把它缩成格内内接(这里只剩 1/4 格宽)。
+     */
+    @Test
+    fun `inline cell keeps the full cell width when the measured aspect is wrong`() {
+        val squareCell = ReaderRect(0f, 0f, 100f, 100f)
+        val letterBoxed = ReaderImageDrawLayout.fitCenter(squareCell, 25, 100)!!
+        assertEquals(25f, letterBoxed.widthPx, 0f)
+        assertEquals(37.5f, letterBoxed.leftPx, 0f)
+
+        val cell = ReaderImageDrawLayout.inlineCell(squareCell, 25, 100)!!
+        assertEquals(100f, cell.widthPx, 0f)
+        assertEquals(0f, cell.leftPx, 0f)
+        assertEquals(400f, cell.heightPx, 0f)
+        assertEquals(-150f, cell.topPx, 0f)
+    }
+
+    @Test
+    fun `invalid inline cell dimensions do not produce draw geometry`() {
+        assertNull(ReaderImageDrawLayout.inlineCell(container, 0, 10))
+        assertNull(ReaderImageDrawLayout.inlineCell(ReaderRect(0f, 0f, 0f, 10f), 10, 10))
+    }
+
+    @Test
+    fun `element dispatch keeps inline cells inline and standalone images centered`() {
+        val bounds = ReaderRect(10f, 20f, 110f, 220f)
+        val inline = ReaderElement.Image(bounds, "inline", null, inline = true)
+        val standalone = ReaderElement.Image(bounds, "standalone", null)
+
+        assertEquals(
+            ReaderImageDrawLayout.inlineCell(bounds, 40, 10),
+            ReaderImageDrawLayout.forElement(inline, 40, 10),
+        )
+        assertEquals(
+            ReaderImageDrawLayout.fitCenter(bounds, 40, 10),
+            ReaderImageDrawLayout.forElement(standalone, 40, 10),
+        )
+    }
 }
 }

+ 74 - 3
app/src/test/java/io/legado/app/feature/reader/legacy/LegacyReaderImageOptionsResolverTest.kt

@@ -1,6 +1,7 @@
 package io.legado.app.feature.reader.legacy
 package io.legado.app.feature.reader.legacy
 
 
 import io.legado.app.feature.reader.core.layout.ReaderImageLayoutMode
 import io.legado.app.feature.reader.core.layout.ReaderImageLayoutMode
+import io.legado.app.feature.reader.core.layout.ReaderInlineImagePlaceholder
 import io.legado.app.feature.reader.core.layout.ReaderTextAlignment
 import io.legado.app.feature.reader.core.layout.ReaderTextAlignment
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertNull
 import org.junit.Assert.assertNull
@@ -11,6 +12,7 @@ class LegacyReaderImageOptionsResolverTest {
     fun parsesTypedImageUrlOptions() {
     fun parsesTypedImageUrlOptions() {
         val options = LegacyReaderImageOptionsResolver.resolve(
         val options = LegacyReaderImageOptionsResolver.resolve(
             "https://example/image, {\"style\":\"right\",\"width\":\"37.5%\",\"click\":\"open(\\\"x\\\")\"}",
             "https://example/image, {\"style\":\"right\",\"width\":\"37.5%\",\"click\":\"open(\\\"x\\\")\"}",
+            htmlParagraph = false,
         )!!
         )!!
         assertEquals(ReaderImageLayoutMode.STANDALONE, options.layoutMode)
         assertEquals(ReaderImageLayoutMode.STANDALONE, options.layoutMode)
         assertEquals(ReaderTextAlignment.END, options.horizontalAlignment)
         assertEquals(ReaderTextAlignment.END, options.horizontalAlignment)
@@ -20,13 +22,82 @@ class LegacyReaderImageOptionsResolverTest {
 
 
     @Test
     @Test
     fun parsesAbsoluteWidthAndKnownModesCaseInsensitively() {
     fun parsesAbsoluteWidthAndKnownModesCaseInsensitively() {
-        val options = LegacyReaderImageOptionsResolver.resolve("x,{\"style\":\"full\",\"width\":\"123\"}")!!
+        val options = LegacyReaderImageOptionsResolver
+            .resolve("x,{\"style\":\"full\",\"width\":\"123\"}", htmlParagraph = false)!!
         assertEquals(ReaderImageLayoutMode.FULL_WIDTH, options.layoutMode)
         assertEquals(ReaderImageLayoutMode.FULL_WIDTH, options.layoutMode)
         assertEquals(123f, options.requestedWidthPx!!, 0f)
         assertEquals(123f, options.requestedWidthPx!!, 0f)
     }
     }
 
 
+    /** 旧纯文本路径的 `iStyle == "text" || iStyle == "TEXT"` 是精确匹配,迁移必须保留。 */
+    @Test
+    fun onlyExactLegacyTextSpellingsBecomeInlineImages() {
+        fun layoutMode(style: String, htmlParagraph: Boolean = false) =
+            LegacyReaderImageOptionsResolver
+                .resolve("x,{\"style\":\"$style\"}", htmlParagraph)!!.layoutMode
+
+        assertEquals(ReaderImageLayoutMode.INLINE, layoutMode("text"))
+        assertEquals(ReaderImageLayoutMode.INLINE, layoutMode("TEXT"))
+        // 混合大小写/带空格的拼写在旧 `setTypeImage` 里按整图排版(不区分大小写的前提不成立)。
+        assertEquals(ReaderImageLayoutMode.STANDALONE, layoutMode("Text"))
+        assertEquals(ReaderImageLayoutMode.STANDALONE, layoutMode("tExT"))
+        assertEquals(ReaderImageLayoutMode.STANDALONE, layoutMode("text "))
+        assertEquals(ReaderImageLayoutMode.STANDALONE, layoutMode("Text "))
+
+        // 旧 HTML 路径(`setTypeHtml`)用的是 `iStyle?.uppercase()`,不区分大小写……
+        assertEquals(ReaderImageLayoutMode.INLINE, layoutMode("Text", htmlParagraph = true))
+        assertEquals(ReaderImageLayoutMode.INLINE, layoutMode("tExT", htmlParagraph = true))
+        // ……但带空格的拼写 uppercase 之后也对不上 "TEXT"。
+        assertEquals(ReaderImageLayoutMode.STANDALONE, layoutMode("text ", htmlParagraph = true))
+        assertEquals(ReaderImageLayoutMode.STANDALONE, layoutMode("Text ", htmlParagraph = true))
+    }
+
+    /**
+     * 旧 `TextChapterLayout` 按拼写选占位字:`"text"` → `srcReplaceChar`(袮),
+     * `"TEXT"` → `reviewChar`(꧁)。两个字的 advance 不同,行内图的宽也随之不同。
+     */
+    @Test
+    fun exactTextSpellingPicksItsLegacyPlaceholderGlyph() {
+        fun placeholder(style: String, htmlParagraph: Boolean = false) =
+            LegacyReaderImageOptionsResolver
+                .resolve("x,{\"style\":\"$style\"}", htmlParagraph)!!.inlinePlaceholder
+
+        assertEquals(ReaderInlineImagePlaceholder.SRC_REPLACE, placeholder("text"))
+        assertEquals(ReaderInlineImagePlaceholder.REVIEW, placeholder("TEXT"))
+        // 非文字嵌入时占位字不参与排版(书级 TEXT 走默认的袮)。
+        assertEquals(ReaderInlineImagePlaceholder.SRC_REPLACE, placeholder("full"))
+        assertEquals(ReaderInlineImagePlaceholder.SRC_REPLACE, placeholder("Text"))
+        // 旧 HTML 路径没有占位字(行内宽来自 ImageSpan),统一取纯文本默认的袮。
+        assertEquals(
+            ReaderInlineImagePlaceholder.SRC_REPLACE,
+            placeholder("TEXT", htmlParagraph = true),
+        )
+        // 没有 style(布局模式回落到书级设置,书级 TEXT 旧版也用袮)时保持旧默认。
+        assertEquals(
+            ReaderInlineImagePlaceholder.SRC_REPLACE,
+            LegacyReaderImageOptionsResolver
+                .resolve("x,{\"click\":\"go()\"}", htmlParagraph = false)!!.inlinePlaceholder,
+        )
+    }
+
+    /**
+     * 旧 `setTypeImage` 的对齐取自"有效样式":单图 style 存在就只用它(不是 LEFT/RIGHT 即居中,
+     * 不回落到书级);只有 style 缺失时 `horizontalAlignment` 才是 null(未指定,由测量层回落书级)。
+     */
+    @Test
+    fun alignmentIsUnspecifiedOnlyWhenTheImageStyleIsAbsent() {
+        fun alignment(source: String) = LegacyReaderImageOptionsResolver
+            .resolve(source, htmlParagraph = false)!!.horizontalAlignment
+
+        assertEquals(ReaderTextAlignment.START, alignment("x,{\"style\":\"left\"}"))
+        assertEquals(ReaderTextAlignment.END, alignment("x,{\"style\":\"right\"}"))
+        assertEquals(ReaderTextAlignment.CENTER, alignment("x,{\"style\":\"full\"}"))
+        assertNull(alignment("x,{\"click\":\"go()\"}"))
+    }
+
     @Test fun malformedOrAbsentOptionsDoNotInventOverrides() {
     @Test fun malformedOrAbsentOptionsDoNotInventOverrides() {
-        assertNull(LegacyReaderImageOptionsResolver.resolve("x"))
-        assertNull(LegacyReaderImageOptionsResolver.resolve("x,{bad}"))
+        assertNull(LegacyReaderImageOptionsResolver.resolve("x", htmlParagraph = false))
+        assertNull(LegacyReaderImageOptionsResolver.resolve("x,{bad}", htmlParagraph = false))
+        assertNull(LegacyReaderImageOptionsResolver.resolve("x", htmlParagraph = true))
+        assertNull(LegacyReaderImageOptionsResolver.resolve("x,{bad}", htmlParagraph = true))
     }
     }
 }
 }

+ 6 - 1
app/src/test/java/io/legado/app/feature/reader/platform/AndroidReaderHtmlSourceResolverTest.kt

@@ -2,10 +2,10 @@ package io.legado.app.feature.reader.platform
 
 
 import android.app.Application
 import android.app.Application
 import android.graphics.Color
 import android.graphics.Color
+import io.legado.app.feature.reader.core.layout.ReaderParagraphDecorationKind
 import io.legado.app.feature.reader.core.source.ReaderChapterInlineSource
 import io.legado.app.feature.reader.core.source.ReaderChapterInlineSource
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceBlock
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceBlock
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
-import io.legado.app.feature.reader.core.layout.ReaderParagraphDecorationKind
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Assert.assertTrue
 import org.junit.Test
 import org.junit.Test
@@ -95,6 +95,11 @@ class AndroidReaderHtmlSourceResolverTest {
 
 
         assertEquals(source, image.source)
         assertEquals(source, image.source)
         assertEquals(3, image.chapterPosition)
         assertEquals(3, image.chapterPosition)
+        // 与旧 `setTypeHtml` 一致:不给 ImageGetter,框架用系统 fallback 图标生成 ImageSpan,
+        // 旧版行内图的占位宽/行盒高就是它的固有尺寸。
+        val extent = image.htmlSpanExtent!!
+        assertTrue(extent.widthPx > 0f)
+        assertTrue(extent.heightPx > 0f)
     }
     }
 
 
     @Test fun consecutiveBreaksPreserveANewlineOnlyVisualParagraphWithoutATrailingPhantomLine() {
     @Test fun consecutiveBreaksPreserveANewlineOnlyVisualParagraphWithoutATrailingPhantomLine() {