|
|
@@ -40,12 +40,36 @@ data class ReaderImageDimensions(val widthPx: Float, val heightPx: Float)
|
|
|
|
|
|
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(
|
|
|
val layoutMode: ReaderImageLayoutMode? = null,
|
|
|
val requestedWidthPx: Float? = null,
|
|
|
val requestedWidthFraction: Float? = null,
|
|
|
val horizontalAlignment: ReaderTextAlignment? = null,
|
|
|
val action: String? = null,
|
|
|
+ /** 只在 [ReaderImageLayoutMode.INLINE] 下参与排版:用哪个占位字量行内图的宽。 */
|
|
|
+ val inlinePlaceholder: ReaderInlineImagePlaceholder = ReaderInlineImagePlaceholder.SRC_REPLACE,
|
|
|
)
|
|
|
|
|
|
fun interface ReaderImageDimensionsResolver {
|
|
|
@@ -53,7 +77,13 @@ fun interface ReaderImageDimensionsResolver {
|
|
|
}
|
|
|
|
|
|
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(
|
|
|
@@ -77,6 +107,11 @@ data class ReaderChapterMeasureStyle(
|
|
|
val letterSpacingEm: Float? = null,
|
|
|
val bodyIndentText: String? = null,
|
|
|
val imageLayoutMode: ReaderImageLayoutMode = ReaderImageLayoutMode.AUTO,
|
|
|
+ /**
|
|
|
+ * 书级 `imageStyle` 为 `LEFT`/`RIGHT` 时的整图对齐。旧 `setTypeImage` 用「有效样式」
|
|
|
+ * (单图 style 优先)取对齐,单图 style 存在但不是 LEFT/RIGHT 时**不**回落到书级。
|
|
|
+ */
|
|
|
+ val imageAlignment: ReaderTextAlignment? = null,
|
|
|
val imageAvailableWidthPx: Float? = null,
|
|
|
/** true = 带 click 动作脚本的图片(段评气泡)不参与排版:不产出测量项,也不解析图片尺寸。 */
|
|
|
val excludeActionImages: Boolean = false,
|
|
|
@@ -129,7 +164,8 @@ class ReaderChapterBlockMeasurer(
|
|
|
private val imageDimensionsResolver: ReaderImageDimensionsResolver,
|
|
|
private val textShaperFactory: ReaderTextShaperFactory = ReaderTextShaperFactory { bodyShaper },
|
|
|
private val htmlSourceResolver: ReaderHtmlSourceResolver = ReaderHtmlSourceResolver { _, _ -> null },
|
|
|
- private val imageOptionsResolver: ReaderImageOptionsResolver = ReaderImageOptionsResolver { null },
|
|
|
+ private val imageOptionsResolver: ReaderImageOptionsResolver =
|
|
|
+ ReaderImageOptionsResolver { _, _ -> null },
|
|
|
private val metrics: ReaderChapterMeasureMetrics? = null,
|
|
|
) {
|
|
|
/**
|
|
|
@@ -162,6 +198,19 @@ class ReaderChapterBlockMeasurer(
|
|
|
metrics?.shape(bodyShaper, bodyIndentText) ?: bodyShaper.shape(bodyIndentText)
|
|
|
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(
|
|
|
items: List<ReaderChapterInlineSource>,
|
|
|
isTitle: Boolean,
|
|
|
@@ -172,7 +221,12 @@ class ReaderChapterBlockMeasurer(
|
|
|
restLineMarginPx: Float = 0f,
|
|
|
alignmentOverride: ReaderTextAlignment? = null,
|
|
|
decorations: List<ReaderParagraphDecoration> = emptyList(),
|
|
|
- justifyAtWordBoundaries: Boolean = false,
|
|
|
+ /**
|
|
|
+ * 该段来自旧 `setTypeHtml` 的 HTML 块。它同时决定旧的整行补空格/附加行距
|
|
|
+ * ([ReaderMeasuredBlock.InlineParagraph.justifyAtWordBoundaries])与旧 HTML 路径
|
|
|
+ * 的图片规则(见 [resolveImageLayout])。
|
|
|
+ */
|
|
|
+ fromHtmlBlock: Boolean = false,
|
|
|
) {
|
|
|
val baseStyle = if (isTitle) style.titleStyle.copy(
|
|
|
fontSizePx = style.titleStyle.fontSizePx * titleScale,
|
|
|
@@ -226,7 +280,8 @@ class ReaderChapterBlockMeasurer(
|
|
|
restLineIndentWidthPx = restLineMarginPx,
|
|
|
leadingIndentItems = leadingIndentItems,
|
|
|
decorations = decorations,
|
|
|
- justifyAtWordBoundaries = justifyAtWordBoundaries,
|
|
|
+ // 旧 HTML 块才有整行补空格与附加行距。
|
|
|
+ justifyAtWordBoundaries = fromHtmlBlock,
|
|
|
alignment = alignmentOverride ?: if (isTitle) style.titleAlignment else style.bodyAlignment,
|
|
|
lineHeightPx = lineHeight ?: baseStyle.fontSizePx,
|
|
|
baselineOffsetPx = baselineOffset ?: baseStyle.fontSizePx,
|
|
|
@@ -313,7 +368,7 @@ class ReaderChapterBlockMeasurer(
|
|
|
is ReaderChapterInlineSource.Image -> {
|
|
|
// excludeActionImages 开启时:带动作脚本的行内图(段评气泡)整体
|
|
|
// 不参与排版,且在图片尺寸解析之前跳过(不触发任何取图请求)
|
|
|
- val options = imageOptionsResolver.resolve(item.source)
|
|
|
+ val options = imageOptionsResolver.resolve(item.source, fromHtmlBlock)
|
|
|
if (style.excludeActionImages && options?.action != null) {
|
|
|
droppedActionImage = true
|
|
|
return@forEach
|
|
|
@@ -328,9 +383,8 @@ class ReaderChapterBlockMeasurer(
|
|
|
style.imageAvailableWidthPx?.times(fraction)
|
|
|
} ?: options?.requestedWidthPx
|
|
|
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 && (
|
|
|
mode == ReaderImageLayoutMode.STANDALONE ||
|
|
|
mode == ReaderImageLayoutMode.FULL_WIDTH ||
|
|
|
@@ -345,25 +399,39 @@ class ReaderChapterBlockMeasurer(
|
|
|
intrinsicWidthPx = size.widthPx,
|
|
|
intrinsicHeightPx = size.heightPx,
|
|
|
chapterPosition = item.chapterPosition,
|
|
|
- action = options?.action,
|
|
|
- horizontalAlignment = options?.horizontalAlignment ?: ReaderTextAlignment.CENTER,
|
|
|
+ // 旧 `setTypeImage` 建 ImageColumn 时根本没传 click:整图不带动作。
|
|
|
+ action = null,
|
|
|
+ horizontalAlignment = layout.alignment,
|
|
|
scaleMode = mode.toScaleMode(),
|
|
|
pageBreakBefore = mode == ReaderImageLayoutMode.SINGLE_PAGE,
|
|
|
pageBreakAfter = mode == ReaderImageLayoutMode.SINGLE_PAGE,
|
|
|
)
|
|
|
} else {
|
|
|
// 文字嵌入(行内图):对照旧 `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(
|
|
|
source = item.source,
|
|
|
widthPx = cellWidthPx,
|
|
|
- heightPx = cellWidthPx * aspect,
|
|
|
+ heightPx = cellHeightPx,
|
|
|
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 -> {
|
|
|
- val options = imageOptionsResolver.resolve(block.source)
|
|
|
+ // 独立图块是新 core 的概念,没有对应的旧分支,按纯文本路径规则处理。
|
|
|
+ val options = imageOptionsResolver.resolve(block.source, htmlParagraph = false)
|
|
|
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)
|
|
|
.coerceAtLeast(1f)
|
|
|
val originalSize = imageDimensionsResolver.resolve(block.source)
|
|
|
@@ -403,16 +487,15 @@ class ReaderChapterBlockMeasurer(
|
|
|
style.imageAvailableWidthPx?.times(fraction)
|
|
|
} ?: options?.requestedWidthPx
|
|
|
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(
|
|
|
source = block.source,
|
|
|
intrinsicWidthPx = size.widthPx,
|
|
|
intrinsicHeightPx = size.heightPx,
|
|
|
chapterPosition = block.chapterPosition,
|
|
|
- action = options?.action,
|
|
|
- horizontalAlignment = options?.horizontalAlignment ?: ReaderTextAlignment.CENTER,
|
|
|
+ // 独立图块按纯文本路径走的是整图分支,旧 `setTypeImage` 不带 click。
|
|
|
+ action = null,
|
|
|
+ horizontalAlignment = layout.alignment,
|
|
|
scaleMode = mode.toScaleMode(),
|
|
|
pageBreakBefore = mode == ReaderImageLayoutMode.SINGLE_PAGE,
|
|
|
pageBreakAfter = mode == ReaderImageLayoutMode.SINGLE_PAGE,
|
|
|
@@ -439,7 +522,7 @@ class ReaderChapterBlockMeasurer(
|
|
|
restLineMarginPx = paragraph.restLineMarginPx,
|
|
|
alignmentOverride = paragraph.alignment,
|
|
|
decorations = paragraph.decorations,
|
|
|
- justifyAtWordBoundaries = true,
|
|
|
+ fromHtmlBlock = true,
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
@@ -489,6 +572,70 @@ private fun fallbackHtmlParagraphs(
|
|
|
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 {
|
|
|
val width = requestedWidthPx?.takeIf { it > 0f && widthPx > 0f } ?: return this
|
|
|
return ReaderImageDimensions(width, heightPx * width / widthPx)
|